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.
- package/CHANGELOG.md +44 -1
- package/README.md +54 -15
- package/package.json +14 -15
- package/playwright.config.ts +5 -2
- package/src/cli.ts +289 -20
- package/src/lib/actions-transport.ts +58 -1
- package/src/lib/archive-import.ts +34 -2
- package/src/lib/backup.ts +271 -33
- package/src/lib/bird-actions.ts +21 -0
- package/src/lib/bird.ts +332 -17
- package/src/lib/blocks.ts +3 -3
- package/src/lib/bookmark-sync-job.ts +3 -3
- package/src/lib/config.ts +34 -1
- package/src/lib/db.ts +321 -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 +267 -0
- package/src/lib/mentions-live.ts +18 -7
- package/src/lib/moderation-target.ts +7 -5
- 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 +8 -24
- package/src/lib/profile-resolver.ts +428 -0
- package/src/lib/queries.ts +522 -68
- package/src/lib/research.ts +607 -0
- package/src/lib/seed.ts +10 -4
- package/src/lib/sqlite.ts +143 -0
- package/src/lib/timeline-collections-live.ts +22 -8
- package/src/lib/timeline-live.ts +182 -0
- package/src/lib/tweet-account-edges.ts +39 -0
- package/src/lib/tweet-lookup.ts +35 -0
- package/src/lib/types.ts +103 -1
- package/src/lib/url-expansion.ts +147 -0
- package/src/lib/whois.ts +1060 -0
- package/src/lib/x-profile.ts +174 -17
- package/src/lib/xurl.ts +41 -6
package/src/lib/x-profile.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
import type Database from "
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
2
|
import { normalizeAvatarUrl } from "./avatar-cache";
|
|
3
|
+
import { syncIdentitySearchIndexForProfileIds } from "./identity-search-index";
|
|
4
|
+
import { syncProfileBioEntitiesForProfileId } from "./profile-bio-entities";
|
|
5
|
+
import { syncProfileAffiliationsFromUser } from "./profile-affiliations";
|
|
6
|
+
import { recordProfileSnapshot } from "./profile-history";
|
|
3
7
|
import type { ProfileRecord, XurlMentionUser } from "./types";
|
|
4
8
|
|
|
5
9
|
export interface ResolvedXProfile {
|
|
@@ -27,30 +31,117 @@ export function randomAvatarHue(input: string) {
|
|
|
27
31
|
}
|
|
28
32
|
|
|
29
33
|
function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
34
|
+
const followingCount = Number(row.following_count ?? 0);
|
|
35
|
+
const entities = parseJsonRecord(row.entities_json);
|
|
30
36
|
return {
|
|
31
37
|
id: String(row.id),
|
|
32
38
|
handle: String(row.handle),
|
|
33
39
|
displayName: String(row.display_name),
|
|
34
40
|
bio: String(row.bio),
|
|
35
41
|
followersCount: Number(row.followers_count),
|
|
42
|
+
...(Number.isFinite(followingCount) ? { followingCount } : {}),
|
|
36
43
|
avatarHue: Number(row.avatar_hue),
|
|
37
44
|
avatarUrl:
|
|
38
45
|
typeof row.avatar_url === "string" ? String(row.avatar_url) : undefined,
|
|
46
|
+
...(typeof row.location === "string" && row.location.length > 0
|
|
47
|
+
? { location: row.location }
|
|
48
|
+
: {}),
|
|
49
|
+
...(typeof row.url === "string" && row.url.length > 0
|
|
50
|
+
? { url: row.url }
|
|
51
|
+
: {}),
|
|
52
|
+
...(typeof row.verified_type === "string" && row.verified_type.length > 0
|
|
53
|
+
? { verifiedType: row.verified_type }
|
|
54
|
+
: {}),
|
|
55
|
+
...(entities ? { entities } : {}),
|
|
39
56
|
createdAt: String(row.created_at),
|
|
40
57
|
};
|
|
41
58
|
}
|
|
42
59
|
|
|
60
|
+
function parseJsonRecord(value: unknown) {
|
|
61
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const parsed = JSON.parse(value) as unknown;
|
|
66
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
67
|
+
? (parsed as Record<string, unknown>)
|
|
68
|
+
: undefined;
|
|
69
|
+
} catch {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getString(value: unknown) {
|
|
75
|
+
return typeof value === "string" && value.trim().length > 0
|
|
76
|
+
? value.trim()
|
|
77
|
+
: undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getExpandedUrlFromEntities(
|
|
81
|
+
entities: Record<string, unknown> | undefined,
|
|
82
|
+
key: "url" | "description",
|
|
83
|
+
) {
|
|
84
|
+
const block = entities?.[key];
|
|
85
|
+
if (!block || typeof block !== "object") {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
const urls = (block as { urls?: unknown }).urls;
|
|
89
|
+
if (!Array.isArray(urls)) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
for (const entry of urls) {
|
|
93
|
+
if (!entry || typeof entry !== "object") {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const record = entry as Record<string, unknown>;
|
|
97
|
+
const expanded =
|
|
98
|
+
getString(record.expandedUrl) ?? getString(record.expanded_url);
|
|
99
|
+
if (expanded) {
|
|
100
|
+
return expanded;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function normalizeVerifiedType(user: XurlMentionUser) {
|
|
107
|
+
const verifiedType = getString(user.verified_type);
|
|
108
|
+
if (verifiedType) {
|
|
109
|
+
return verifiedType.toLowerCase();
|
|
110
|
+
}
|
|
111
|
+
return user.verified ? "verified" : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildProfileMetadata(user: XurlMentionUser) {
|
|
115
|
+
const entities = user.entities;
|
|
116
|
+
const url =
|
|
117
|
+
getExpandedUrlFromEntities(entities, "url") ?? getString(user.url);
|
|
118
|
+
return {
|
|
119
|
+
location: getString(user.location) ?? null,
|
|
120
|
+
url: url ?? null,
|
|
121
|
+
verifiedType: normalizeVerifiedType(user),
|
|
122
|
+
entitiesJson: JSON.stringify(entities ?? {}),
|
|
123
|
+
rawJson: JSON.stringify(user),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
43
127
|
function updateExistingProfileFromUser(
|
|
44
|
-
db: Database
|
|
128
|
+
db: Database,
|
|
45
129
|
profileId: string,
|
|
46
130
|
user: XurlMentionUser,
|
|
47
131
|
): ResolvedXProfile {
|
|
48
132
|
const username = String(user.username ?? "").replace(/^@/, "");
|
|
49
133
|
const displayName = String(user.name ?? "").trim() || username;
|
|
50
134
|
const followersCount = Number(user.public_metrics?.followers_count ?? 0);
|
|
135
|
+
const hasFollowingCount =
|
|
136
|
+
typeof user.public_metrics?.following_count === "number";
|
|
137
|
+
const followingCount = hasFollowingCount
|
|
138
|
+
? (user.public_metrics?.following_count ?? null)
|
|
139
|
+
: null;
|
|
51
140
|
const bio = String(user.description ?? "");
|
|
52
141
|
const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
|
|
142
|
+
const metadata = buildProfileMetadata(user);
|
|
53
143
|
|
|
144
|
+
recordProfileSnapshot(db, profileId, "pre_update");
|
|
54
145
|
db.prepare(
|
|
55
146
|
`
|
|
56
147
|
update profiles
|
|
@@ -58,15 +149,43 @@ function updateExistingProfileFromUser(
|
|
|
58
149
|
display_name = ?,
|
|
59
150
|
bio = ?,
|
|
60
151
|
followers_count = ?,
|
|
61
|
-
|
|
152
|
+
following_count = coalesce(?, following_count),
|
|
153
|
+
avatar_url = coalesce(?, avatar_url),
|
|
154
|
+
location = coalesce(?, location),
|
|
155
|
+
url = coalesce(?, url),
|
|
156
|
+
verified_type = coalesce(?, verified_type),
|
|
157
|
+
entities_json = case
|
|
158
|
+
when ? not in ('', '{}', 'null') then ?
|
|
159
|
+
else profiles.entities_json
|
|
160
|
+
end,
|
|
161
|
+
raw_json = ?
|
|
62
162
|
where id = ?
|
|
63
163
|
`,
|
|
64
|
-
).run(
|
|
164
|
+
).run(
|
|
165
|
+
username,
|
|
166
|
+
displayName,
|
|
167
|
+
bio,
|
|
168
|
+
followersCount,
|
|
169
|
+
followingCount,
|
|
170
|
+
avatarUrl,
|
|
171
|
+
metadata.location,
|
|
172
|
+
metadata.url,
|
|
173
|
+
metadata.verifiedType,
|
|
174
|
+
metadata.entitiesJson,
|
|
175
|
+
metadata.entitiesJson,
|
|
176
|
+
metadata.rawJson,
|
|
177
|
+
profileId,
|
|
178
|
+
);
|
|
179
|
+
syncProfileAffiliationsFromUser(db, profileId, user);
|
|
180
|
+
recordProfileSnapshot(db, profileId, "x_profile");
|
|
181
|
+
syncProfileBioEntitiesForProfileId(db, profileId);
|
|
182
|
+
syncIdentitySearchIndexForProfileIds(db, [profileId]);
|
|
65
183
|
|
|
66
184
|
const row = db
|
|
67
185
|
.prepare(
|
|
68
186
|
`
|
|
69
|
-
select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url,
|
|
187
|
+
select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url,
|
|
188
|
+
location, url, verified_type, entities_json, created_at
|
|
70
189
|
from profiles
|
|
71
190
|
where id = ?
|
|
72
191
|
`,
|
|
@@ -80,7 +199,7 @@ function updateExistingProfileFromUser(
|
|
|
80
199
|
}
|
|
81
200
|
|
|
82
201
|
export function upsertProfileFromXUser(
|
|
83
|
-
db: Database
|
|
202
|
+
db: Database,
|
|
84
203
|
user: XurlMentionUser,
|
|
85
204
|
): ResolvedXProfile {
|
|
86
205
|
const externalUserId = String(user.id ?? "");
|
|
@@ -111,22 +230,41 @@ export function upsertProfileFromXUser(
|
|
|
111
230
|
|
|
112
231
|
const displayName = String(user.name ?? "").trim() || username;
|
|
113
232
|
const followersCount = Number(user.public_metrics?.followers_count ?? 0);
|
|
233
|
+
const hasFollowingCount =
|
|
234
|
+
typeof user.public_metrics?.following_count === "number";
|
|
235
|
+
const followingCount = hasFollowingCount
|
|
236
|
+
? (user.public_metrics?.following_count ?? null)
|
|
237
|
+
: null;
|
|
114
238
|
const bio = String(user.description ?? "");
|
|
115
239
|
const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
|
|
240
|
+
const metadata = buildProfileMetadata(user);
|
|
116
241
|
const createdAt = new Date().toISOString();
|
|
117
242
|
const avatarHue = randomAvatarHue(username);
|
|
118
243
|
|
|
119
244
|
db.prepare(
|
|
120
245
|
`
|
|
121
246
|
insert into profiles (
|
|
122
|
-
id, handle, display_name, bio, followers_count,
|
|
123
|
-
|
|
247
|
+
id, handle, display_name, bio, followers_count, following_count, avatar_hue,
|
|
248
|
+
avatar_url, location, url, verified_type, entities_json, raw_json, created_at
|
|
249
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
124
250
|
on conflict(id) do update set
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
251
|
+
handle = excluded.handle,
|
|
252
|
+
display_name = excluded.display_name,
|
|
253
|
+
bio = excluded.bio,
|
|
254
|
+
followers_count = excluded.followers_count,
|
|
255
|
+
following_count = case
|
|
256
|
+
when ? then excluded.following_count
|
|
257
|
+
else profiles.following_count
|
|
258
|
+
end,
|
|
259
|
+
avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url),
|
|
260
|
+
location = coalesce(excluded.location, profiles.location),
|
|
261
|
+
url = coalesce(excluded.url, profiles.url),
|
|
262
|
+
verified_type = coalesce(excluded.verified_type, profiles.verified_type),
|
|
263
|
+
entities_json = case
|
|
264
|
+
when excluded.entities_json not in ('', '{}', 'null') then excluded.entities_json
|
|
265
|
+
else profiles.entities_json
|
|
266
|
+
end,
|
|
267
|
+
raw_json = excluded.raw_json
|
|
130
268
|
`,
|
|
131
269
|
).run(
|
|
132
270
|
profileId,
|
|
@@ -134,10 +272,21 @@ export function upsertProfileFromXUser(
|
|
|
134
272
|
displayName,
|
|
135
273
|
bio,
|
|
136
274
|
followersCount,
|
|
275
|
+
followingCount ?? 0,
|
|
137
276
|
avatarHue,
|
|
138
277
|
avatarUrl,
|
|
278
|
+
metadata.location,
|
|
279
|
+
metadata.url,
|
|
280
|
+
metadata.verifiedType,
|
|
281
|
+
metadata.entitiesJson,
|
|
282
|
+
metadata.rawJson,
|
|
139
283
|
createdAt,
|
|
284
|
+
hasFollowingCount ? 1 : 0,
|
|
140
285
|
);
|
|
286
|
+
syncProfileAffiliationsFromUser(db, profileId, user);
|
|
287
|
+
recordProfileSnapshot(db, profileId, "x_profile");
|
|
288
|
+
syncProfileBioEntitiesForProfileId(db, profileId);
|
|
289
|
+
syncIdentitySearchIndexForProfileIds(db, [profileId]);
|
|
141
290
|
|
|
142
291
|
return {
|
|
143
292
|
profile: {
|
|
@@ -146,8 +295,13 @@ export function upsertProfileFromXUser(
|
|
|
146
295
|
displayName,
|
|
147
296
|
bio,
|
|
148
297
|
followersCount,
|
|
298
|
+
followingCount: followingCount ?? 0,
|
|
149
299
|
avatarHue,
|
|
150
300
|
avatarUrl: avatarUrl ?? undefined,
|
|
301
|
+
...(metadata.location ? { location: metadata.location } : {}),
|
|
302
|
+
...(metadata.url ? { url: metadata.url } : {}),
|
|
303
|
+
...(metadata.verifiedType ? { verifiedType: metadata.verifiedType } : {}),
|
|
304
|
+
...(user.entities ? { entities: user.entities } : {}),
|
|
151
305
|
createdAt,
|
|
152
306
|
},
|
|
153
307
|
externalUserId,
|
|
@@ -155,14 +309,15 @@ export function upsertProfileFromXUser(
|
|
|
155
309
|
}
|
|
156
310
|
|
|
157
311
|
export function ensureStubProfileForXUser(
|
|
158
|
-
db: Database
|
|
312
|
+
db: Database,
|
|
159
313
|
externalUserId: string,
|
|
160
314
|
): ResolvedXProfile {
|
|
161
315
|
const profileId = buildExternalProfileId(externalUserId);
|
|
162
316
|
const existingRow = db
|
|
163
317
|
.prepare(
|
|
164
318
|
`
|
|
165
|
-
select id, handle, display_name, bio, followers_count, avatar_hue, avatar_url,
|
|
319
|
+
select id, handle, display_name, bio, followers_count, following_count, avatar_hue, avatar_url,
|
|
320
|
+
location, url, verified_type, entities_json, created_at
|
|
166
321
|
from profiles
|
|
167
322
|
where id = ?
|
|
168
323
|
limit 1
|
|
@@ -183,8 +338,9 @@ export function ensureStubProfileForXUser(
|
|
|
183
338
|
db.prepare(
|
|
184
339
|
`
|
|
185
340
|
insert into profiles (
|
|
186
|
-
id, handle, display_name, bio, followers_count,
|
|
187
|
-
|
|
341
|
+
id, handle, display_name, bio, followers_count, following_count, avatar_hue,
|
|
342
|
+
avatar_url, location, url, verified_type, entities_json, raw_json, created_at
|
|
343
|
+
) values (?, ?, ?, '', 0, 0, ?, null, null, null, null, '{}', '{}', ?)
|
|
188
344
|
`,
|
|
189
345
|
).run(profileId, handle, handle, avatarHue, createdAt);
|
|
190
346
|
|
|
@@ -195,6 +351,7 @@ export function ensureStubProfileForXUser(
|
|
|
195
351
|
displayName: handle,
|
|
196
352
|
bio: "",
|
|
197
353
|
followersCount: 0,
|
|
354
|
+
followingCount: 0,
|
|
198
355
|
avatarHue,
|
|
199
356
|
createdAt,
|
|
200
357
|
},
|
package/src/lib/xurl.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
TransportStatus,
|
|
5
5
|
XurlMentionsResponse,
|
|
6
6
|
XurlMentionUser,
|
|
7
|
+
XurlTweetsResponse,
|
|
7
8
|
XurlUserTweet,
|
|
8
9
|
} from "./types";
|
|
9
10
|
|
|
@@ -239,7 +240,7 @@ export async function lookupUsersByIds(ids: string[]) {
|
|
|
239
240
|
const query = new URLSearchParams({
|
|
240
241
|
ids: ids.join(","),
|
|
241
242
|
"user.fields":
|
|
242
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
243
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
243
244
|
});
|
|
244
245
|
const payload = await runJsonCommand([`/2/users?${query.toString()}`]);
|
|
245
246
|
const data = payload.data;
|
|
@@ -254,7 +255,7 @@ export async function lookupUsersByHandles(handles: string[]) {
|
|
|
254
255
|
const query = new URLSearchParams({
|
|
255
256
|
usernames: handles.map((item) => item.replace(/^@/, "")).join(","),
|
|
256
257
|
"user.fields":
|
|
257
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
258
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
258
259
|
});
|
|
259
260
|
const payload = await runJsonCommand([`/2/users/by?${query.toString()}`]);
|
|
260
261
|
const data = payload.data;
|
|
@@ -334,7 +335,7 @@ export async function listMentionsViaXurl({
|
|
|
334
335
|
expansions: "author_id",
|
|
335
336
|
"tweet.fields": "created_at,conversation_id,entities,public_metrics",
|
|
336
337
|
"user.fields":
|
|
337
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
338
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
338
339
|
});
|
|
339
340
|
if (paginationToken) {
|
|
340
341
|
query.set("pagination_token", paginationToken);
|
|
@@ -391,9 +392,10 @@ async function listTimelineCollectionViaXurl({
|
|
|
391
392
|
const query = new URLSearchParams({
|
|
392
393
|
max_results: String(maxResults),
|
|
393
394
|
expansions: "author_id",
|
|
394
|
-
"tweet.fields":
|
|
395
|
+
"tweet.fields":
|
|
396
|
+
"created_at,conversation_id,entities,public_metrics,referenced_tweets",
|
|
395
397
|
"user.fields":
|
|
396
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
398
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
397
399
|
});
|
|
398
400
|
if (paginationToken) {
|
|
399
401
|
query.set("pagination_token", paginationToken);
|
|
@@ -449,7 +451,8 @@ export async function listBlockedUsers(
|
|
|
449
451
|
) {
|
|
450
452
|
const query = new URLSearchParams({
|
|
451
453
|
max_results: "100",
|
|
452
|
-
"user.fields":
|
|
454
|
+
"user.fields":
|
|
455
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
453
456
|
});
|
|
454
457
|
if (paginationToken) {
|
|
455
458
|
query.set("pagination_token", paginationToken);
|
|
@@ -513,6 +516,38 @@ export async function listUserTweets(
|
|
|
513
516
|
};
|
|
514
517
|
}
|
|
515
518
|
|
|
519
|
+
export async function lookupTweetsByIds(
|
|
520
|
+
ids: string[],
|
|
521
|
+
): Promise<XurlTweetsResponse> {
|
|
522
|
+
if (ids.length === 0) {
|
|
523
|
+
return { data: [] };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const query = new URLSearchParams({
|
|
527
|
+
ids: ids.join(","),
|
|
528
|
+
expansions: "author_id",
|
|
529
|
+
"tweet.fields":
|
|
530
|
+
"created_at,conversation_id,entities,public_metrics,referenced_tweets",
|
|
531
|
+
"user.fields":
|
|
532
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
const payload = await runJsonCommand([`/2/tweets?${query.toString()}`]);
|
|
536
|
+
return {
|
|
537
|
+
data: Array.isArray(payload.data)
|
|
538
|
+
? (payload.data as XurlTweetsResponse["data"])
|
|
539
|
+
: [],
|
|
540
|
+
includes:
|
|
541
|
+
payload.includes && typeof payload.includes === "object"
|
|
542
|
+
? (payload.includes as XurlTweetsResponse["includes"])
|
|
543
|
+
: undefined,
|
|
544
|
+
meta:
|
|
545
|
+
payload.meta && typeof payload.meta === "object"
|
|
546
|
+
? (payload.meta as XurlTweetsResponse["meta"])
|
|
547
|
+
: undefined,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
516
551
|
export async function postViaXurl(text: string) {
|
|
517
552
|
return runShortcut(["post", text]);
|
|
518
553
|
}
|