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
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 {
|
|
@@ -28,6 +32,7 @@ export function randomAvatarHue(input: string) {
|
|
|
28
32
|
|
|
29
33
|
function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
30
34
|
const followingCount = Number(row.following_count ?? 0);
|
|
35
|
+
const entities = parseJsonRecord(row.entities_json);
|
|
31
36
|
return {
|
|
32
37
|
id: String(row.id),
|
|
33
38
|
handle: String(row.handle),
|
|
@@ -38,12 +43,89 @@ function toProfile(row: Record<string, unknown>): ProfileRecord {
|
|
|
38
43
|
avatarHue: Number(row.avatar_hue),
|
|
39
44
|
avatarUrl:
|
|
40
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 } : {}),
|
|
41
56
|
createdAt: String(row.created_at),
|
|
42
57
|
};
|
|
43
58
|
}
|
|
44
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
|
+
|
|
45
127
|
function updateExistingProfileFromUser(
|
|
46
|
-
db: Database
|
|
128
|
+
db: Database,
|
|
47
129
|
profileId: string,
|
|
48
130
|
user: XurlMentionUser,
|
|
49
131
|
): ResolvedXProfile {
|
|
@@ -57,7 +139,9 @@ function updateExistingProfileFromUser(
|
|
|
57
139
|
: null;
|
|
58
140
|
const bio = String(user.description ?? "");
|
|
59
141
|
const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
|
|
142
|
+
const metadata = buildProfileMetadata(user);
|
|
60
143
|
|
|
144
|
+
recordProfileSnapshot(db, profileId, "pre_update");
|
|
61
145
|
db.prepare(
|
|
62
146
|
`
|
|
63
147
|
update profiles
|
|
@@ -66,7 +150,15 @@ function updateExistingProfileFromUser(
|
|
|
66
150
|
bio = ?,
|
|
67
151
|
followers_count = ?,
|
|
68
152
|
following_count = coalesce(?, following_count),
|
|
69
|
-
avatar_url = coalesce(?, avatar_url)
|
|
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 = ?
|
|
70
162
|
where id = ?
|
|
71
163
|
`,
|
|
72
164
|
).run(
|
|
@@ -76,13 +168,24 @@ function updateExistingProfileFromUser(
|
|
|
76
168
|
followersCount,
|
|
77
169
|
followingCount,
|
|
78
170
|
avatarUrl,
|
|
171
|
+
metadata.location,
|
|
172
|
+
metadata.url,
|
|
173
|
+
metadata.verifiedType,
|
|
174
|
+
metadata.entitiesJson,
|
|
175
|
+
metadata.entitiesJson,
|
|
176
|
+
metadata.rawJson,
|
|
79
177
|
profileId,
|
|
80
178
|
);
|
|
179
|
+
syncProfileAffiliationsFromUser(db, profileId, user);
|
|
180
|
+
recordProfileSnapshot(db, profileId, "x_profile");
|
|
181
|
+
syncProfileBioEntitiesForProfileId(db, profileId);
|
|
182
|
+
syncIdentitySearchIndexForProfileIds(db, [profileId]);
|
|
81
183
|
|
|
82
184
|
const row = db
|
|
83
185
|
.prepare(
|
|
84
186
|
`
|
|
85
|
-
select id, handle, display_name, bio, followers_count, following_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
|
|
86
189
|
from profiles
|
|
87
190
|
where id = ?
|
|
88
191
|
`,
|
|
@@ -96,7 +199,7 @@ function updateExistingProfileFromUser(
|
|
|
96
199
|
}
|
|
97
200
|
|
|
98
201
|
export function upsertProfileFromXUser(
|
|
99
|
-
db: Database
|
|
202
|
+
db: Database,
|
|
100
203
|
user: XurlMentionUser,
|
|
101
204
|
): ResolvedXProfile {
|
|
102
205
|
const externalUserId = String(user.id ?? "");
|
|
@@ -134,14 +237,16 @@ export function upsertProfileFromXUser(
|
|
|
134
237
|
: null;
|
|
135
238
|
const bio = String(user.description ?? "");
|
|
136
239
|
const avatarUrl = normalizeAvatarUrl(user.profile_image_url);
|
|
240
|
+
const metadata = buildProfileMetadata(user);
|
|
137
241
|
const createdAt = new Date().toISOString();
|
|
138
242
|
const avatarHue = randomAvatarHue(username);
|
|
139
243
|
|
|
140
244
|
db.prepare(
|
|
141
245
|
`
|
|
142
246
|
insert into profiles (
|
|
143
|
-
id, handle, display_name, bio, followers_count, following_count, avatar_hue,
|
|
144
|
-
|
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
145
250
|
on conflict(id) do update set
|
|
146
251
|
handle = excluded.handle,
|
|
147
252
|
display_name = excluded.display_name,
|
|
@@ -151,7 +256,15 @@ export function upsertProfileFromXUser(
|
|
|
151
256
|
when ? then excluded.following_count
|
|
152
257
|
else profiles.following_count
|
|
153
258
|
end,
|
|
154
|
-
avatar_url = coalesce(excluded.avatar_url, profiles.avatar_url)
|
|
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
|
|
155
268
|
`,
|
|
156
269
|
).run(
|
|
157
270
|
profileId,
|
|
@@ -162,9 +275,18 @@ export function upsertProfileFromXUser(
|
|
|
162
275
|
followingCount ?? 0,
|
|
163
276
|
avatarHue,
|
|
164
277
|
avatarUrl,
|
|
278
|
+
metadata.location,
|
|
279
|
+
metadata.url,
|
|
280
|
+
metadata.verifiedType,
|
|
281
|
+
metadata.entitiesJson,
|
|
282
|
+
metadata.rawJson,
|
|
165
283
|
createdAt,
|
|
166
284
|
hasFollowingCount ? 1 : 0,
|
|
167
285
|
);
|
|
286
|
+
syncProfileAffiliationsFromUser(db, profileId, user);
|
|
287
|
+
recordProfileSnapshot(db, profileId, "x_profile");
|
|
288
|
+
syncProfileBioEntitiesForProfileId(db, profileId);
|
|
289
|
+
syncIdentitySearchIndexForProfileIds(db, [profileId]);
|
|
168
290
|
|
|
169
291
|
return {
|
|
170
292
|
profile: {
|
|
@@ -176,6 +298,10 @@ export function upsertProfileFromXUser(
|
|
|
176
298
|
followingCount: followingCount ?? 0,
|
|
177
299
|
avatarHue,
|
|
178
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 } : {}),
|
|
179
305
|
createdAt,
|
|
180
306
|
},
|
|
181
307
|
externalUserId,
|
|
@@ -183,14 +309,15 @@ export function upsertProfileFromXUser(
|
|
|
183
309
|
}
|
|
184
310
|
|
|
185
311
|
export function ensureStubProfileForXUser(
|
|
186
|
-
db: Database
|
|
312
|
+
db: Database,
|
|
187
313
|
externalUserId: string,
|
|
188
314
|
): ResolvedXProfile {
|
|
189
315
|
const profileId = buildExternalProfileId(externalUserId);
|
|
190
316
|
const existingRow = db
|
|
191
317
|
.prepare(
|
|
192
318
|
`
|
|
193
|
-
select id, handle, display_name, bio, followers_count, following_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
|
|
194
321
|
from profiles
|
|
195
322
|
where id = ?
|
|
196
323
|
limit 1
|
|
@@ -211,8 +338,9 @@ export function ensureStubProfileForXUser(
|
|
|
211
338
|
db.prepare(
|
|
212
339
|
`
|
|
213
340
|
insert into profiles (
|
|
214
|
-
id, handle, display_name, bio, followers_count, following_count, avatar_hue,
|
|
215
|
-
|
|
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, '{}', '{}', ?)
|
|
216
344
|
`,
|
|
217
345
|
).run(profileId, handle, handle, avatarHue, createdAt);
|
|
218
346
|
|
package/src/lib/xurl.ts
CHANGED
|
@@ -240,7 +240,7 @@ export async function lookupUsersByIds(ids: string[]) {
|
|
|
240
240
|
const query = new URLSearchParams({
|
|
241
241
|
ids: ids.join(","),
|
|
242
242
|
"user.fields":
|
|
243
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
243
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
244
244
|
});
|
|
245
245
|
const payload = await runJsonCommand([`/2/users?${query.toString()}`]);
|
|
246
246
|
const data = payload.data;
|
|
@@ -255,7 +255,7 @@ export async function lookupUsersByHandles(handles: string[]) {
|
|
|
255
255
|
const query = new URLSearchParams({
|
|
256
256
|
usernames: handles.map((item) => item.replace(/^@/, "")).join(","),
|
|
257
257
|
"user.fields":
|
|
258
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
258
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
259
259
|
});
|
|
260
260
|
const payload = await runJsonCommand([`/2/users/by?${query.toString()}`]);
|
|
261
261
|
const data = payload.data;
|
|
@@ -335,7 +335,7 @@ export async function listMentionsViaXurl({
|
|
|
335
335
|
expansions: "author_id",
|
|
336
336
|
"tweet.fields": "created_at,conversation_id,entities,public_metrics",
|
|
337
337
|
"user.fields":
|
|
338
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
338
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
339
339
|
});
|
|
340
340
|
if (paginationToken) {
|
|
341
341
|
query.set("pagination_token", paginationToken);
|
|
@@ -395,7 +395,7 @@ async function listTimelineCollectionViaXurl({
|
|
|
395
395
|
"tweet.fields":
|
|
396
396
|
"created_at,conversation_id,entities,public_metrics,referenced_tweets",
|
|
397
397
|
"user.fields":
|
|
398
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
398
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
399
399
|
});
|
|
400
400
|
if (paginationToken) {
|
|
401
401
|
query.set("pagination_token", paginationToken);
|
|
@@ -451,7 +451,8 @@ export async function listBlockedUsers(
|
|
|
451
451
|
) {
|
|
452
452
|
const query = new URLSearchParams({
|
|
453
453
|
max_results: "100",
|
|
454
|
-
"user.fields":
|
|
454
|
+
"user.fields":
|
|
455
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
455
456
|
});
|
|
456
457
|
if (paginationToken) {
|
|
457
458
|
query.set("pagination_token", paginationToken);
|
|
@@ -528,7 +529,7 @@ export async function lookupTweetsByIds(
|
|
|
528
529
|
"tweet.fields":
|
|
529
530
|
"created_at,conversation_id,entities,public_metrics,referenced_tweets",
|
|
530
531
|
"user.fields":
|
|
531
|
-
"description,public_metrics,profile_image_url,created_at,verified",
|
|
532
|
+
"description,entities,location,public_metrics,profile_image_url,url,created_at,verified,verified_type",
|
|
532
533
|
});
|
|
533
534
|
|
|
534
535
|
const payload = await runJsonCommand([`/2/tweets?${query.toString()}`]);
|