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
|
@@ -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,
|
|
@@ -105,6 +105,12 @@ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
|
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
function getReferencedTweetId(tweet: XurlMentionData, type: string) {
|
|
109
|
+
return (
|
|
110
|
+
tweet.referenced_tweets?.find((item) => item.type === type)?.id ?? null
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
108
114
|
function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
|
|
109
115
|
const tweets: XurlMentionData[] = [];
|
|
110
116
|
const seenTweetIds = new Set<string>();
|
|
@@ -144,7 +150,7 @@ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
|
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
function mergeTimelineCollectionIntoLocalStore(
|
|
147
|
-
db: Database
|
|
153
|
+
db: Database,
|
|
148
154
|
accountId: string,
|
|
149
155
|
kind: TimelineCollectionKind,
|
|
150
156
|
payload: XurlMentionsResponse,
|
|
@@ -162,9 +168,9 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
162
168
|
id, account_id, author_profile_id, kind, text, created_at,
|
|
163
169
|
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
164
170
|
entities_json, media_json, quoted_tweet_id
|
|
165
|
-
) values (?, ?, ?, ?, ?, ?,
|
|
171
|
+
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '[]', ?)
|
|
166
172
|
on conflict(id) do update set
|
|
167
|
-
account_id =
|
|
173
|
+
account_id = tweets.account_id,
|
|
168
174
|
author_profile_id = excluded.author_profile_id,
|
|
169
175
|
kind = case
|
|
170
176
|
when tweets.kind in ('home', 'mention') then tweets.kind
|
|
@@ -176,8 +182,11 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
176
182
|
media_count = excluded.media_count,
|
|
177
183
|
entities_json = excluded.entities_json,
|
|
178
184
|
media_json = excluded.media_json,
|
|
179
|
-
|
|
180
|
-
|
|
185
|
+
is_replied = max(tweets.is_replied, excluded.is_replied),
|
|
186
|
+
reply_to_id = coalesce(excluded.reply_to_id, tweets.reply_to_id),
|
|
187
|
+
quoted_tweet_id = coalesce(excluded.quoted_tweet_id, tweets.quoted_tweet_id),
|
|
188
|
+
bookmarked = tweets.bookmarked,
|
|
189
|
+
liked = tweets.liked
|
|
181
190
|
`,
|
|
182
191
|
);
|
|
183
192
|
const upsertCollection = db.prepare(`
|
|
@@ -203,6 +212,8 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
203
212
|
const profile = usersById.has(tweet.author_id)
|
|
204
213
|
? upsertProfileFromXUser(db, author)
|
|
205
214
|
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
215
|
+
const replyToId = getReferencedTweetId(tweet, "replied_to");
|
|
216
|
+
const quotedTweetId = getReferencedTweetId(tweet, "quoted");
|
|
206
217
|
upsertTweet.run(
|
|
207
218
|
tweet.id,
|
|
208
219
|
accountId,
|
|
@@ -210,11 +221,14 @@ function mergeTimelineCollectionIntoLocalStore(
|
|
|
210
221
|
tweetKind,
|
|
211
222
|
tweet.text,
|
|
212
223
|
tweet.created_at,
|
|
224
|
+
replyToId ? 1 : 0,
|
|
225
|
+
replyToId,
|
|
213
226
|
Number(tweet.public_metrics?.like_count ?? 0),
|
|
214
227
|
getMediaCount(tweet),
|
|
215
228
|
bookmarked,
|
|
216
229
|
liked,
|
|
217
230
|
JSON.stringify(tweet.entities ?? {}),
|
|
231
|
+
quotedTweetId,
|
|
218
232
|
);
|
|
219
233
|
upsertCollection.run(
|
|
220
234
|
accountId,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { Database } from "./sqlite";
|
|
2
|
+
import { listHomeTimelineViaBird } from "./bird";
|
|
3
|
+
import { getNativeDb } from "./db";
|
|
4
|
+
import { readSyncCache, writeSyncCache } from "./sync-cache";
|
|
5
|
+
import type { XurlMentionData, XurlMentionsResponse } from "./types";
|
|
6
|
+
import { upsertTweetAccountEdge } from "./tweet-account-edges";
|
|
7
|
+
import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_TIMELINE_CACHE_TTL_MS = 2 * 60_000;
|
|
10
|
+
|
|
11
|
+
function parseCacheTtlMs(value?: number) {
|
|
12
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
13
|
+
return DEFAULT_TIMELINE_CACHE_TTL_MS;
|
|
14
|
+
}
|
|
15
|
+
return Math.floor(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertLimit(limit: number) {
|
|
19
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
20
|
+
throw new Error("--limit must be at least 1");
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function resolveAccount(db: Database, accountId?: string) {
|
|
25
|
+
const row = accountId
|
|
26
|
+
? (db.prepare("select id from accounts where id = ?").get(accountId) as
|
|
27
|
+
| { id: string }
|
|
28
|
+
| undefined)
|
|
29
|
+
: (db
|
|
30
|
+
.prepare(
|
|
31
|
+
`
|
|
32
|
+
select id
|
|
33
|
+
from accounts
|
|
34
|
+
order by is_default desc, created_at asc
|
|
35
|
+
limit 1
|
|
36
|
+
`,
|
|
37
|
+
)
|
|
38
|
+
.get() as { id: string } | undefined);
|
|
39
|
+
|
|
40
|
+
if (!row) {
|
|
41
|
+
throw new Error(`Unknown account: ${accountId ?? "default"}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return row.id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getMediaCount(tweet: XurlMentionData) {
|
|
48
|
+
const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
|
|
49
|
+
return urls.filter(
|
|
50
|
+
(url) =>
|
|
51
|
+
url &&
|
|
52
|
+
typeof url === "object" &&
|
|
53
|
+
typeof (url as Record<string, unknown>).media_key === "string",
|
|
54
|
+
).length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function replaceTweetFts(db: Database, tweetId: string, text: string) {
|
|
58
|
+
db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
|
|
59
|
+
db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
|
|
60
|
+
tweetId,
|
|
61
|
+
text,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function mergeHomeTimelineIntoLocalStore(
|
|
66
|
+
db: Database,
|
|
67
|
+
accountId: string,
|
|
68
|
+
payload: XurlMentionsResponse,
|
|
69
|
+
) {
|
|
70
|
+
const usersById = new Map(
|
|
71
|
+
(payload.includes?.users ?? []).map((user) => [user.id, user]),
|
|
72
|
+
);
|
|
73
|
+
const upsertTweet = db.prepare(
|
|
74
|
+
`
|
|
75
|
+
insert into tweets (
|
|
76
|
+
id, account_id, author_profile_id, kind, text, created_at,
|
|
77
|
+
is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
|
|
78
|
+
entities_json, media_json, quoted_tweet_id
|
|
79
|
+
) values (?, ?, ?, 'home', ?, ?, 0, null, ?, ?, 0, 0, ?, '[]', null)
|
|
80
|
+
on conflict(id) do update set
|
|
81
|
+
account_id = tweets.account_id,
|
|
82
|
+
author_profile_id = excluded.author_profile_id,
|
|
83
|
+
kind = tweets.kind,
|
|
84
|
+
text = excluded.text,
|
|
85
|
+
created_at = excluded.created_at,
|
|
86
|
+
like_count = excluded.like_count,
|
|
87
|
+
media_count = excluded.media_count,
|
|
88
|
+
entities_json = excluded.entities_json,
|
|
89
|
+
media_json = excluded.media_json,
|
|
90
|
+
bookmarked = tweets.bookmarked,
|
|
91
|
+
liked = tweets.liked
|
|
92
|
+
`,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
db.transaction(() => {
|
|
96
|
+
const seenAt = new Date().toISOString();
|
|
97
|
+
for (const tweet of payload.data) {
|
|
98
|
+
const author =
|
|
99
|
+
usersById.get(tweet.author_id) ??
|
|
100
|
+
({
|
|
101
|
+
id: tweet.author_id,
|
|
102
|
+
username: `user_${tweet.author_id}`,
|
|
103
|
+
name: `user_${tweet.author_id}`,
|
|
104
|
+
} as const);
|
|
105
|
+
const profile = usersById.has(tweet.author_id)
|
|
106
|
+
? upsertProfileFromXUser(db, author)
|
|
107
|
+
: ensureStubProfileForXUser(db, tweet.author_id);
|
|
108
|
+
upsertTweet.run(
|
|
109
|
+
tweet.id,
|
|
110
|
+
accountId,
|
|
111
|
+
profile.profile.id,
|
|
112
|
+
tweet.text,
|
|
113
|
+
tweet.created_at,
|
|
114
|
+
Number(tweet.public_metrics?.like_count ?? 0),
|
|
115
|
+
getMediaCount(tweet),
|
|
116
|
+
JSON.stringify(tweet.entities ?? {}),
|
|
117
|
+
);
|
|
118
|
+
upsertTweetAccountEdge(db, {
|
|
119
|
+
accountId,
|
|
120
|
+
tweetId: tweet.id,
|
|
121
|
+
kind: "home",
|
|
122
|
+
source: "bird",
|
|
123
|
+
seenAt,
|
|
124
|
+
rawJson: JSON.stringify(tweet),
|
|
125
|
+
});
|
|
126
|
+
replaceTweetFts(db, tweet.id, tweet.text);
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function syncHomeTimeline({
|
|
132
|
+
account,
|
|
133
|
+
limit = 100,
|
|
134
|
+
following = true,
|
|
135
|
+
refresh = false,
|
|
136
|
+
cacheTtlMs,
|
|
137
|
+
}: {
|
|
138
|
+
account?: string;
|
|
139
|
+
limit?: number;
|
|
140
|
+
following?: boolean;
|
|
141
|
+
refresh?: boolean;
|
|
142
|
+
cacheTtlMs?: number;
|
|
143
|
+
}) {
|
|
144
|
+
assertLimit(limit);
|
|
145
|
+
const db = getNativeDb();
|
|
146
|
+
const accountId = resolveAccount(db, account);
|
|
147
|
+
const cacheKey = `timeline:bird:${accountId}:${following ? "following" : "for-you"}:${String(limit)}`;
|
|
148
|
+
const ttlMs = parseCacheTtlMs(cacheTtlMs);
|
|
149
|
+
const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
|
|
150
|
+
const cacheAgeMs = cached
|
|
151
|
+
? Date.now() - new Date(cached.updatedAt).getTime()
|
|
152
|
+
: Number.POSITIVE_INFINITY;
|
|
153
|
+
|
|
154
|
+
if (!refresh && cached && cacheAgeMs <= ttlMs) {
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
source: "cache",
|
|
158
|
+
kind: "timeline",
|
|
159
|
+
accountId,
|
|
160
|
+
feed: following ? "following" : "for-you",
|
|
161
|
+
count: cached.value.data.length,
|
|
162
|
+
payload: cached.value,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const payload = await listHomeTimelineViaBird({
|
|
167
|
+
maxResults: limit,
|
|
168
|
+
following,
|
|
169
|
+
});
|
|
170
|
+
mergeHomeTimelineIntoLocalStore(db, accountId, payload);
|
|
171
|
+
writeSyncCache(cacheKey, payload, db);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
ok: true,
|
|
175
|
+
source: "bird",
|
|
176
|
+
kind: "timeline",
|
|
177
|
+
accountId,
|
|
178
|
+
feed: following ? "following" : "for-you",
|
|
179
|
+
count: payload.data.length,
|
|
180
|
+
payload,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { lookupTweetsByIdsViaBird } from "./bird";
|
|
2
|
+
import type { XurlTweetsResponse } from "./types";
|
|
3
|
+
import { lookupTweetsByIds as lookupTweetsByIdsViaXurl } from "./xurl";
|
|
4
|
+
|
|
5
|
+
export type TweetLookupMode = "auto" | "xurl" | "bird";
|
|
6
|
+
|
|
7
|
+
function errorMessage(error: unknown) {
|
|
8
|
+
return error instanceof Error ? error.message : String(error);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function lookupTweetsByIds(
|
|
12
|
+
ids: string[],
|
|
13
|
+
mode: TweetLookupMode = "auto",
|
|
14
|
+
): Promise<XurlTweetsResponse> {
|
|
15
|
+
if (mode === "bird") {
|
|
16
|
+
return lookupTweetsByIdsViaBird(ids);
|
|
17
|
+
}
|
|
18
|
+
if (mode === "xurl") {
|
|
19
|
+
return lookupTweetsByIdsViaXurl(ids);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
return await lookupTweetsByIdsViaXurl(ids);
|
|
24
|
+
} catch (xurlError) {
|
|
25
|
+
try {
|
|
26
|
+
return await lookupTweetsByIdsViaBird(ids);
|
|
27
|
+
} catch (birdError) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Tweet lookup failed via xurl and bird: xurl: ${errorMessage(
|
|
30
|
+
xurlError,
|
|
31
|
+
)}; bird: ${errorMessage(birdError)}`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/lib/types.ts
CHANGED
|
@@ -20,11 +20,58 @@ export interface ProfileRecord {
|
|
|
20
20
|
displayName: string;
|
|
21
21
|
bio: string;
|
|
22
22
|
followersCount: number;
|
|
23
|
+
followingCount?: number;
|
|
23
24
|
avatarHue: number;
|
|
24
25
|
avatarUrl?: string;
|
|
26
|
+
location?: string;
|
|
27
|
+
url?: string;
|
|
28
|
+
verifiedType?: string;
|
|
29
|
+
entities?: Record<string, unknown>;
|
|
30
|
+
affiliations?: ProfileAffiliation[];
|
|
31
|
+
primaryAffiliation?: ProfileAffiliation;
|
|
25
32
|
createdAt: string;
|
|
26
33
|
}
|
|
27
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
|
+
|
|
28
75
|
export interface TweetMentionEntity {
|
|
29
76
|
username: string;
|
|
30
77
|
id?: string;
|
|
@@ -98,6 +145,7 @@ export interface TimelineItem {
|
|
|
98
145
|
accountHandle: string;
|
|
99
146
|
kind: "home" | "mention" | "like" | "bookmark";
|
|
100
147
|
text: string;
|
|
148
|
+
searchSnippet?: string;
|
|
101
149
|
createdAt: string;
|
|
102
150
|
isReplied: boolean;
|
|
103
151
|
likeCount: number;
|
|
@@ -109,6 +157,7 @@ export interface TimelineItem {
|
|
|
109
157
|
media: TweetMediaItem[];
|
|
110
158
|
replyToTweet?: EmbeddedTweet | null;
|
|
111
159
|
quotedTweet?: EmbeddedTweet | null;
|
|
160
|
+
qualityReason?: string | null;
|
|
112
161
|
}
|
|
113
162
|
|
|
114
163
|
export interface DmMessageItem {
|
|
@@ -122,11 +171,31 @@ export interface DmMessageItem {
|
|
|
122
171
|
sender: ProfileRecord;
|
|
123
172
|
}
|
|
124
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
|
+
|
|
125
193
|
export interface DmConversationItem {
|
|
126
194
|
id: string;
|
|
127
195
|
accountId: string;
|
|
128
196
|
accountHandle: string;
|
|
129
197
|
title: string;
|
|
198
|
+
searchSnippet?: string;
|
|
130
199
|
lastMessageAt: string;
|
|
131
200
|
lastMessagePreview: string;
|
|
132
201
|
unreadCount: number;
|
|
@@ -134,6 +203,7 @@ export interface DmConversationItem {
|
|
|
134
203
|
influenceScore: number;
|
|
135
204
|
influenceLabel: string;
|
|
136
205
|
participant: ProfileRecord;
|
|
206
|
+
matches?: DmSearchMatchItem[];
|
|
137
207
|
}
|
|
138
208
|
|
|
139
209
|
export interface TimelineQuery {
|
|
@@ -145,6 +215,8 @@ export interface TimelineQuery {
|
|
|
145
215
|
until?: string;
|
|
146
216
|
includeReplies?: boolean;
|
|
147
217
|
qualityFilter?: TimelineQualityFilter;
|
|
218
|
+
lowQualityThreshold?: number;
|
|
219
|
+
includeQualityReason?: boolean;
|
|
148
220
|
likedOnly?: boolean;
|
|
149
221
|
bookmarkedOnly?: boolean;
|
|
150
222
|
limit?: number;
|
|
@@ -152,6 +224,7 @@ export interface TimelineQuery {
|
|
|
152
224
|
|
|
153
225
|
export interface DmQuery {
|
|
154
226
|
account?: string;
|
|
227
|
+
conversationIds?: string[];
|
|
155
228
|
participant?: string;
|
|
156
229
|
search?: string;
|
|
157
230
|
replyFilter?: ReplyFilter;
|
|
@@ -160,6 +233,7 @@ export interface DmQuery {
|
|
|
160
233
|
minInfluenceScore?: number;
|
|
161
234
|
maxInfluenceScore?: number;
|
|
162
235
|
sort?: "recent" | "influence";
|
|
236
|
+
context?: number;
|
|
163
237
|
limit?: number;
|
|
164
238
|
}
|
|
165
239
|
|
|
@@ -171,7 +245,7 @@ export interface TransportStatus {
|
|
|
171
245
|
}
|
|
172
246
|
|
|
173
247
|
export type ModerationAction = "block" | "unblock" | "mute" | "unmute";
|
|
174
|
-
export type ModerationTransportKind = "bird" | "xurl";
|
|
248
|
+
export type ModerationTransportKind = "bird" | "xurl" | "x-web";
|
|
175
249
|
|
|
176
250
|
export interface ModerationActionTransportResult {
|
|
177
251
|
ok: boolean;
|
|
@@ -252,6 +326,7 @@ export interface XurlPublicMetrics {
|
|
|
252
326
|
bookmark_count?: number;
|
|
253
327
|
impression_count?: number;
|
|
254
328
|
followers_count?: number;
|
|
329
|
+
following_count?: number;
|
|
255
330
|
}
|
|
256
331
|
|
|
257
332
|
export interface XurlMentionUser {
|
|
@@ -259,7 +334,13 @@ export interface XurlMentionUser {
|
|
|
259
334
|
name: string;
|
|
260
335
|
username: string;
|
|
261
336
|
description?: string;
|
|
337
|
+
location?: string;
|
|
338
|
+
url?: string;
|
|
339
|
+
verified?: boolean;
|
|
340
|
+
verified_type?: string;
|
|
262
341
|
profile_image_url?: string;
|
|
342
|
+
entities?: Record<string, unknown>;
|
|
343
|
+
affiliation?: Record<string, unknown>;
|
|
263
344
|
public_metrics?: XurlPublicMetrics;
|
|
264
345
|
created_at?: string;
|
|
265
346
|
}
|
|
@@ -271,6 +352,7 @@ export interface XurlMentionData {
|
|
|
271
352
|
created_at: string;
|
|
272
353
|
conversation_id?: string;
|
|
273
354
|
entities?: Record<string, unknown>;
|
|
355
|
+
referenced_tweets?: XurlReferencedTweet[];
|
|
274
356
|
public_metrics?: XurlPublicMetrics;
|
|
275
357
|
edit_history_tweet_ids?: string[];
|
|
276
358
|
}
|
|
@@ -290,6 +372,18 @@ export interface XurlUserTweet {
|
|
|
290
372
|
edit_history_tweet_ids?: string[];
|
|
291
373
|
}
|
|
292
374
|
|
|
375
|
+
export interface XurlTweetData {
|
|
376
|
+
id: string;
|
|
377
|
+
author_id: string;
|
|
378
|
+
text: string;
|
|
379
|
+
created_at: string;
|
|
380
|
+
conversation_id?: string;
|
|
381
|
+
entities?: Record<string, unknown>;
|
|
382
|
+
referenced_tweets?: XurlReferencedTweet[];
|
|
383
|
+
public_metrics?: XurlPublicMetrics;
|
|
384
|
+
edit_history_tweet_ids?: string[];
|
|
385
|
+
}
|
|
386
|
+
|
|
293
387
|
export interface ProfileReplyItem {
|
|
294
388
|
id: string;
|
|
295
389
|
text: string;
|
|
@@ -322,3 +416,11 @@ export interface XurlMentionsResponse {
|
|
|
322
416
|
};
|
|
323
417
|
meta?: Record<string, unknown>;
|
|
324
418
|
}
|
|
419
|
+
|
|
420
|
+
export interface XurlTweetsResponse {
|
|
421
|
+
data: XurlTweetData[];
|
|
422
|
+
includes?: {
|
|
423
|
+
users?: XurlMentionUser[];
|
|
424
|
+
};
|
|
425
|
+
meta?: Record<string, unknown>;
|
|
426
|
+
}
|