birdclaw 0.3.0 → 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.
- package/CHANGELOG.md +19 -0
- package/README.md +37 -9
- package/package.json +4 -6
- package/src/cli.ts +176 -20
- package/src/lib/archive-import.ts +25 -0
- package/src/lib/backup.ts +269 -33
- package/src/lib/bird-actions.ts +17 -0
- package/src/lib/bird.ts +189 -0
- package/src/lib/blocks.ts +3 -3
- package/src/lib/bookmark-sync-job.ts +3 -3
- package/src/lib/db.ts +313 -14
- package/src/lib/dms-live.ts +3 -3
- package/src/lib/identity-search-index.ts +369 -0
- package/src/lib/mention-threads-live.ts +5 -9
- package/src/lib/mentions-live.ts +18 -7
- package/src/lib/moderation-target.ts +4 -4
- package/src/lib/moderation-write.ts +3 -3
- package/src/lib/profile-affiliation-hydration.ts +189 -0
- package/src/lib/profile-affiliations.ts +262 -0
- package/src/lib/profile-bio-entities.ts +318 -0
- package/src/lib/profile-history.ts +164 -0
- package/src/lib/profile-hydration.ts +4 -28
- package/src/lib/profile-resolver.ts +428 -0
- package/src/lib/queries.ts +391 -57
- package/src/lib/research.ts +11 -0
- package/src/lib/seed.ts +2 -2
- package/src/lib/sqlite.ts +143 -0
- package/src/lib/timeline-collections-live.ts +7 -7
- package/src/lib/timeline-live.ts +16 -9
- package/src/lib/tweet-account-edges.ts +39 -0
- package/src/lib/types.ts +74 -0
- package/src/lib/url-expansion.ts +147 -0
- package/src/lib/whois.ts +1060 -0
- package/src/lib/x-profile.ts +140 -12
- package/src/lib/xurl.ts +7 -6
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { DatabaseSync, type StatementSync } from "node:sqlite";
|
|
2
|
+
|
|
3
|
+
export type Database = NativeSqliteDatabase;
|
|
4
|
+
|
|
5
|
+
type DatabaseOptions = {
|
|
6
|
+
readonly?: boolean;
|
|
7
|
+
fileMustExist?: boolean;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type PragmaOptions = {
|
|
11
|
+
simple?: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type RunResult = {
|
|
15
|
+
changes: number;
|
|
16
|
+
lastInsertRowid: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function bindArgs(parameters: unknown[]) {
|
|
20
|
+
if (parameters.length === 1 && Array.isArray(parameters[0])) {
|
|
21
|
+
return parameters[0];
|
|
22
|
+
}
|
|
23
|
+
return parameters;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizeValue(value: unknown): unknown {
|
|
27
|
+
if (value instanceof Uint8Array && !Buffer.isBuffer(value)) {
|
|
28
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeRow(row: unknown): unknown {
|
|
34
|
+
if (
|
|
35
|
+
!row ||
|
|
36
|
+
typeof row !== "object" ||
|
|
37
|
+
Array.isArray(row) ||
|
|
38
|
+
Buffer.isBuffer(row)
|
|
39
|
+
) {
|
|
40
|
+
return normalizeValue(row);
|
|
41
|
+
}
|
|
42
|
+
return Object.fromEntries(
|
|
43
|
+
Object.entries(row as Record<string, unknown>).map(([key, value]) => [
|
|
44
|
+
key,
|
|
45
|
+
normalizeValue(value),
|
|
46
|
+
]),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class NativeSqliteStatement {
|
|
51
|
+
readonly reader: boolean;
|
|
52
|
+
|
|
53
|
+
constructor(private readonly statement: StatementSync) {
|
|
54
|
+
this.reader = statement.columns().length > 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
all(...parameters: unknown[]): unknown[] {
|
|
58
|
+
return this.statement.all(...bindArgs(parameters)).map(normalizeRow);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get(...parameters: unknown[]): unknown {
|
|
62
|
+
return normalizeRow(this.statement.get(...bindArgs(parameters)));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
run(...parameters: unknown[]): RunResult {
|
|
66
|
+
const result = this.statement.run(...bindArgs(parameters));
|
|
67
|
+
return {
|
|
68
|
+
changes: Number(result.changes),
|
|
69
|
+
lastInsertRowid: Number(result.lastInsertRowid),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
iterate(...parameters: unknown[]): IterableIterator<unknown> {
|
|
74
|
+
const rows = this.statement.iterate(...bindArgs(parameters));
|
|
75
|
+
return (function* () {
|
|
76
|
+
for (const row of rows) {
|
|
77
|
+
yield normalizeRow(row);
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export class NativeSqliteDatabase {
|
|
84
|
+
private transactionDepth = 0;
|
|
85
|
+
private readonly db: DatabaseSync;
|
|
86
|
+
|
|
87
|
+
constructor(path: string, options: DatabaseOptions = {}) {
|
|
88
|
+
this.db = new DatabaseSync(path, {
|
|
89
|
+
readOnly: options.readonly,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
close(): void {
|
|
94
|
+
if (!this.db.isOpen) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.db.close();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
exec(sql: string): void {
|
|
101
|
+
this.db.exec(sql);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
pragma(sql: string, options: PragmaOptions = {}): unknown {
|
|
105
|
+
const rows = this.prepare(`pragma ${sql}`).all() as Array<
|
|
106
|
+
Record<string, unknown>
|
|
107
|
+
>;
|
|
108
|
+
if (!options.simple) {
|
|
109
|
+
return rows;
|
|
110
|
+
}
|
|
111
|
+
const first = rows[0];
|
|
112
|
+
return first ? Object.values(first)[0] : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
prepare(sql: string): NativeSqliteStatement {
|
|
116
|
+
return new NativeSqliteStatement(this.db.prepare(sql));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
transaction<TArgs extends unknown[], TResult>(
|
|
120
|
+
fn: (...args: TArgs) => TResult,
|
|
121
|
+
): (...args: TArgs) => TResult {
|
|
122
|
+
return (...args: TArgs) => {
|
|
123
|
+
const nested = this.db.isTransaction;
|
|
124
|
+
const savepoint = `__birdclaw_tx_${++this.transactionDepth}`;
|
|
125
|
+
this.exec(nested ? `savepoint ${savepoint}` : "begin");
|
|
126
|
+
try {
|
|
127
|
+
const result = fn(...args);
|
|
128
|
+
this.exec(nested ? `release ${savepoint}` : "commit");
|
|
129
|
+
return result;
|
|
130
|
+
} catch (error) {
|
|
131
|
+
if (nested) {
|
|
132
|
+
this.exec(`rollback to ${savepoint}`);
|
|
133
|
+
this.exec(`release ${savepoint}`);
|
|
134
|
+
} else {
|
|
135
|
+
this.exec("rollback");
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export default NativeSqliteDatabase;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type Database from "
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
2
|
import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
|
|
3
3
|
import { getNativeDb } from "./db";
|
|
4
4
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
@@ -50,7 +50,7 @@ function assertXurlLimit(limit: number) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
function resolveAccount(db: Database
|
|
53
|
+
function resolveAccount(db: Database, accountId?: string) {
|
|
54
54
|
const row = accountId
|
|
55
55
|
? (db
|
|
56
56
|
.prepare(
|
|
@@ -97,7 +97,7 @@ function getMediaCount(tweet: XurlMentionData) {
|
|
|
97
97
|
).length;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function replaceTweetFts(db: Database
|
|
100
|
+
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
101
101
|
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
102
102
|
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
103
103
|
tweetId,
|
|
@@ -150,7 +150,7 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
function mergeTimelineCollectionIntoLocalStore(
|
|
153
|
-
db: Database
|
|
153
|
+
db: Database,
|
|
154
154
|
accountId: string,
|
|
155
155
|
kind: TimelineCollectionKind,
|
|
156
156
|
payload: XurlMentionsResponse,
|
|
@@ -170,7 +170,7 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
170
170
|
entities_json, media_json, quoted_tweet_id
|
|
171
171
|
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?)
|
|
172
172
|
on conflict(id) do update set
|
|
173
|
-
account_id =
|
|
173
|
+
account_id = tweets.account_id,
|
|
174
174
|
author_profile_id = excluded.author_profile_id,
|
|
175
175
|
kind = case
|
|
176
176
|
when tweets.kind in ('home', 'mention') then tweets.kind
|
|
@@ -185,8 +185,8 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
185
185
|
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
186
186
|
reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
|
|
187
187
|
quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
|
|
188
|
-
bookmarked =
|
|
189
|
-
liked =
|
|
188
|
+
bookmarked = tweets.bookmarked,
|
|
189
|
+
liked = tweets.liked
|
|
190
190
|
`,
|
|
191
191
|
);
|
|
192
192
|
const upsertCollection = db.prepare(`
|
package/src/lib/timeline-live.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type Database from "
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
2
|
import { listHomeTimelineViaBird } from "./bird";
|
|
3
3
|
import { getNativeDb } from "./db";
|
|
4
4
|
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
5
5
|
import type { XurlMentionData, XurlMentionsResponse } from "./types";
|
|
6
|
+
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
6
7
|
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
7
8
|
|
|
8
9
|
const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
|
|
@@ -20,7 +21,7 @@ function assertLimit(limit: number) {
|
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
function resolveAccount(db: Database
|
|
24
|
+
function resolveAccount(db: Database, accountId?: string) {
|
|
24
25
|
const row = accountId
|
|
25
26
|
? (db.prepare("select id from accounts where id = ?").get(accountId) as
|
|
26
27
|
| { id: string }
|
|
@@ -53,7 +54,7 @@ function getMediaCount(tweet: XurlMentionData) {
|
|
|
53
54
|
).length;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
|
-
function replaceTweetFts(db: Database
|
|
57
|
+
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
57
58
|
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
58
59
|
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
59
60
|
tweetId,
|
|
@@ -62,7 +63,7 @@ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
function mergeHomeTimelineIntoLocalStore(
|
|
65
|
-
db: Database
|
|
66
|
+
db: Database,
|
|
66
67
|
accountId: string,
|
|
67
68
|
payload: XurlMentionsResponse,
|
|
68
69
|
) {
|
|
@@ -77,12 +78,9 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
77
78
|
entities_json, media_json, quoted_tweet_id
|
|
78
79
|
) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
|
|
79
80
|
on conflict(id) do update set
|
|
80
|
-
account_id =
|
|
81
|
+
account_id = tweets.account_id,
|
|
81
82
|
author_profile_id = excluded.author_profile_id,
|
|
82
|
-
kind =
|
|
83
|
-
when tweets.kind = 'mention' then tweets.kind
|
|
84
|
-
else 'home'
|
|
85
|
-
end,
|
|
83
|
+
kind = tweets.kind,
|
|
86
84
|
text = excluded.text,
|
|
87
85
|
created_at = excluded.created_at,
|
|
88
86
|
like_count = excluded.like_count,
|
|
@@ -95,6 +93,7 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
95
93
|
);
|
|
96
94
|
|
|
97
95
|
db.transaction(() => {
|
|
96
|
+
const seenAt = new Date().toISOString();
|
|
98
97
|
for (const tweet of payload.data) {
|
|
99
98
|
const author =
|
|
100
99
|
usersById.get(tweet.author_id) ??
|
|
@@ -116,6 +115,14 @@ function mergeHomeTimelineIntoLocalStore(
|
|
|
116
115
|
getMediaCount(tweet),
|
|
117
116
|
JSON.stringify(tweet.entities ?? {}),
|
|
118
117
|
);
|
|
118
|
+
upsertTweetAccountEdge(db, {
|
|
119
|
+
accountId,
|
|
120
|
+
tweetId: tweet.id,
|
|
121
|
+
kind: "home",
|
|
122
|
+
source: "bird",
|
|
123
|
+
seenAt,
|
|
124
|
+
rawJson: JSON.stringify(tweet),
|
|
125
|
+
});
|
|
119
126
|
replaceTweetFts(db, tweet.id, tweet.text);
|
|
120
127
|
}
|
|
121
128
|
})();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
|
+
|
|
3
|
+
export type TweetAccountEdgeKind = "home" | "mention";
|
|
4
|
+
|
|
5
|
+
export function upsertTweetAccountEdge(
|
|
6
|
+
db: Database,
|
|
7
|
+
{
|
|
8
|
+
accountId,
|
|
9
|
+
tweetId,
|
|
10
|
+
kind,
|
|
11
|
+
source,
|
|
12
|
+
seenAt,
|
|
13
|
+
rawJson = "{}",
|
|
14
|
+
}: {
|
|
15
|
+
accountId: string;
|
|
16
|
+
tweetId: string;
|
|
17
|
+
kind: TweetAccountEdgeKind;
|
|
18
|
+
source: string;
|
|
19
|
+
seenAt: string;
|
|
20
|
+
rawJson?: string;
|
|
21
|
+
},
|
|
22
|
+
) {
|
|
23
|
+
db.prepare(`
|
|
24
|
+
insert into tweet_account_edges (
|
|
25
|
+
account_id, tweet_id, kind, first_seen_at, last_seen_at, seen_count,
|
|
26
|
+
source, raw_json, updated_at
|
|
27
|
+
) values (?, ?, ?, ?, ?, 1, ?, ?, ?)
|
|
28
|
+
on conflict(account_id, tweet_id, kind) do update set
|
|
29
|
+
first_seen_at = min(tweet_account_edges.first_seen_at, excluded.first_seen_at),
|
|
30
|
+
last_seen_at = max(tweet_account_edges.last_seen_at, excluded.last_seen_at),
|
|
31
|
+
seen_count = tweet_account_edges.seen_count + 1,
|
|
32
|
+
source = coalesce(nullif(excluded.source, ''), tweet_account_edges.source),
|
|
33
|
+
raw_json = case
|
|
34
|
+
when excluded.raw_json not in ('', '{}', 'null') then excluded.raw_json
|
|
35
|
+
else tweet_account_edges.raw_json
|
|
36
|
+
end,
|
|
37
|
+
updated_at = max(tweet_account_edges.updated_at, excluded.updated_at)
|
|
38
|
+
`).run(accountId, tweetId, kind, seenAt, seenAt, source, rawJson, seenAt);
|
|
39
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -23,9 +23,55 @@ export interface ProfileRecord {
|
|
|
23
23
|
followingCount?: number;
|
|
24
24
|
avatarHue: number;
|
|
25
25
|
avatarUrl?: string;
|
|
26
|
+
location?: string;
|
|
27
|
+
url?: string;
|
|
28
|
+
verifiedType?: string;
|
|
29
|
+
entities?: Record<string, unknown>;
|
|
30
|
+
affiliations?: ProfileAffiliation[];
|
|
31
|
+
primaryAffiliation?: ProfileAffiliation;
|
|
26
32
|
createdAt: string;
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
export interface ProfileAffiliation {
|
|
36
|
+
organizationProfileId: string;
|
|
37
|
+
organizationName?: string;
|
|
38
|
+
organizationHandle?: string;
|
|
39
|
+
badgeUrl?: string | null;
|
|
40
|
+
url?: string | null;
|
|
41
|
+
label?: string | null;
|
|
42
|
+
source: string;
|
|
43
|
+
firstSeenAt: string;
|
|
44
|
+
lastSeenAt: string;
|
|
45
|
+
isActive: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ProfileSnapshot {
|
|
49
|
+
profileId: string;
|
|
50
|
+
snapshotHash: string;
|
|
51
|
+
observedAt: string;
|
|
52
|
+
lastSeenAt: string;
|
|
53
|
+
source: string;
|
|
54
|
+
handle: string;
|
|
55
|
+
displayName: string;
|
|
56
|
+
bio: string;
|
|
57
|
+
location: string | null;
|
|
58
|
+
url: string | null;
|
|
59
|
+
verifiedType: string | null;
|
|
60
|
+
followersCount: number;
|
|
61
|
+
followingCount: number;
|
|
62
|
+
affiliations: unknown[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ProfileBioEntity {
|
|
66
|
+
profileId: string;
|
|
67
|
+
kind: "handle" | "domain" | "company_phrase";
|
|
68
|
+
value: string;
|
|
69
|
+
source: string;
|
|
70
|
+
firstSeenAt: string;
|
|
71
|
+
lastSeenAt: string;
|
|
72
|
+
isActive: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
29
75
|
export interface TweetMentionEntity {
|
|
30
76
|
username: string;
|
|
31
77
|
id?: string;
|
|
@@ -125,6 +171,25 @@ export interface DmMessageItem {
|
|
|
125
171
|
sender: ProfileRecord;
|
|
126
172
|
}
|
|
127
173
|
|
|
174
|
+
export interface UrlExpansionItem {
|
|
175
|
+
url: string;
|
|
176
|
+
expandedUrl: string;
|
|
177
|
+
finalUrl: string;
|
|
178
|
+
status: "hit" | "miss" | "error";
|
|
179
|
+
source: "cache" | "network";
|
|
180
|
+
title?: string;
|
|
181
|
+
description?: string | null;
|
|
182
|
+
error?: string;
|
|
183
|
+
updatedAt: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export interface DmSearchMatchItem {
|
|
187
|
+
message: DmMessageItem;
|
|
188
|
+
before: DmMessageItem[];
|
|
189
|
+
after: DmMessageItem[];
|
|
190
|
+
urlExpansions?: UrlExpansionItem[];
|
|
191
|
+
}
|
|
192
|
+
|
|
128
193
|
export interface DmConversationItem {
|
|
129
194
|
id: string;
|
|
130
195
|
accountId: string;
|
|
@@ -138,6 +203,7 @@ export interface DmConversationItem {
|
|
|
138
203
|
influenceScore: number;
|
|
139
204
|
influenceLabel: string;
|
|
140
205
|
participant: ProfileRecord;
|
|
206
|
+
matches?: DmSearchMatchItem[];
|
|
141
207
|
}
|
|
142
208
|
|
|
143
209
|
export interface TimelineQuery {
|
|
@@ -158,6 +224,7 @@ export interface TimelineQuery {
|
|
|
158
224
|
|
|
159
225
|
export interface DmQuery {
|
|
160
226
|
account?: string;
|
|
227
|
+
conversationIds?: string[];
|
|
161
228
|
participant?: string;
|
|
162
229
|
search?: string;
|
|
163
230
|
replyFilter?: ReplyFilter;
|
|
@@ -166,6 +233,7 @@ export interface DmQuery {
|
|
|
166
233
|
minInfluenceScore?: number;
|
|
167
234
|
maxInfluenceScore?: number;
|
|
168
235
|
sort?: "recent" | "influence";
|
|
236
|
+
context?: number;
|
|
169
237
|
limit?: number;
|
|
170
238
|
}
|
|
171
239
|
|
|
@@ -266,7 +334,13 @@ export interface XurlMentionUser {
|
|
|
266
334
|
name: string;
|
|
267
335
|
username: string;
|
|
268
336
|
description?: string;
|
|
337
|
+
location?: string;
|
|
338
|
+
url?: string;
|
|
339
|
+
verified?: boolean;
|
|
340
|
+
verified_type?: string;
|
|
269
341
|
profile_image_url?: string;
|
|
342
|
+
entities?: Record<string, unknown>;
|
|
343
|
+
affiliation?: Record<string, unknown>;
|
|
270
344
|
public_metrics?: XurlPublicMetrics;
|
|
271
345
|
created_at?: string;
|
|
272
346
|
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
2
|
+
import type { UrlExpansionItem } from "./types";
|
|
3
|
+
|
|
4
|
+
const SUCCESS_CACHE_TTL_MS = 365 * 24 * 60 * 60 * 1000;
|
|
5
|
+
const FAILURE_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
6
|
+
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
|
7
|
+
|
|
8
|
+
interface CachedUrlExpansion {
|
|
9
|
+
expandedUrl: string;
|
|
10
|
+
finalUrl: string;
|
|
11
|
+
status: UrlExpansionItem["status"];
|
|
12
|
+
title?: string;
|
|
13
|
+
description?: string | null;
|
|
14
|
+
error?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ExpandUrlsOptions {
|
|
18
|
+
refresh?: boolean;
|
|
19
|
+
successMaxAgeMs?: number;
|
|
20
|
+
failureMaxAgeMs?: number;
|
|
21
|
+
fetchImpl?: typeof fetch;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cacheKeyForUrl(url: string) {
|
|
25
|
+
return `url:expand:${url}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isFresh(updatedAt: string, maxAgeMs: number) {
|
|
29
|
+
return Date.now() - new Date(updatedAt).getTime() <= maxAgeMs;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function trimTrailingPunctuation(url: string) {
|
|
33
|
+
return url.replace(/[.,;:!?]+$/g, "");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function extractUrls(text: string) {
|
|
37
|
+
return Array.from(
|
|
38
|
+
new Set(
|
|
39
|
+
Array.from(text.matchAll(URL_REGEX), (match) =>
|
|
40
|
+
trimTrailingPunctuation(match[0]),
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
).filter((url) => url.length > 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function toExpansionItem(
|
|
47
|
+
url: string,
|
|
48
|
+
value: CachedUrlExpansion,
|
|
49
|
+
source: UrlExpansionItem["source"],
|
|
50
|
+
updatedAt: string,
|
|
51
|
+
): UrlExpansionItem {
|
|
52
|
+
return {
|
|
53
|
+
url,
|
|
54
|
+
expandedUrl: value.expandedUrl,
|
|
55
|
+
finalUrl: value.finalUrl,
|
|
56
|
+
status: value.status,
|
|
57
|
+
source,
|
|
58
|
+
...(value.title ? { title: value.title } : {}),
|
|
59
|
+
...(value.description !== undefined
|
|
60
|
+
? { description: value.description }
|
|
61
|
+
: {}),
|
|
62
|
+
...(value.error ? { error: value.error } : {}),
|
|
63
|
+
updatedAt,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function fetchExpansion(
|
|
68
|
+
url: string,
|
|
69
|
+
fetchImpl: typeof fetch,
|
|
70
|
+
): Promise<CachedUrlExpansion> {
|
|
71
|
+
try {
|
|
72
|
+
let response = await fetchImpl(url, {
|
|
73
|
+
method: "HEAD",
|
|
74
|
+
redirect: "follow",
|
|
75
|
+
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!response.url || response.url === url || response.status >= 400) {
|
|
79
|
+
response = await fetchImpl(url, {
|
|
80
|
+
method: "GET",
|
|
81
|
+
redirect: "follow",
|
|
82
|
+
headers: { "user-agent": "birdclaw/0.3 url-expander" },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const finalUrl = response.url || url;
|
|
87
|
+
return {
|
|
88
|
+
expandedUrl: finalUrl,
|
|
89
|
+
finalUrl,
|
|
90
|
+
status: response.ok || finalUrl !== url ? "hit" : "miss",
|
|
91
|
+
...(response.ok ? {} : { error: `HTTP ${response.status}` }),
|
|
92
|
+
};
|
|
93
|
+
} catch (error) {
|
|
94
|
+
return {
|
|
95
|
+
expandedUrl: url,
|
|
96
|
+
finalUrl: url,
|
|
97
|
+
status: "error",
|
|
98
|
+
error: error instanceof Error ? error.message : String(error),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function expandUrls(
|
|
104
|
+
urls: string[],
|
|
105
|
+
options: ExpandUrlsOptions = {},
|
|
106
|
+
): Promise<UrlExpansionItem[]> {
|
|
107
|
+
const uniqueUrls = Array.from(new Set(urls));
|
|
108
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
109
|
+
const results: UrlExpansionItem[] = [];
|
|
110
|
+
|
|
111
|
+
for (const url of uniqueUrls) {
|
|
112
|
+
const cached = readSyncCache<CachedUrlExpansion>(cacheKeyForUrl(url));
|
|
113
|
+
if (cached && !options.refresh) {
|
|
114
|
+
const maxAge =
|
|
115
|
+
cached.value.status === "hit"
|
|
116
|
+
? (options.successMaxAgeMs ?? SUCCESS_CACHE_TTL_MS)
|
|
117
|
+
: (options.failureMaxAgeMs ?? FAILURE_CACHE_TTL_MS);
|
|
118
|
+
if (isFresh(cached.updatedAt, maxAge)) {
|
|
119
|
+
results.push(
|
|
120
|
+
toExpansionItem(url, cached.value, "cache", cached.updatedAt),
|
|
121
|
+
);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const value = await fetchExpansion(url, fetchImpl);
|
|
127
|
+
const updatedAt = writeSyncCache(cacheKeyForUrl(url), value);
|
|
128
|
+
results.push(toExpansionItem(url, value, "network", updatedAt));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return results;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function expandUrlsFromTexts(
|
|
135
|
+
texts: string[],
|
|
136
|
+
options: ExpandUrlsOptions = {},
|
|
137
|
+
) {
|
|
138
|
+
return expandUrls(
|
|
139
|
+
texts.flatMap((text) => extractUrls(text)),
|
|
140
|
+
options,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export const __test__ = {
|
|
145
|
+
cacheKeyForUrl,
|
|
146
|
+
trimTrailingPunctuation,
|
|
147
|
+
};
|