birdclaw 0.4.0 → 0.4.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 +6 -0
- package/package.json +1 -1
- package/src/cli.ts +113 -0
- package/src/lib/archive-import.ts +2 -0
- package/src/lib/backup.ts +2 -0
- package/src/lib/db.ts +91 -0
- package/src/lib/link-index.ts +716 -0
- package/src/lib/types.ts +34 -0
- package/src/lib/url-expansion.ts +13 -5
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.1 - 2026-05-11
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Add a first-class short-link index for `t.co` URLs, including `links backfill` and `search links` so DM shares can be found through expanded tweet text, authors, dates, and media filters.
|
|
10
|
+
|
|
5
11
|
## 0.4.0 - 2026-05-09
|
|
6
12
|
|
|
7
13
|
### Added
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
} from "#/lib/config";
|
|
30
30
|
import { syncDirectMessagesViaCachedBird } from "#/lib/dms-live";
|
|
31
31
|
import { listInboxItems, scoreInbox } from "#/lib/inbox";
|
|
32
|
+
import { backfillLinkIndex, searchLinks } from "#/lib/link-index";
|
|
32
33
|
import { syncMentionThreads } from "#/lib/mention-threads-live";
|
|
33
34
|
import { exportMentionItems } from "#/lib/mentions-export";
|
|
34
35
|
import {
|
|
@@ -73,6 +74,24 @@ function printError(error: string) {
|
|
|
73
74
|
console.error(JSON.stringify({ error }));
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
function formatLinkSearchItems(items: ReturnType<typeof searchLinks>) {
|
|
78
|
+
return items
|
|
79
|
+
.map((item) => {
|
|
80
|
+
const linked = item.linkedTweet
|
|
81
|
+
? ` -> @${item.linkedTweet.author.handle}/${item.linkedTweet.id}: ${item.linkedTweet.text}`
|
|
82
|
+
: ` -> ${item.expansion.finalUrl}`;
|
|
83
|
+
const source =
|
|
84
|
+
item.occurrence.sourceKind === "dm"
|
|
85
|
+
? `dm ${item.occurrence.direction ?? ""}`.trim()
|
|
86
|
+
: "tweet";
|
|
87
|
+
const participant = item.participant
|
|
88
|
+
? ` @${item.participant.handle}`
|
|
89
|
+
: "";
|
|
90
|
+
return `${item.occurrence.createdAt} ${source}${participant}: ${item.occurrence.shortUrl}${linked}`;
|
|
91
|
+
})
|
|
92
|
+
.join("\n");
|
|
93
|
+
}
|
|
94
|
+
|
|
76
95
|
function parseNonNegativeIntegerOption(
|
|
77
96
|
value: string | undefined,
|
|
78
97
|
option: string,
|
|
@@ -364,6 +383,100 @@ searchCommand
|
|
|
364
383
|
print(items, program.opts().json ?? false);
|
|
365
384
|
});
|
|
366
385
|
|
|
386
|
+
searchCommand
|
|
387
|
+
.command("links <query>")
|
|
388
|
+
.description("Search indexed short links, expansions, and linked tweets")
|
|
389
|
+
.option("--account <accountIdOrHandle>", "Account id or handle")
|
|
390
|
+
.option("--since <date>", "Include links created at or after this date")
|
|
391
|
+
.option("--until <date>", "Include links created before this date")
|
|
392
|
+
.option("--source <kind>", "dm or tweet")
|
|
393
|
+
.option("--direction <direction>", "inbound or outbound")
|
|
394
|
+
.option("--participant <value>", "DM participant handle or name")
|
|
395
|
+
.option("--media <type>", "image, video, or gif")
|
|
396
|
+
.option("--limit <n>", "Limit results", "20")
|
|
397
|
+
.action(async (query, options) => {
|
|
398
|
+
await autoUpdateBeforeRead();
|
|
399
|
+
const items = searchLinks(query, {
|
|
400
|
+
account: options.account,
|
|
401
|
+
since: options.since,
|
|
402
|
+
until: options.until,
|
|
403
|
+
source:
|
|
404
|
+
options.source === "tweet"
|
|
405
|
+
? "tweet"
|
|
406
|
+
: options.source === "dm"
|
|
407
|
+
? "dm"
|
|
408
|
+
: undefined,
|
|
409
|
+
direction:
|
|
410
|
+
options.direction === "inbound"
|
|
411
|
+
? "inbound"
|
|
412
|
+
: options.direction === "outbound"
|
|
413
|
+
? "outbound"
|
|
414
|
+
: undefined,
|
|
415
|
+
participant: options.participant,
|
|
416
|
+
mediaType:
|
|
417
|
+
options.media === "image" ||
|
|
418
|
+
options.media === "video" ||
|
|
419
|
+
options.media === "gif"
|
|
420
|
+
? options.media
|
|
421
|
+
: undefined,
|
|
422
|
+
limit: Number(options.limit),
|
|
423
|
+
});
|
|
424
|
+
if (program.opts().json) {
|
|
425
|
+
print(items, true);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
console.log(formatLinkSearchItems(items));
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const linksCommand = program
|
|
432
|
+
.command("links")
|
|
433
|
+
.description("Build and inspect the short-link index");
|
|
434
|
+
|
|
435
|
+
linksCommand
|
|
436
|
+
.command("backfill")
|
|
437
|
+
.description("Backfill indexed URL occurrences and t.co expansions")
|
|
438
|
+
.option("--all-urls", "Index all URLs, not only t.co")
|
|
439
|
+
.option("--source <kind>", "dm or tweet")
|
|
440
|
+
.option("--refresh-url-cache", "Re-expand URLs already in the index")
|
|
441
|
+
.option("--limit <n>", "Limit network/cache expansions for this run")
|
|
442
|
+
.option("--concurrency <n>", "Concurrent URL expansion workers", "12")
|
|
443
|
+
.option("--timeout-ms <n>", "Per-redirect fetch timeout", "15000")
|
|
444
|
+
.action(async (options) => {
|
|
445
|
+
const limit = parseNonNegativeIntegerOption(options.limit, "--limit");
|
|
446
|
+
if (options.limit !== undefined && limit === undefined) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const concurrency = parseNonNegativeIntegerOption(
|
|
450
|
+
options.concurrency,
|
|
451
|
+
"--concurrency",
|
|
452
|
+
);
|
|
453
|
+
if (concurrency === undefined) {
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
const timeoutMs = parseNonNegativeIntegerOption(
|
|
457
|
+
options.timeoutMs,
|
|
458
|
+
"--timeout-ms",
|
|
459
|
+
);
|
|
460
|
+
if (timeoutMs === undefined) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const result = await backfillLinkIndex({
|
|
464
|
+
includeAllUrls: Boolean(options.allUrls),
|
|
465
|
+
refresh: Boolean(options.refreshUrlCache),
|
|
466
|
+
source:
|
|
467
|
+
options.source === "tweet"
|
|
468
|
+
? "tweet"
|
|
469
|
+
: options.source === "dm"
|
|
470
|
+
? "dm"
|
|
471
|
+
: undefined,
|
|
472
|
+
limit,
|
|
473
|
+
concurrency,
|
|
474
|
+
timeoutMs,
|
|
475
|
+
});
|
|
476
|
+
await autoSyncAfterWrite();
|
|
477
|
+
print(result, program.opts().json ?? false);
|
|
478
|
+
});
|
|
479
|
+
|
|
367
480
|
program
|
|
368
481
|
.command("whois <query>")
|
|
369
482
|
.description("Identify likely people or orgs from local DMs and tweets")
|
|
@@ -288,6 +288,8 @@ function clearImportedData() {
|
|
|
288
288
|
delete from tweet_actions;
|
|
289
289
|
delete from tweet_account_edges;
|
|
290
290
|
delete from tweet_collections;
|
|
291
|
+
delete from link_occurrences;
|
|
292
|
+
delete from url_expansions;
|
|
291
293
|
delete from dm_fts;
|
|
292
294
|
delete from tweets_fts;
|
|
293
295
|
delete from dm_messages;
|
package/src/lib/backup.ts
CHANGED
|
@@ -971,6 +971,8 @@ function clearBackupImportData(db: Database) {
|
|
|
971
971
|
delete from tweet_actions;
|
|
972
972
|
delete from tweet_account_edges;
|
|
973
973
|
delete from tweet_collections;
|
|
974
|
+
delete from link_occurrences;
|
|
975
|
+
delete from url_expansions;
|
|
974
976
|
delete from blocks;
|
|
975
977
|
delete from mutes;
|
|
976
978
|
delete from dm_fts;
|
package/src/lib/db.ts
CHANGED
|
@@ -185,6 +185,31 @@ export interface SyncCacheTable {
|
|
|
185
185
|
updated_at: string;
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
export interface UrlExpansionsTable {
|
|
189
|
+
short_url: string;
|
|
190
|
+
expanded_url: string;
|
|
191
|
+
final_url: string;
|
|
192
|
+
status: string;
|
|
193
|
+
expanded_tweet_id: string | null;
|
|
194
|
+
expanded_handle: string | null;
|
|
195
|
+
title: string | null;
|
|
196
|
+
description: string | null;
|
|
197
|
+
error: string | null;
|
|
198
|
+
source: string;
|
|
199
|
+
updated_at: string;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface LinkOccurrencesTable {
|
|
203
|
+
source_kind: "dm" | "tweet";
|
|
204
|
+
source_id: string;
|
|
205
|
+
source_position: number;
|
|
206
|
+
short_url: string;
|
|
207
|
+
account_id: string | null;
|
|
208
|
+
conversation_id: string | null;
|
|
209
|
+
direction: string | null;
|
|
210
|
+
created_at: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
188
213
|
export interface BirdclawDatabase {
|
|
189
214
|
accounts: AccountsTable;
|
|
190
215
|
profiles: ProfilesTable;
|
|
@@ -202,6 +227,8 @@ export interface BirdclawDatabase {
|
|
|
202
227
|
mutes: MutesTable;
|
|
203
228
|
ai_scores: AiScoresTable;
|
|
204
229
|
sync_cache: SyncCacheTable;
|
|
230
|
+
url_expansions: UrlExpansionsTable;
|
|
231
|
+
link_occurrences: LinkOccurrencesTable;
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
let nativeDb: Database | undefined;
|
|
@@ -407,6 +434,32 @@ const BASE_SCHEMA_SQL = `
|
|
|
407
434
|
updated_at text not null
|
|
408
435
|
);
|
|
409
436
|
|
|
437
|
+
create table if not exists url_expansions (
|
|
438
|
+
short_url text primary key,
|
|
439
|
+
expanded_url text not null,
|
|
440
|
+
final_url text not null,
|
|
441
|
+
status text not null,
|
|
442
|
+
expanded_tweet_id text,
|
|
443
|
+
expanded_handle text,
|
|
444
|
+
title text,
|
|
445
|
+
description text,
|
|
446
|
+
error text,
|
|
447
|
+
source text not null,
|
|
448
|
+
updated_at text not null
|
|
449
|
+
);
|
|
450
|
+
|
|
451
|
+
create table if not exists link_occurrences (
|
|
452
|
+
source_kind text not null,
|
|
453
|
+
source_id text not null,
|
|
454
|
+
source_position integer not null,
|
|
455
|
+
short_url text not null,
|
|
456
|
+
account_id text,
|
|
457
|
+
conversation_id text,
|
|
458
|
+
direction text,
|
|
459
|
+
created_at text not null,
|
|
460
|
+
primary key (source_kind, source_id, source_position, short_url)
|
|
461
|
+
);
|
|
462
|
+
|
|
410
463
|
create virtual table if not exists tweets_fts using fts5(
|
|
411
464
|
tweet_id unindexed,
|
|
412
465
|
text
|
|
@@ -441,6 +494,13 @@ const INDEX_SQL = `
|
|
|
441
494
|
create index if not exists idx_mutes_account_created on mutes(account_id, created_at desc);
|
|
442
495
|
create index if not exists idx_ai_scores_updated on ai_scores(updated_at desc);
|
|
443
496
|
create index if not exists idx_sync_cache_updated on sync_cache(updated_at desc);
|
|
497
|
+
create index if not exists idx_url_expansions_expanded on url_expansions(expanded_url);
|
|
498
|
+
create index if not exists idx_url_expansions_tweet on url_expansions(expanded_tweet_id);
|
|
499
|
+
create index if not exists idx_url_expansions_handle on url_expansions(expanded_handle);
|
|
500
|
+
create index if not exists idx_link_occurrences_url on link_occurrences(short_url);
|
|
501
|
+
create index if not exists idx_link_occurrences_created on link_occurrences(created_at desc);
|
|
502
|
+
create index if not exists idx_link_occurrences_account on link_occurrences(account_id, created_at desc);
|
|
503
|
+
create index if not exists idx_link_occurrences_direction on link_occurrences(direction, created_at desc);
|
|
444
504
|
`;
|
|
445
505
|
|
|
446
506
|
function getColumnNames(db: Database, tableName: string): Set<string> {
|
|
@@ -612,6 +672,36 @@ function ensureIdentitySearchIndexTable(db: Database) {
|
|
|
612
672
|
`);
|
|
613
673
|
}
|
|
614
674
|
|
|
675
|
+
function ensureLinkIndexTables(db: Database) {
|
|
676
|
+
db.exec(`
|
|
677
|
+
create table if not exists url_expansions (
|
|
678
|
+
short_url text primary key,
|
|
679
|
+
expanded_url text not null,
|
|
680
|
+
final_url text not null,
|
|
681
|
+
status text not null,
|
|
682
|
+
expanded_tweet_id text,
|
|
683
|
+
expanded_handle text,
|
|
684
|
+
title text,
|
|
685
|
+
description text,
|
|
686
|
+
error text,
|
|
687
|
+
source text not null,
|
|
688
|
+
updated_at text not null
|
|
689
|
+
);
|
|
690
|
+
|
|
691
|
+
create table if not exists link_occurrences (
|
|
692
|
+
source_kind text not null,
|
|
693
|
+
source_id text not null,
|
|
694
|
+
source_position integer not null,
|
|
695
|
+
short_url text not null,
|
|
696
|
+
account_id text,
|
|
697
|
+
conversation_id text,
|
|
698
|
+
direction text,
|
|
699
|
+
created_at text not null,
|
|
700
|
+
primary key (source_kind, source_id, source_position, short_url)
|
|
701
|
+
);
|
|
702
|
+
`);
|
|
703
|
+
}
|
|
704
|
+
|
|
615
705
|
function backfillTweetCollections(db: Database) {
|
|
616
706
|
const now = new Date().toISOString();
|
|
617
707
|
const insert = db.prepare(`
|
|
@@ -675,6 +765,7 @@ function initDatabase(options: InitDatabaseOptions = {}) {
|
|
|
675
765
|
ensureProfileSnapshotsTable(nativeDb);
|
|
676
766
|
ensureProfileBioEntitiesTable(nativeDb);
|
|
677
767
|
ensureIdentitySearchIndexTable(nativeDb);
|
|
768
|
+
ensureLinkIndexTables(nativeDb);
|
|
678
769
|
ensureSchemaIndexes(nativeDb);
|
|
679
770
|
if (options.seedDemoData !== false) {
|
|
680
771
|
seedDemoData(nativeDb);
|
|
@@ -0,0 +1,716 @@
|
|
|
1
|
+
import { getNativeDb } from "./db";
|
|
2
|
+
import type { Database } from "./sqlite";
|
|
3
|
+
import {
|
|
4
|
+
expandUrls,
|
|
5
|
+
extractUrls,
|
|
6
|
+
type ExpandUrlsOptions,
|
|
7
|
+
} from "./url-expansion";
|
|
8
|
+
import type {
|
|
9
|
+
LinkIndexItem,
|
|
10
|
+
LinkOccurrenceItem,
|
|
11
|
+
LinkSearchItem,
|
|
12
|
+
ProfileRecord,
|
|
13
|
+
TimelineItem,
|
|
14
|
+
TweetEntities,
|
|
15
|
+
TweetMediaItem,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_EXPAND_CONCURRENCY = 12;
|
|
19
|
+
|
|
20
|
+
interface TweetUrlEntityLike {
|
|
21
|
+
url?: unknown;
|
|
22
|
+
expandedUrl?: unknown;
|
|
23
|
+
expanded_url?: unknown;
|
|
24
|
+
displayUrl?: unknown;
|
|
25
|
+
display_url?: unknown;
|
|
26
|
+
title?: unknown;
|
|
27
|
+
description?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface SourceUrl {
|
|
31
|
+
url: string;
|
|
32
|
+
expandedUrl?: string;
|
|
33
|
+
title?: string;
|
|
34
|
+
description?: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LinkBackfillOptions {
|
|
38
|
+
includeAllUrls?: boolean;
|
|
39
|
+
refresh?: boolean;
|
|
40
|
+
source?: "dm" | "tweet";
|
|
41
|
+
limit?: number;
|
|
42
|
+
concurrency?: number;
|
|
43
|
+
fetchImpl?: ExpandUrlsOptions["fetchImpl"];
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface LinkBackfillResult {
|
|
48
|
+
occurrences: number;
|
|
49
|
+
uniqueUrls: number;
|
|
50
|
+
entityExpansions: number;
|
|
51
|
+
networkExpansions: number;
|
|
52
|
+
cacheExpansions: number;
|
|
53
|
+
misses: number;
|
|
54
|
+
errors: number;
|
|
55
|
+
remainingUnexpanded: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface LinkSearchOptions {
|
|
59
|
+
since?: string;
|
|
60
|
+
until?: string;
|
|
61
|
+
account?: string;
|
|
62
|
+
source?: "dm" | "tweet";
|
|
63
|
+
direction?: "inbound" | "outbound";
|
|
64
|
+
participant?: string;
|
|
65
|
+
mediaType?: "image" | "video" | "gif";
|
|
66
|
+
limit?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseJsonField<T>(value: unknown, fallback: T): T {
|
|
70
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
71
|
+
return fallback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
return JSON.parse(value) as T;
|
|
76
|
+
} catch {
|
|
77
|
+
return fallback;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getString(value: unknown) {
|
|
82
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function isIndexedUrl(url: string, includeAllUrls: boolean) {
|
|
86
|
+
if (includeAllUrls) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
92
|
+
return host === "t.co" || host.endsWith(".t.co");
|
|
93
|
+
} catch {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getTweetTarget(url: string) {
|
|
99
|
+
try {
|
|
100
|
+
const parsed = new URL(url);
|
|
101
|
+
const host = parsed.hostname.toLowerCase();
|
|
102
|
+
if (
|
|
103
|
+
host !== "x.com" &&
|
|
104
|
+
host !== "twitter.com" &&
|
|
105
|
+
host !== "mobile.twitter.com" &&
|
|
106
|
+
host !== "www.x.com" &&
|
|
107
|
+
host !== "www.twitter.com"
|
|
108
|
+
) {
|
|
109
|
+
return {};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parts = parsed.pathname.split("/").filter(Boolean);
|
|
113
|
+
const statusIndex = parts.findIndex(
|
|
114
|
+
(part) => part === "status" || part === "statuses",
|
|
115
|
+
);
|
|
116
|
+
if (statusIndex === -1 || !parts[statusIndex + 1]) {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const tweetId = parts[statusIndex + 1]?.match(/^\d+/)?.[0];
|
|
121
|
+
const handle =
|
|
122
|
+
parts[statusIndex - 1] && parts[statusIndex - 1] !== "i"
|
|
123
|
+
? parts[statusIndex - 1]
|
|
124
|
+
: undefined;
|
|
125
|
+
return {
|
|
126
|
+
expandedTweetId: tweetId,
|
|
127
|
+
expandedHandle: handle,
|
|
128
|
+
};
|
|
129
|
+
} catch {
|
|
130
|
+
return {};
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function normalizeExpansion(item: {
|
|
135
|
+
url: string;
|
|
136
|
+
expandedUrl: string;
|
|
137
|
+
finalUrl: string;
|
|
138
|
+
status: "hit" | "miss" | "error";
|
|
139
|
+
title?: string;
|
|
140
|
+
description?: string | null;
|
|
141
|
+
error?: string;
|
|
142
|
+
source: string;
|
|
143
|
+
updatedAt: string;
|
|
144
|
+
}): LinkIndexItem {
|
|
145
|
+
const target = getTweetTarget(item.finalUrl);
|
|
146
|
+
return {
|
|
147
|
+
shortUrl: item.url,
|
|
148
|
+
expandedUrl: item.expandedUrl,
|
|
149
|
+
finalUrl: item.finalUrl,
|
|
150
|
+
status: item.status,
|
|
151
|
+
expandedTweetId: target.expandedTweetId ?? null,
|
|
152
|
+
expandedHandle: target.expandedHandle ?? null,
|
|
153
|
+
title: item.title ?? null,
|
|
154
|
+
description: item.description ?? null,
|
|
155
|
+
error: item.error ?? null,
|
|
156
|
+
source: item.source,
|
|
157
|
+
updatedAt: item.updatedAt,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function upsertExpansion(db: Database, item: LinkIndexItem) {
|
|
162
|
+
db.prepare(`
|
|
163
|
+
insert into url_expansions (
|
|
164
|
+
short_url, expanded_url, final_url, status, expanded_tweet_id,
|
|
165
|
+
expanded_handle, title, description, error, source, updated_at
|
|
166
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
167
|
+
on conflict(short_url) do update set
|
|
168
|
+
expanded_url = excluded.expanded_url,
|
|
169
|
+
final_url = excluded.final_url,
|
|
170
|
+
status = excluded.status,
|
|
171
|
+
expanded_tweet_id = excluded.expanded_tweet_id,
|
|
172
|
+
expanded_handle = excluded.expanded_handle,
|
|
173
|
+
title = excluded.title,
|
|
174
|
+
description = excluded.description,
|
|
175
|
+
error = excluded.error,
|
|
176
|
+
source = excluded.source,
|
|
177
|
+
updated_at = excluded.updated_at
|
|
178
|
+
`).run(
|
|
179
|
+
item.shortUrl,
|
|
180
|
+
item.expandedUrl,
|
|
181
|
+
item.finalUrl,
|
|
182
|
+
item.status,
|
|
183
|
+
item.expandedTweetId,
|
|
184
|
+
item.expandedHandle,
|
|
185
|
+
item.title,
|
|
186
|
+
item.description,
|
|
187
|
+
item.error,
|
|
188
|
+
item.source,
|
|
189
|
+
item.updatedAt,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function toTweetEntityUrls(entitiesJson: string): SourceUrl[] {
|
|
194
|
+
const entities = parseJsonField<TweetEntities>(entitiesJson, {});
|
|
195
|
+
const urls = Array.isArray(entities.urls) ? entities.urls : [];
|
|
196
|
+
return urls
|
|
197
|
+
.map((entry: TweetUrlEntityLike) => {
|
|
198
|
+
const url = getString(entry.url);
|
|
199
|
+
const expandedUrl =
|
|
200
|
+
getString(entry.expandedUrl) ?? getString(entry.expanded_url);
|
|
201
|
+
if (!url) {
|
|
202
|
+
return undefined;
|
|
203
|
+
}
|
|
204
|
+
return {
|
|
205
|
+
url,
|
|
206
|
+
...(expandedUrl ? { expandedUrl } : {}),
|
|
207
|
+
...(getString(entry.title) ? { title: getString(entry.title) } : {}),
|
|
208
|
+
...(entry.description !== undefined
|
|
209
|
+
? { description: getString(entry.description) ?? null }
|
|
210
|
+
: {}),
|
|
211
|
+
};
|
|
212
|
+
})
|
|
213
|
+
.filter((entry): entry is SourceUrl => Boolean(entry));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function uniqueSourceUrls(
|
|
217
|
+
text: string,
|
|
218
|
+
entityUrls: SourceUrl[],
|
|
219
|
+
includeAllUrls: boolean,
|
|
220
|
+
) {
|
|
221
|
+
const byUrl = new Map<string, SourceUrl>();
|
|
222
|
+
for (const url of extractUrls(text)) {
|
|
223
|
+
if (isIndexedUrl(url, includeAllUrls)) {
|
|
224
|
+
byUrl.set(url, { url });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const entityUrl of entityUrls) {
|
|
228
|
+
if (!isIndexedUrl(entityUrl.url, includeAllUrls)) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
byUrl.set(entityUrl.url, { ...byUrl.get(entityUrl.url), ...entityUrl });
|
|
232
|
+
}
|
|
233
|
+
return Array.from(byUrl.values());
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function rebuildOccurrences(
|
|
237
|
+
db: Database,
|
|
238
|
+
includeAllUrls: boolean,
|
|
239
|
+
source: LinkBackfillOptions["source"],
|
|
240
|
+
) {
|
|
241
|
+
const insert = db.prepare(`
|
|
242
|
+
insert or replace into link_occurrences (
|
|
243
|
+
source_kind, source_id, source_position, short_url, account_id,
|
|
244
|
+
conversation_id, direction, created_at
|
|
245
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
246
|
+
`);
|
|
247
|
+
let occurrences = 0;
|
|
248
|
+
let entityExpansions = 0;
|
|
249
|
+
const now = new Date().toISOString();
|
|
250
|
+
|
|
251
|
+
db.transaction(() => {
|
|
252
|
+
if (source) {
|
|
253
|
+
db.prepare("delete from link_occurrences where source_kind = ?").run(
|
|
254
|
+
source,
|
|
255
|
+
);
|
|
256
|
+
} else {
|
|
257
|
+
db.exec("delete from link_occurrences");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const dmRows =
|
|
261
|
+
source === "tweet"
|
|
262
|
+
? []
|
|
263
|
+
: (db
|
|
264
|
+
.prepare(`
|
|
265
|
+
select m.id, m.conversation_id, c.account_id, m.direction, m.created_at, m.text
|
|
266
|
+
from dm_messages m
|
|
267
|
+
join dm_conversations c on c.id = m.conversation_id
|
|
268
|
+
where m.text like '%://%'
|
|
269
|
+
`)
|
|
270
|
+
.all() as Array<Record<string, unknown>>);
|
|
271
|
+
for (const row of dmRows) {
|
|
272
|
+
const urls = uniqueSourceUrls(String(row.text), [], includeAllUrls);
|
|
273
|
+
urls.forEach((entry, index) => {
|
|
274
|
+
insert.run(
|
|
275
|
+
"dm",
|
|
276
|
+
String(row.id),
|
|
277
|
+
index,
|
|
278
|
+
entry.url,
|
|
279
|
+
String(row.account_id),
|
|
280
|
+
String(row.conversation_id),
|
|
281
|
+
String(row.direction),
|
|
282
|
+
String(row.created_at),
|
|
283
|
+
);
|
|
284
|
+
occurrences++;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const tweetRows =
|
|
289
|
+
source === "dm"
|
|
290
|
+
? []
|
|
291
|
+
: (db
|
|
292
|
+
.prepare(`
|
|
293
|
+
select id, account_id, created_at, text, entities_json
|
|
294
|
+
from tweets
|
|
295
|
+
where text like '%://%' or entities_json like '%://%'
|
|
296
|
+
`)
|
|
297
|
+
.all() as Array<Record<string, unknown>>);
|
|
298
|
+
for (const row of tweetRows) {
|
|
299
|
+
const entityUrls = toTweetEntityUrls(String(row.entities_json));
|
|
300
|
+
const urls = uniqueSourceUrls(
|
|
301
|
+
String(row.text),
|
|
302
|
+
entityUrls,
|
|
303
|
+
includeAllUrls,
|
|
304
|
+
);
|
|
305
|
+
urls.forEach((entry, index) => {
|
|
306
|
+
insert.run(
|
|
307
|
+
"tweet",
|
|
308
|
+
String(row.id),
|
|
309
|
+
index,
|
|
310
|
+
entry.url,
|
|
311
|
+
String(row.account_id),
|
|
312
|
+
null,
|
|
313
|
+
null,
|
|
314
|
+
String(row.created_at),
|
|
315
|
+
);
|
|
316
|
+
occurrences++;
|
|
317
|
+
if (entry.expandedUrl) {
|
|
318
|
+
upsertExpansion(
|
|
319
|
+
db,
|
|
320
|
+
normalizeExpansion({
|
|
321
|
+
url: entry.url,
|
|
322
|
+
expandedUrl: entry.expandedUrl,
|
|
323
|
+
finalUrl: entry.expandedUrl,
|
|
324
|
+
status: "hit",
|
|
325
|
+
title: entry.title,
|
|
326
|
+
description: entry.description,
|
|
327
|
+
source: "entity",
|
|
328
|
+
updatedAt: now,
|
|
329
|
+
}),
|
|
330
|
+
);
|
|
331
|
+
entityExpansions++;
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
})();
|
|
336
|
+
|
|
337
|
+
return { occurrences, entityExpansions };
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function expandWithConcurrency(
|
|
341
|
+
db: Database,
|
|
342
|
+
urls: string[],
|
|
343
|
+
options: LinkBackfillOptions,
|
|
344
|
+
) {
|
|
345
|
+
const concurrency = Math.max(
|
|
346
|
+
1,
|
|
347
|
+
Math.min(options.concurrency ?? DEFAULT_EXPAND_CONCURRENCY, 64),
|
|
348
|
+
);
|
|
349
|
+
const counts = {
|
|
350
|
+
networkExpansions: 0,
|
|
351
|
+
cacheExpansions: 0,
|
|
352
|
+
misses: 0,
|
|
353
|
+
errors: 0,
|
|
354
|
+
};
|
|
355
|
+
let nextIndex = 0;
|
|
356
|
+
|
|
357
|
+
async function worker() {
|
|
358
|
+
for (;;) {
|
|
359
|
+
const index = nextIndex++;
|
|
360
|
+
const url = urls[index];
|
|
361
|
+
if (!url) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const result = (
|
|
365
|
+
await expandUrls([url], {
|
|
366
|
+
refresh: options.refresh,
|
|
367
|
+
fetchImpl: options.fetchImpl,
|
|
368
|
+
timeoutMs: options.timeoutMs,
|
|
369
|
+
})
|
|
370
|
+
)[0]!;
|
|
371
|
+
if (result.source === "network") {
|
|
372
|
+
counts.networkExpansions++;
|
|
373
|
+
} else {
|
|
374
|
+
counts.cacheExpansions++;
|
|
375
|
+
}
|
|
376
|
+
if (result.status === "miss") {
|
|
377
|
+
counts.misses++;
|
|
378
|
+
}
|
|
379
|
+
if (result.status === "error") {
|
|
380
|
+
counts.errors++;
|
|
381
|
+
}
|
|
382
|
+
upsertExpansion(db, normalizeExpansion(result));
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
await Promise.all(
|
|
387
|
+
Array.from({ length: Math.min(concurrency, urls.length) }, () => worker()),
|
|
388
|
+
);
|
|
389
|
+
return counts;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function backfillLinkIndex(
|
|
393
|
+
options: LinkBackfillOptions = {},
|
|
394
|
+
): Promise<LinkBackfillResult> {
|
|
395
|
+
const db = getNativeDb({ seedDemoData: false });
|
|
396
|
+
const { occurrences, entityExpansions } = rebuildOccurrences(
|
|
397
|
+
db,
|
|
398
|
+
Boolean(options.includeAllUrls),
|
|
399
|
+
options.source,
|
|
400
|
+
);
|
|
401
|
+
const limit =
|
|
402
|
+
typeof options.limit === "number" && Number.isFinite(options.limit)
|
|
403
|
+
? Math.max(0, Math.trunc(options.limit))
|
|
404
|
+
: undefined;
|
|
405
|
+
const needsExpansionClause = options.refresh
|
|
406
|
+
? "1 = 1"
|
|
407
|
+
: "(e.short_url is null or e.status in ('error', 'miss'))";
|
|
408
|
+
|
|
409
|
+
const urlsToExpand = db
|
|
410
|
+
.prepare(`
|
|
411
|
+
select distinct o.short_url
|
|
412
|
+
from link_occurrences o
|
|
413
|
+
left join url_expansions e on e.short_url = o.short_url
|
|
414
|
+
where ${needsExpansionClause}
|
|
415
|
+
${options.source ? "and o.source_kind = ?" : ""}
|
|
416
|
+
order by o.short_url
|
|
417
|
+
${limit === undefined ? "" : "limit ?"}
|
|
418
|
+
`)
|
|
419
|
+
.all(
|
|
420
|
+
...(options.source ? [options.source] : []),
|
|
421
|
+
...(limit === undefined ? [] : [limit]),
|
|
422
|
+
) as Array<{
|
|
423
|
+
short_url: string;
|
|
424
|
+
}>;
|
|
425
|
+
|
|
426
|
+
const expansionCounts = await expandWithConcurrency(
|
|
427
|
+
db,
|
|
428
|
+
urlsToExpand.map((row) => row.short_url),
|
|
429
|
+
options,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const uniqueUrls = db
|
|
433
|
+
.prepare(`
|
|
434
|
+
select count(distinct short_url) as count
|
|
435
|
+
from link_occurrences
|
|
436
|
+
${options.source ? "where source_kind = ?" : ""}
|
|
437
|
+
`)
|
|
438
|
+
.get(...(options.source ? [options.source] : [])) as { count: number };
|
|
439
|
+
const remaining = db
|
|
440
|
+
.prepare(`
|
|
441
|
+
select count(distinct o.short_url) as count
|
|
442
|
+
from link_occurrences o
|
|
443
|
+
left join url_expansions e on e.short_url = o.short_url
|
|
444
|
+
where (e.short_url is null or e.status in ('error', 'miss'))
|
|
445
|
+
${options.source ? "and o.source_kind = ?" : ""}
|
|
446
|
+
`)
|
|
447
|
+
.get(...(options.source ? [options.source] : [])) as { count: number };
|
|
448
|
+
|
|
449
|
+
return {
|
|
450
|
+
occurrences,
|
|
451
|
+
uniqueUrls: Number(uniqueUrls.count),
|
|
452
|
+
entityExpansions,
|
|
453
|
+
...expansionCounts,
|
|
454
|
+
remainingUnexpanded: Number(remaining.count),
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function likePattern(value: string) {
|
|
459
|
+
return `%${value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_").toLowerCase()}%`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function toProfile(
|
|
463
|
+
row: Record<string, unknown>,
|
|
464
|
+
prefix: string,
|
|
465
|
+
): ProfileRecord | null {
|
|
466
|
+
if (!row[`${prefix}id`]) {
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
return {
|
|
470
|
+
id: String(row[`${prefix}id`]),
|
|
471
|
+
handle: String(row[`${prefix}handle`]),
|
|
472
|
+
displayName: String(row[`${prefix}display_name`]),
|
|
473
|
+
bio: String(row[`${prefix}bio`]),
|
|
474
|
+
followersCount: Number(row[`${prefix}followers_count`]),
|
|
475
|
+
followingCount: Number(row[`${prefix}following_count`]),
|
|
476
|
+
avatarHue: Number(row[`${prefix}avatar_hue`]),
|
|
477
|
+
avatarUrl: getString(row[`${prefix}avatar_url`]),
|
|
478
|
+
location: getString(row[`${prefix}location`]),
|
|
479
|
+
url: getString(row[`${prefix}url`]),
|
|
480
|
+
verifiedType: getString(row[`${prefix}verified_type`]),
|
|
481
|
+
entities: parseJsonField<Record<string, unknown>>(
|
|
482
|
+
row[`${prefix}entities_json`],
|
|
483
|
+
{},
|
|
484
|
+
),
|
|
485
|
+
createdAt: String(row[`${prefix}created_at`]),
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function toLinkedTweet(row: Record<string, unknown>): TimelineItem | null {
|
|
490
|
+
const author = toProfile(row, "linked_author_");
|
|
491
|
+
if (!row.linked_id || !author) {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
id: String(row.linked_id),
|
|
497
|
+
accountId: String(row.linked_account_id),
|
|
498
|
+
accountHandle: String(row.linked_account_handle ?? ""),
|
|
499
|
+
kind: String(row.linked_kind) as TimelineItem["kind"],
|
|
500
|
+
text: String(row.linked_text),
|
|
501
|
+
createdAt: String(row.linked_created_at),
|
|
502
|
+
isReplied: Boolean(row.linked_is_replied),
|
|
503
|
+
likeCount: Number(row.linked_like_count),
|
|
504
|
+
mediaCount: Number(row.linked_media_count),
|
|
505
|
+
bookmarked: Boolean(row.linked_bookmarked),
|
|
506
|
+
liked: Boolean(row.linked_liked),
|
|
507
|
+
author,
|
|
508
|
+
entities: parseJsonField<TweetEntities>(row.linked_entities_json, {}),
|
|
509
|
+
media: parseJsonField<TweetMediaItem[]>(row.linked_media_json, []),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function toLinkSearchItem(row: Record<string, unknown>): LinkSearchItem {
|
|
514
|
+
const occurrence: LinkOccurrenceItem = {
|
|
515
|
+
sourceKind: String(row.source_kind) as LinkOccurrenceItem["sourceKind"],
|
|
516
|
+
sourceId: String(row.source_id),
|
|
517
|
+
sourcePosition: Number(row.source_position),
|
|
518
|
+
shortUrl: String(row.short_url),
|
|
519
|
+
accountId: getString(row.account_id) ?? null,
|
|
520
|
+
conversationId: getString(row.conversation_id) ?? null,
|
|
521
|
+
direction: getString(row.direction) ?? null,
|
|
522
|
+
createdAt: String(row.created_at),
|
|
523
|
+
};
|
|
524
|
+
return {
|
|
525
|
+
occurrence,
|
|
526
|
+
expansion: {
|
|
527
|
+
shortUrl: String(row.short_url),
|
|
528
|
+
expandedUrl: String(row.expanded_url),
|
|
529
|
+
finalUrl: String(row.final_url),
|
|
530
|
+
status: String(row.status) as LinkIndexItem["status"],
|
|
531
|
+
expandedTweetId: getString(row.expanded_tweet_id) ?? null,
|
|
532
|
+
expandedHandle: getString(row.expanded_handle) ?? null,
|
|
533
|
+
title: getString(row.title) ?? null,
|
|
534
|
+
description: getString(row.description) ?? null,
|
|
535
|
+
error: getString(row.error) ?? null,
|
|
536
|
+
source: String(row.expansion_source),
|
|
537
|
+
updatedAt: String(row.updated_at),
|
|
538
|
+
},
|
|
539
|
+
sourceText: String(row.source_text),
|
|
540
|
+
sourceAuthor: toProfile(row, "source_author_"),
|
|
541
|
+
participant: toProfile(row, "participant_"),
|
|
542
|
+
linkedTweet: toLinkedTweet(row),
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
export function searchLinks(query: string, options: LinkSearchOptions = {}) {
|
|
547
|
+
const db = getNativeDb({ seedDemoData: false });
|
|
548
|
+
const conditions: string[] = [];
|
|
549
|
+
const params: unknown[] = [];
|
|
550
|
+
const searchFields = `
|
|
551
|
+
lower(
|
|
552
|
+
coalesce(e.short_url, '') || ' ' ||
|
|
553
|
+
coalesce(e.expanded_url, '') || ' ' ||
|
|
554
|
+
coalesce(e.final_url, '') || ' ' ||
|
|
555
|
+
coalesce(e.expanded_handle, '') || ' ' ||
|
|
556
|
+
coalesce(e.title, '') || ' ' ||
|
|
557
|
+
coalesce(e.description, '') || ' ' ||
|
|
558
|
+
coalesce(dm.text, '') || ' ' ||
|
|
559
|
+
coalesce(source_tweet.text, '') || ' ' ||
|
|
560
|
+
coalesce(linked.text, '') || ' ' ||
|
|
561
|
+
coalesce(linked_author.handle, '') || ' ' ||
|
|
562
|
+
coalesce(linked_author.display_name, '')
|
|
563
|
+
)
|
|
564
|
+
`;
|
|
565
|
+
|
|
566
|
+
for (const term of query.match(/[\p{L}\p{N}_:.@/-]+/gu) ?? []) {
|
|
567
|
+
conditions.push(`${searchFields} like ? escape '\\'`);
|
|
568
|
+
params.push(likePattern(term));
|
|
569
|
+
}
|
|
570
|
+
if (options.since) {
|
|
571
|
+
conditions.push("o.created_at >= ?");
|
|
572
|
+
params.push(options.since);
|
|
573
|
+
}
|
|
574
|
+
if (options.until) {
|
|
575
|
+
conditions.push("o.created_at < ?");
|
|
576
|
+
params.push(options.until);
|
|
577
|
+
}
|
|
578
|
+
if (options.account) {
|
|
579
|
+
conditions.push("(o.account_id = ? or account.handle = ?)");
|
|
580
|
+
params.push(options.account, options.account.replace(/^@/, ""));
|
|
581
|
+
}
|
|
582
|
+
if (options.source) {
|
|
583
|
+
conditions.push("o.source_kind = ?");
|
|
584
|
+
params.push(options.source);
|
|
585
|
+
}
|
|
586
|
+
if (options.direction) {
|
|
587
|
+
conditions.push("o.direction = ?");
|
|
588
|
+
params.push(options.direction);
|
|
589
|
+
}
|
|
590
|
+
if (options.participant) {
|
|
591
|
+
conditions.push(
|
|
592
|
+
"(participant.handle = ? or participant.display_name like ?)",
|
|
593
|
+
);
|
|
594
|
+
params.push(
|
|
595
|
+
options.participant.replace(/^@/, ""),
|
|
596
|
+
`%${options.participant}%`,
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
if (options.mediaType) {
|
|
600
|
+
conditions.push(
|
|
601
|
+
"(linked.media_json like ? or source_tweet.media_json like ?)",
|
|
602
|
+
);
|
|
603
|
+
params.push(
|
|
604
|
+
`%"type":"${options.mediaType}"%`,
|
|
605
|
+
`%"type":"${options.mediaType}"%`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const limit =
|
|
610
|
+
options.limit && Number.isFinite(options.limit)
|
|
611
|
+
? Math.max(1, Math.trunc(options.limit))
|
|
612
|
+
: 20;
|
|
613
|
+
params.push(limit);
|
|
614
|
+
|
|
615
|
+
const rows = db
|
|
616
|
+
.prepare(`
|
|
617
|
+
select
|
|
618
|
+
o.source_kind,
|
|
619
|
+
o.source_id,
|
|
620
|
+
o.source_position,
|
|
621
|
+
o.short_url,
|
|
622
|
+
o.account_id,
|
|
623
|
+
o.conversation_id,
|
|
624
|
+
o.direction,
|
|
625
|
+
o.created_at,
|
|
626
|
+
e.expanded_url,
|
|
627
|
+
e.final_url,
|
|
628
|
+
e.status,
|
|
629
|
+
e.expanded_tweet_id,
|
|
630
|
+
e.expanded_handle,
|
|
631
|
+
e.title,
|
|
632
|
+
e.description,
|
|
633
|
+
e.error,
|
|
634
|
+
e.source as expansion_source,
|
|
635
|
+
e.updated_at,
|
|
636
|
+
coalesce(dm.text, source_tweet.text, '') as source_text,
|
|
637
|
+
account.handle as account_handle,
|
|
638
|
+
source_author.id as source_author_id,
|
|
639
|
+
source_author.handle as source_author_handle,
|
|
640
|
+
source_author.display_name as source_author_display_name,
|
|
641
|
+
source_author.bio as source_author_bio,
|
|
642
|
+
source_author.followers_count as source_author_followers_count,
|
|
643
|
+
source_author.following_count as source_author_following_count,
|
|
644
|
+
source_author.avatar_hue as source_author_avatar_hue,
|
|
645
|
+
source_author.avatar_url as source_author_avatar_url,
|
|
646
|
+
source_author.location as source_author_location,
|
|
647
|
+
source_author.url as source_author_url,
|
|
648
|
+
source_author.verified_type as source_author_verified_type,
|
|
649
|
+
source_author.entities_json as source_author_entities_json,
|
|
650
|
+
source_author.created_at as source_author_created_at,
|
|
651
|
+
participant.id as participant_id,
|
|
652
|
+
participant.handle as participant_handle,
|
|
653
|
+
participant.display_name as participant_display_name,
|
|
654
|
+
participant.bio as participant_bio,
|
|
655
|
+
participant.followers_count as participant_followers_count,
|
|
656
|
+
participant.following_count as participant_following_count,
|
|
657
|
+
participant.avatar_hue as participant_avatar_hue,
|
|
658
|
+
participant.avatar_url as participant_avatar_url,
|
|
659
|
+
participant.location as participant_location,
|
|
660
|
+
participant.url as participant_url,
|
|
661
|
+
participant.verified_type as participant_verified_type,
|
|
662
|
+
participant.entities_json as participant_entities_json,
|
|
663
|
+
participant.created_at as participant_created_at,
|
|
664
|
+
linked.id as linked_id,
|
|
665
|
+
linked.account_id as linked_account_id,
|
|
666
|
+
linked_account.handle as linked_account_handle,
|
|
667
|
+
linked.kind as linked_kind,
|
|
668
|
+
linked.text as linked_text,
|
|
669
|
+
linked.created_at as linked_created_at,
|
|
670
|
+
linked.is_replied as linked_is_replied,
|
|
671
|
+
linked.like_count as linked_like_count,
|
|
672
|
+
linked.media_count as linked_media_count,
|
|
673
|
+
linked.bookmarked as linked_bookmarked,
|
|
674
|
+
linked.liked as linked_liked,
|
|
675
|
+
linked.entities_json as linked_entities_json,
|
|
676
|
+
linked.media_json as linked_media_json,
|
|
677
|
+
linked_author.id as linked_author_id,
|
|
678
|
+
linked_author.handle as linked_author_handle,
|
|
679
|
+
linked_author.display_name as linked_author_display_name,
|
|
680
|
+
linked_author.bio as linked_author_bio,
|
|
681
|
+
linked_author.followers_count as linked_author_followers_count,
|
|
682
|
+
linked_author.following_count as linked_author_following_count,
|
|
683
|
+
linked_author.avatar_hue as linked_author_avatar_hue,
|
|
684
|
+
linked_author.avatar_url as linked_author_avatar_url,
|
|
685
|
+
linked_author.location as linked_author_location,
|
|
686
|
+
linked_author.url as linked_author_url,
|
|
687
|
+
linked_author.verified_type as linked_author_verified_type,
|
|
688
|
+
linked_author.entities_json as linked_author_entities_json,
|
|
689
|
+
linked_author.created_at as linked_author_created_at
|
|
690
|
+
from link_occurrences o
|
|
691
|
+
join url_expansions e on e.short_url = o.short_url
|
|
692
|
+
left join accounts account on account.id = o.account_id
|
|
693
|
+
left join dm_messages dm
|
|
694
|
+
on o.source_kind = 'dm' and dm.id = o.source_id
|
|
695
|
+
left join dm_conversations conversation
|
|
696
|
+
on conversation.id = o.conversation_id
|
|
697
|
+
left join profiles participant
|
|
698
|
+
on participant.id = conversation.participant_profile_id
|
|
699
|
+
left join tweets source_tweet
|
|
700
|
+
on o.source_kind = 'tweet' and source_tweet.id = o.source_id
|
|
701
|
+
left join profiles source_author
|
|
702
|
+
on source_author.id = source_tweet.author_profile_id
|
|
703
|
+
left join tweets linked
|
|
704
|
+
on linked.id = e.expanded_tweet_id
|
|
705
|
+
left join accounts linked_account
|
|
706
|
+
on linked_account.id = linked.account_id
|
|
707
|
+
left join profiles linked_author
|
|
708
|
+
on linked_author.id = linked.author_profile_id
|
|
709
|
+
${conditions.length > 0 ? `where ${conditions.join(" and ")}` : ""}
|
|
710
|
+
order by o.created_at desc
|
|
711
|
+
limit ?
|
|
712
|
+
`)
|
|
713
|
+
.all(...params) as Array<Record<string, unknown>>;
|
|
714
|
+
|
|
715
|
+
return rows.map(toLinkSearchItem);
|
|
716
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -183,6 +183,40 @@ export interface UrlExpansionItem {
|
|
|
183
183
|
updatedAt: string;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
export interface LinkOccurrenceItem {
|
|
187
|
+
sourceKind: "dm" | "tweet";
|
|
188
|
+
sourceId: string;
|
|
189
|
+
sourcePosition: number;
|
|
190
|
+
shortUrl: string;
|
|
191
|
+
accountId?: string | null;
|
|
192
|
+
conversationId?: string | null;
|
|
193
|
+
direction?: string | null;
|
|
194
|
+
createdAt: string;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface LinkIndexItem {
|
|
198
|
+
shortUrl: string;
|
|
199
|
+
expandedUrl: string;
|
|
200
|
+
finalUrl: string;
|
|
201
|
+
status: "hit" | "miss" | "error";
|
|
202
|
+
expandedTweetId?: string | null;
|
|
203
|
+
expandedHandle?: string | null;
|
|
204
|
+
title?: string | null;
|
|
205
|
+
description?: string | null;
|
|
206
|
+
error?: string | null;
|
|
207
|
+
source: string;
|
|
208
|
+
updatedAt: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export interface LinkSearchItem {
|
|
212
|
+
occurrence: LinkOccurrenceItem;
|
|
213
|
+
expansion: LinkIndexItem;
|
|
214
|
+
sourceText: string;
|
|
215
|
+
sourceAuthor?: ProfileRecord | null;
|
|
216
|
+
participant?: ProfileRecord | null;
|
|
217
|
+
linkedTweet?: TimelineItem | null;
|
|
218
|
+
}
|
|
219
|
+
|
|
186
220
|
export interface DmSearchMatchItem {
|
|
187
221
|
message: DmMessageItem;
|
|
188
222
|
before: DmMessageItem[];
|
package/src/lib/url-expansion.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { UrlExpansionItem } from "./types";
|
|
|
3
3
|
|
|
4
4
|
const SUCCESS_CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
5
5
|
const FAILURE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
6
7
|
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
|
7
8
|
|
|
8
9
|
interface CachedUrlExpansion {
|
|
@@ -19,6 +20,7 @@ export interface ExpandUrlsOptions {
|
|
|
19
20
|
successMaxAgeMs?: number;
|
|
20
21
|
failureMaxAgeMs?: number;
|
|
21
22
|
fetchImpl?: typeof fetch;
|
|
23
|
+
timeoutMs?: number;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
function cacheKeyForUrl(url: string) {
|
|
@@ -67,19 +69,24 @@ function toExpansionItem(
|
|
|
67
69
|
async function fetchExpansion(
|
|
68
70
|
url: string,
|
|
69
71
|
fetchImpl: typeof fetch,
|
|
72
|
+
timeoutMs: number,
|
|
70
73
|
): Promise<CachedUrlExpansion> {
|
|
74
|
+
const requestInit = {
|
|
75
|
+
redirect: "follow",
|
|
76
|
+
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
77
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
78
|
+
} satisfies RequestInit;
|
|
79
|
+
|
|
71
80
|
try {
|
|
72
81
|
let response = await fetchImpl(url, {
|
|
82
|
+
...requestInit,
|
|
73
83
|
method: "HEAD",
|
|
74
|
-
redirect: "follow",
|
|
75
|
-
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
76
84
|
});
|
|
77
85
|
|
|
78
86
|
if (!response.url || response.url === url || response.status >= 400) {
|
|
79
87
|
response = await fetchImpl(url, {
|
|
88
|
+
...requestInit,
|
|
80
89
|
method: "GET",
|
|
81
|
-
redirect: "follow",
|
|
82
|
-
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
83
90
|
});
|
|
84
91
|
}
|
|
85
92
|
|
|
@@ -106,6 +113,7 @@ export async function expandUrls(
|
|
|
106
113
|
): Promise<UrlExpansionItem[]> {
|
|
107
114
|
const uniqueUrls = Array.from(new Set(urls));
|
|
108
115
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
109
117
|
const results: UrlExpansionItem[] = [];
|
|
110
118
|
|
|
111
119
|
for (const url of uniqueUrls) {
|
|
@@ -123,7 +131,7 @@ export async function expandUrls(
|
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
|
|
126
|
-
const value = await fetchExpansion(url, fetchImpl);
|
|
134
|
+
const value = await fetchExpansion(url, fetchImpl, timeoutMs);
|
|
127
135
|
const updatedAt = writeSyncCache(cacheKeyForUrl(url), value);
|
|
128
136
|
results.push(toExpansionItem(url, value, "network", updatedAt));
|
|
129
137
|
}
|