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.
@@ -1,5 +1,9 @@
1
- import type Database from "better-sqlite3";
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.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, created_at
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.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, avatar_url, created_at
144
- ) values (?, ?, ?, ?, ?, ?, ?, ?, ?)
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.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, created_at
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, avatar_url, created_at
215
- ) values (?, ?, ?, '', 0, 0, ?, null, ?)
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": "description,public_metrics,profile_image_url,created_at",
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()}`]);