birdclaw 0.3.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 +25 -0
- package/README.md +37 -9
- package/package.json +4 -6
- package/src/cli.ts +289 -20
- package/src/lib/archive-import.ts +27 -0
- package/src/lib/backup.ts +271 -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 +404 -14
- package/src/lib/dms-live.ts +3 -3
- package/src/lib/identity-search-index.ts +369 -0
- package/src/lib/link-index.ts +716 -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 +108 -0
- package/src/lib/url-expansion.ts +155 -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,59 @@ 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 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
|
+
|
|
220
|
+
export interface DmSearchMatchItem {
|
|
221
|
+
message: DmMessageItem;
|
|
222
|
+
before: DmMessageItem[];
|
|
223
|
+
after: DmMessageItem[];
|
|
224
|
+
urlExpansions?: UrlExpansionItem[];
|
|
225
|
+
}
|
|
226
|
+
|
|
128
227
|
export interface DmConversationItem {
|
|
129
228
|
id: string;
|
|
130
229
|
accountId: string;
|
|
@@ -138,6 +237,7 @@ export interface DmConversationItem {
|
|
|
138
237
|
influenceScore: number;
|
|
139
238
|
influenceLabel: string;
|
|
140
239
|
participant: ProfileRecord;
|
|
240
|
+
matches?: DmSearchMatchItem[];
|
|
141
241
|
}
|
|
142
242
|
|
|
143
243
|
export interface TimelineQuery {
|
|
@@ -158,6 +258,7 @@ export interface TimelineQuery {
|
|
|
158
258
|
|
|
159
259
|
export interface DmQuery {
|
|
160
260
|
account?: string;
|
|
261
|
+
conversationIds?: string[];
|
|
161
262
|
participant?: string;
|
|
162
263
|
search?: string;
|
|
163
264
|
replyFilter?: ReplyFilter;
|
|
@@ -166,6 +267,7 @@ export interface DmQuery {
|
|
|
166
267
|
minInfluenceScore?: number;
|
|
167
268
|
maxInfluenceScore?: number;
|
|
168
269
|
sort?: "recent" | "influence";
|
|
270
|
+
context?: number;
|
|
169
271
|
limit?: number;
|
|
170
272
|
}
|
|
171
273
|
|
|
@@ -266,7 +368,13 @@ export interface XurlMentionUser {
|
|
|
266
368
|
name: string;
|
|
267
369
|
username: string;
|
|
268
370
|
description?: string;
|
|
371
|
+
location?: string;
|
|
372
|
+
url?: string;
|
|
373
|
+
verified?: boolean;
|
|
374
|
+
verified_type?: string;
|
|
269
375
|
profile_image_url?: string;
|
|
376
|
+
entities?: Record<string, unknown>;
|
|
377
|
+
affiliation?: Record<string, unknown>;
|
|
270
378
|
public_metrics?: XurlPublicMetrics;
|
|
271
379
|
created_at?: string;
|
|
272
380
|
}
|
|
@@ -0,0 +1,155 @@
|
|
|
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 DEFAULT_FETCH_TIMEOUT_MS = 15_000;
|
|
7
|
+
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
|
8
|
+
|
|
9
|
+
interface CachedUrlExpansion {
|
|
10
|
+
expandedUrl: string;
|
|
11
|
+
finalUrl: string;
|
|
12
|
+
status: UrlExpansionItem["status"];
|
|
13
|
+
title?: string;
|
|
14
|
+
description?: string | null;
|
|
15
|
+
error?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ExpandUrlsOptions {
|
|
19
|
+
refresh?: boolean;
|
|
20
|
+
successMaxAgeMs?: number;
|
|
21
|
+
failureMaxAgeMs?: number;
|
|
22
|
+
fetchImpl?: typeof fetch;
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function cacheKeyForUrl(url: string) {
|
|
27
|
+
return `url:expand:${url}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isFresh(updatedAt: string, maxAgeMs: number) {
|
|
31
|
+
return Date.now() - new Date(updatedAt).getTime() <= maxAgeMs;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function trimTrailingPunctuation(url: string) {
|
|
35
|
+
return url.replace(/[.,;:!?]+$/g, "");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function extractUrls(text: string) {
|
|
39
|
+
return Array.from(
|
|
40
|
+
new Set(
|
|
41
|
+
Array.from(text.matchAll(URL_REGEX), (match) =>
|
|
42
|
+
trimTrailingPunctuation(match[0]),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
).filter((url) => url.length > 0);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function toExpansionItem(
|
|
49
|
+
url: string,
|
|
50
|
+
value: CachedUrlExpansion,
|
|
51
|
+
source: UrlExpansionItem["source"],
|
|
52
|
+
updatedAt: string,
|
|
53
|
+
): UrlExpansionItem {
|
|
54
|
+
return {
|
|
55
|
+
url,
|
|
56
|
+
expandedUrl: value.expandedUrl,
|
|
57
|
+
finalUrl: value.finalUrl,
|
|
58
|
+
status: value.status,
|
|
59
|
+
source,
|
|
60
|
+
...(value.title ? { title: value.title } : {}),
|
|
61
|
+
...(value.description !== undefined
|
|
62
|
+
? { description: value.description }
|
|
63
|
+
: {}),
|
|
64
|
+
...(value.error ? { error: value.error } : {}),
|
|
65
|
+
updatedAt,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function fetchExpansion(
|
|
70
|
+
url: string,
|
|
71
|
+
fetchImpl: typeof fetch,
|
|
72
|
+
timeoutMs: number,
|
|
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
|
+
|
|
80
|
+
try {
|
|
81
|
+
let response = await fetchImpl(url, {
|
|
82
|
+
...requestInit,
|
|
83
|
+
method: "HEAD",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (!response.url || response.url === url || response.status >= 400) {
|
|
87
|
+
response = await fetchImpl(url, {
|
|
88
|
+
...requestInit,
|
|
89
|
+
method: "GET",
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const finalUrl = response.url || url;
|
|
94
|
+
return {
|
|
95
|
+
expandedUrl: finalUrl,
|
|
96
|
+
finalUrl,
|
|
97
|
+
status: response.ok || finalUrl !== url ? "hit" : "miss",
|
|
98
|
+
...(response.ok ? {} : { error: `HTTP ${response.status}` }),
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
expandedUrl: url,
|
|
103
|
+
finalUrl: url,
|
|
104
|
+
status: "error",
|
|
105
|
+
error: error instanceof Error ? error.message : String(error),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function expandUrls(
|
|
111
|
+
urls: string[],
|
|
112
|
+
options: ExpandUrlsOptions = {},
|
|
113
|
+
): Promise<UrlExpansionItem[]> {
|
|
114
|
+
const uniqueUrls = Array.from(new Set(urls));
|
|
115
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
116
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
|
|
117
|
+
const results: UrlExpansionItem[] = [];
|
|
118
|
+
|
|
119
|
+
for (const url of uniqueUrls) {
|
|
120
|
+
const cached = readSyncCache<CachedUrlExpansion>(cacheKeyForUrl(url));
|
|
121
|
+
if (cached && !options.refresh) {
|
|
122
|
+
const maxAge =
|
|
123
|
+
cached.value.status === "hit"
|
|
124
|
+
? (options.successMaxAgeMs ?? SUCCESS_CACHE_TTL_MS)
|
|
125
|
+
: (options.failureMaxAgeMs ?? FAILURE_CACHE_TTL_MS);
|
|
126
|
+
if (isFresh(cached.updatedAt, maxAge)) {
|
|
127
|
+
results.push(
|
|
128
|
+
toExpansionItem(url, cached.value, "cache", cached.updatedAt),
|
|
129
|
+
);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const value = await fetchExpansion(url, fetchImpl, timeoutMs);
|
|
135
|
+
const updatedAt = writeSyncCache(cacheKeyForUrl(url), value);
|
|
136
|
+
results.push(toExpansionItem(url, value, "network", updatedAt));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function expandUrlsFromTexts(
|
|
143
|
+
texts: string[],
|
|
144
|
+
options: ExpandUrlsOptions = {},
|
|
145
|
+
) {
|
|
146
|
+
return expandUrls(
|
|
147
|
+
texts.flatMap((text) => extractUrls(text)),
|
|
148
|
+
options,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const __test__ = {
|
|
153
|
+
cacheKeyForUrl,
|
|
154
|
+
trimTrailingPunctuation,
|
|
155
|
+
};
|