birdclaw 0.5.0 → 0.6.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 +63 -0
- package/README.md +55 -5
- package/bin/birdclaw.mjs +50 -11
- package/package.json +9 -7
- package/public/birdclaw-mark.png +0 -0
- package/scripts/browser-perf.mjs +400 -0
- package/scripts/build-docs-site.mjs +940 -0
- package/scripts/docs-site-assets.mjs +311 -0
- package/scripts/run-vitest.mjs +21 -0
- package/scripts/sanitize-node-options.mjs +23 -0
- package/scripts/start-test-server.mjs +42 -0
- package/src/cli.ts +422 -14
- package/src/components/AccountSwitcher.tsx +186 -0
- package/src/components/AppNav.tsx +29 -9
- package/src/components/AvatarChip.tsx +9 -3
- package/src/components/BrandMark.tsx +67 -0
- package/src/components/ConversationThread.tsx +11 -10
- package/src/components/DmWorkspace.tsx +39 -14
- package/src/components/FeedState.tsx +147 -0
- package/src/components/InboxCard.tsx +14 -2
- package/src/components/LinkPreviewCard.tsx +40 -18
- package/src/components/MarkdownViewer.tsx +452 -0
- package/src/components/SavedTimelineView.tsx +64 -56
- package/src/components/SyncNowButton.tsx +137 -0
- package/src/components/ThemeSlider.tsx +49 -93
- package/src/components/TimelineCard.tsx +364 -136
- package/src/components/TimelineRouteFrame.tsx +170 -0
- package/src/components/TweetMediaGrid.tsx +170 -24
- package/src/components/TweetRichText.tsx +28 -13
- package/src/components/account-selection.ts +64 -0
- package/src/components/useTimelineRouteData.ts +153 -0
- package/src/lib/account-sync-job.ts +654 -0
- package/src/lib/actions-transport.ts +216 -146
- package/src/lib/api-client.ts +304 -0
- package/src/lib/archive-finder.ts +72 -53
- package/src/lib/archive-import.ts +1377 -1298
- package/src/lib/authored-live.ts +261 -204
- package/src/lib/avatar-cache.ts +159 -44
- package/src/lib/backup.ts +1532 -951
- package/src/lib/bird-actions.ts +139 -57
- package/src/lib/bird-command.ts +101 -28
- package/src/lib/bird.ts +549 -194
- package/src/lib/blocklist.ts +40 -23
- package/src/lib/blocks-write.ts +129 -80
- package/src/lib/blocks.ts +165 -97
- package/src/lib/bookmark-sync-job.ts +250 -160
- package/src/lib/conversation-surface.ts +205 -0
- package/src/lib/db.ts +35 -3
- package/src/lib/dms-live.ts +720 -66
- package/src/lib/effect-runtime.ts +45 -0
- package/src/lib/follow-graph.ts +224 -180
- package/src/lib/http-effect.ts +222 -0
- package/src/lib/inbox.ts +74 -43
- package/src/lib/link-index.ts +88 -76
- package/src/lib/link-insights.ts +24 -0
- package/src/lib/link-preview-metadata.ts +472 -52
- package/src/lib/media-fetch.ts +286 -213
- package/src/lib/mention-threads-live.ts +352 -288
- package/src/lib/mentions-live.ts +390 -342
- package/src/lib/moderation-target.ts +102 -65
- package/src/lib/moderation-write.ts +77 -18
- package/src/lib/mutes-write.ts +129 -80
- package/src/lib/mutes.ts +8 -1
- package/src/lib/openai.ts +84 -53
- package/src/lib/period-digest.ts +953 -0
- package/src/lib/profile-affiliation-hydration.ts +93 -54
- package/src/lib/profile-hydration.ts +124 -72
- package/src/lib/profile-replies.ts +60 -43
- package/src/lib/profile-resolver.ts +402 -294
- package/src/lib/queries.ts +1024 -189
- package/src/lib/research.ts +165 -120
- package/src/lib/sqlite.ts +1 -0
- package/src/lib/timeline-collections-live.ts +204 -167
- package/src/lib/timeline-live.ts +60 -39
- package/src/lib/tweet-lookup.ts +30 -19
- package/src/lib/types.ts +38 -1
- package/src/lib/ui.ts +41 -10
- package/src/lib/url-expansion.ts +226 -55
- package/src/lib/url-safety.ts +220 -0
- package/src/lib/web-sync.ts +511 -0
- package/src/lib/whois.ts +166 -147
- package/src/lib/x-web.ts +102 -71
- package/src/lib/xurl.ts +681 -411
- package/src/routeTree.gen.ts +63 -0
- package/src/routes/__root.tsx +25 -5
- package/src/routes/api/action.tsx +127 -78
- package/src/routes/api/avatar.tsx +39 -30
- package/src/routes/api/blocks.tsx +26 -23
- package/src/routes/api/conversation.tsx +25 -14
- package/src/routes/api/inbox.tsx +27 -21
- package/src/routes/api/link-insights.tsx +31 -25
- package/src/routes/api/link-preview.tsx +25 -21
- package/src/routes/api/period-digest.tsx +123 -0
- package/src/routes/api/profile-hydrate.tsx +31 -28
- package/src/routes/api/query.tsx +79 -55
- package/src/routes/api/status.tsx +18 -10
- package/src/routes/api/sync.tsx +105 -0
- package/src/routes/dms.tsx +195 -55
- package/src/routes/inbox.tsx +32 -19
- package/src/routes/index.tsx +21 -127
- package/src/routes/links.tsx +50 -5
- package/src/routes/mentions.tsx +21 -127
- package/src/routes/today.tsx +441 -0
- package/src/styles.css +74 -11
- package/vite.config.ts +8 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
3
|
+
|
|
4
|
+
const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
|
5
|
+
|
|
6
|
+
export function jsonResponse(data: unknown, init?: ResponseInit) {
|
|
7
|
+
const headers = new Headers(init?.headers);
|
|
8
|
+
headers.set("content-type", "application/json");
|
|
9
|
+
return new Response(JSON.stringify(data), {
|
|
10
|
+
...init,
|
|
11
|
+
headers,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function requestJsonEffect<T = Record<string, unknown>>(
|
|
16
|
+
request: Request,
|
|
17
|
+
fallback?: T,
|
|
18
|
+
): Effect.Effect<T, unknown> {
|
|
19
|
+
return tryPromise(() => request.json() as Promise<T>).pipe(
|
|
20
|
+
Effect.catchAll((error) =>
|
|
21
|
+
fallback === undefined ? Effect.fail(error) : Effect.succeed(fallback),
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function runRouteEffect<A, E>(effect: Effect.Effect<A, E>): Promise<A> {
|
|
27
|
+
return runEffectPromise(effect);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizedHost(value: string) {
|
|
31
|
+
return value.toLowerCase().replace(/^\[|\]$/g, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isTestEnvironment() {
|
|
35
|
+
return process.env.NODE_ENV === "test" || process.env.VITEST === "true";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function requestCookie(request: Request, name: string) {
|
|
39
|
+
const cookie = request.headers.get("cookie");
|
|
40
|
+
if (!cookie) return { found: false, value: null };
|
|
41
|
+
for (const part of cookie.split(";")) {
|
|
42
|
+
const [key, ...rest] = part.trim().split("=");
|
|
43
|
+
if (key === name) {
|
|
44
|
+
try {
|
|
45
|
+
return { found: true, value: decodeURIComponent(rest.join("=")) };
|
|
46
|
+
} catch {
|
|
47
|
+
return { found: true, value: null };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { found: false, value: null };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function configuredWebToken() {
|
|
55
|
+
const token = process.env.BIRDCLAW_WEB_TOKEN?.trim();
|
|
56
|
+
return token || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function requestWebTokenStatus(request: Request) {
|
|
60
|
+
const token = configuredWebToken();
|
|
61
|
+
if (!token)
|
|
62
|
+
return {
|
|
63
|
+
configured: false,
|
|
64
|
+
valid: false,
|
|
65
|
+
fromCookie: false,
|
|
66
|
+
provided: false,
|
|
67
|
+
};
|
|
68
|
+
const headerToken = request.headers.get("x-birdclaw-token");
|
|
69
|
+
const cookieToken = requestCookie(request, "birdclaw_token");
|
|
70
|
+
const provided = headerToken !== null || cookieToken.found;
|
|
71
|
+
const cookieValue = cookieToken.value;
|
|
72
|
+
const valid = headerToken === token || cookieValue === token;
|
|
73
|
+
return {
|
|
74
|
+
configured: true,
|
|
75
|
+
valid,
|
|
76
|
+
fromCookie: cookieValue === token && headerToken !== token,
|
|
77
|
+
provided,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isLocalWebHost(value: string) {
|
|
82
|
+
const host = normalizedHost(value);
|
|
83
|
+
return LOCAL_HOSTS.has(host) || host.endsWith(".localhost");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function allowsUnauthenticatedLocalWeb() {
|
|
87
|
+
return process.env.BIRDCLAW_LOCAL_WEB === "1";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasForwardedRequestHeaders(request: Request) {
|
|
91
|
+
return (
|
|
92
|
+
request.headers.has("forwarded") ||
|
|
93
|
+
request.headers.has("x-forwarded-for") ||
|
|
94
|
+
request.headers.has("x-forwarded-proto") ||
|
|
95
|
+
request.headers.has("x-forwarded-host") ||
|
|
96
|
+
request.headers.has("x-real-ip")
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function firstForwardedValue(value: string | null) {
|
|
101
|
+
return value?.split(",")[0]?.trim() || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function forwardedHeaderPair(request: Request, key: "host" | "proto") {
|
|
105
|
+
const forwarded = firstForwardedValue(request.headers.get("forwarded"));
|
|
106
|
+
if (!forwarded) return null;
|
|
107
|
+
for (const part of forwarded.split(";")) {
|
|
108
|
+
const [name, ...rest] = part.trim().split("=");
|
|
109
|
+
if (name?.toLowerCase() !== key) continue;
|
|
110
|
+
const value = rest.join("=").trim().replace(/^"|"$/g, "");
|
|
111
|
+
return value || null;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function forwardedOrigin(request: Request) {
|
|
117
|
+
const proto =
|
|
118
|
+
firstForwardedValue(request.headers.get("x-forwarded-proto")) ??
|
|
119
|
+
forwardedHeaderPair(request, "proto");
|
|
120
|
+
const host =
|
|
121
|
+
firstForwardedValue(request.headers.get("x-forwarded-host")) ??
|
|
122
|
+
forwardedHeaderPair(request, "host");
|
|
123
|
+
if (!proto || !host) return null;
|
|
124
|
+
const normalizedProto = proto.toLowerCase();
|
|
125
|
+
if (normalizedProto !== "http" && normalizedProto !== "https") return null;
|
|
126
|
+
return `${normalizedProto}://${host}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function sameRequestOrigin(request: Request, origin: string, url: URL) {
|
|
130
|
+
return origin === url.origin || origin === forwardedOrigin(request);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function sensitiveRequestErrorResponse(request: Request) {
|
|
134
|
+
const url = new URL(request.url);
|
|
135
|
+
const token = requestWebTokenStatus(request);
|
|
136
|
+
const isLocalRequest =
|
|
137
|
+
allowsUnauthenticatedLocalWeb() &&
|
|
138
|
+
isLocalWebHost(url.hostname) &&
|
|
139
|
+
!hasForwardedRequestHeaders(request);
|
|
140
|
+
const fetchSite = request.headers.get("sec-fetch-site");
|
|
141
|
+
const origin = request.headers.get("origin");
|
|
142
|
+
const allowRemoteEnv = process.env.BIRDCLAW_ALLOW_REMOTE_WEB === "1";
|
|
143
|
+
const allowTrustedRemote = allowRemoteEnv && !token.configured;
|
|
144
|
+
|
|
145
|
+
if (isTestEnvironment() && !token.valid) return null;
|
|
146
|
+
|
|
147
|
+
if (origin && !sameRequestOrigin(request, origin, url)) {
|
|
148
|
+
return jsonResponse(
|
|
149
|
+
{ ok: false, message: "Cross-origin web API access is disabled" },
|
|
150
|
+
{ status: 403 },
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (fetchSite === "cross-site") {
|
|
155
|
+
return jsonResponse(
|
|
156
|
+
{ ok: false, message: "Cross-site web API access is disabled" },
|
|
157
|
+
{ status: 403 },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (
|
|
162
|
+
!isTestEnvironment() &&
|
|
163
|
+
!token.configured &&
|
|
164
|
+
!isLocalRequest &&
|
|
165
|
+
!allowTrustedRemote
|
|
166
|
+
) {
|
|
167
|
+
return jsonResponse(
|
|
168
|
+
{
|
|
169
|
+
ok: false,
|
|
170
|
+
message:
|
|
171
|
+
"Remote API access requires BIRDCLAW_ALLOW_REMOTE_WEB=1 for a trusted private proxy, or BIRDCLAW_WEB_TOKEN for tokened access",
|
|
172
|
+
},
|
|
173
|
+
{ status: 403 },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (token.configured && !token.valid && (token.provided || !isLocalRequest)) {
|
|
178
|
+
return jsonResponse(
|
|
179
|
+
{ ok: false, message: "Invalid web token" },
|
|
180
|
+
{ status: 403 },
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const allowRemote = allowRemoteEnv && (token.valid || allowTrustedRemote);
|
|
185
|
+
if (!allowRemote && !isLocalRequest) {
|
|
186
|
+
return jsonResponse(
|
|
187
|
+
{ ok: false, message: "Remote web API access is disabled" },
|
|
188
|
+
{ status: 403 },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
token.fromCookie &&
|
|
194
|
+
fetchSite !== "same-origin" &&
|
|
195
|
+
fetchSite !== "same-site"
|
|
196
|
+
) {
|
|
197
|
+
return jsonResponse(
|
|
198
|
+
{ ok: false, message: "Same-origin browser request required" },
|
|
199
|
+
{ status: 403 },
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function parseBoundedInteger(
|
|
207
|
+
value: number | string | null | undefined,
|
|
208
|
+
{
|
|
209
|
+
defaultValue,
|
|
210
|
+
min = 1,
|
|
211
|
+
max,
|
|
212
|
+
}: { defaultValue?: number; min?: number; max: number },
|
|
213
|
+
) {
|
|
214
|
+
if (value === null || value === undefined || value === "")
|
|
215
|
+
return defaultValue;
|
|
216
|
+
if (typeof value === "string" && !/^\d+$/.test(value.trim())) {
|
|
217
|
+
return defaultValue;
|
|
218
|
+
}
|
|
219
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
220
|
+
if (!Number.isSafeInteger(parsed)) return defaultValue;
|
|
221
|
+
return Math.min(max, Math.max(min, parsed));
|
|
222
|
+
}
|
package/src/lib/inbox.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
1
2
|
import { getNativeDb } from "./db";
|
|
2
|
-
import {
|
|
3
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
4
|
+
import { scoreInboxItemWithOpenAIEffect } from "./openai";
|
|
3
5
|
import { listDmConversations, listTimelineItems } from "./queries";
|
|
4
6
|
import type { InboxItem, InboxQuery, InboxResponse } from "./types";
|
|
5
7
|
|
|
@@ -61,6 +63,7 @@ function readStoredScores() {
|
|
|
61
63
|
|
|
62
64
|
export function listInboxItems({
|
|
63
65
|
kind = "mixed",
|
|
66
|
+
account,
|
|
64
67
|
minScore = 0,
|
|
65
68
|
hideLowSignal = false,
|
|
66
69
|
limit = 20,
|
|
@@ -71,6 +74,7 @@ export function listInboxItems({
|
|
|
71
74
|
if (kind === "mixed" || kind === "mentions") {
|
|
72
75
|
for (const mention of listTimelineItems({
|
|
73
76
|
resource: "mentions",
|
|
77
|
+
account,
|
|
74
78
|
replyFilter: "unreplied",
|
|
75
79
|
limit: 50,
|
|
76
80
|
})) {
|
|
@@ -102,8 +106,9 @@ export function listInboxItems({
|
|
|
102
106
|
|
|
103
107
|
if (kind === "mixed" || kind === "dms") {
|
|
104
108
|
for (const dm of listDmConversations({
|
|
109
|
+
account,
|
|
105
110
|
replyFilter: "unreplied",
|
|
106
|
-
sort: "
|
|
111
|
+
sort: "followers",
|
|
107
112
|
limit: 50,
|
|
108
113
|
})) {
|
|
109
114
|
const scoreKey = `dm:${dm.id}`;
|
|
@@ -151,30 +156,47 @@ export function listInboxItems({
|
|
|
151
156
|
};
|
|
152
157
|
}
|
|
153
158
|
|
|
154
|
-
|
|
159
|
+
function toError(error: unknown) {
|
|
160
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function trySync<T>(try_: () => T) {
|
|
164
|
+
return Effect.try({
|
|
165
|
+
try: try_,
|
|
166
|
+
catch: toError,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function scoreInboxEffect({
|
|
155
171
|
kind = "mixed",
|
|
172
|
+
account,
|
|
156
173
|
limit = 8,
|
|
157
|
-
}: Pick<InboxQuery, "kind" | "limit"> = {}) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const
|
|
164
|
-
entityKind: item.entityKind,
|
|
165
|
-
title: item.title,
|
|
166
|
-
text: item.text,
|
|
167
|
-
influenceScore: item.influenceScore,
|
|
168
|
-
participant: {
|
|
169
|
-
handle: item.participant.handle,
|
|
170
|
-
displayName: item.participant.displayName,
|
|
171
|
-
bio: item.participant.bio,
|
|
172
|
-
followersCount: item.participant.followersCount,
|
|
173
|
-
},
|
|
174
|
-
});
|
|
174
|
+
}: Pick<InboxQuery, "kind" | "account" | "limit"> = {}) {
|
|
175
|
+
return Effect.gen(function* () {
|
|
176
|
+
const db = yield* trySync(() => getNativeDb());
|
|
177
|
+
const items = yield* trySync(
|
|
178
|
+
() => listInboxItems({ kind, account, limit }).items,
|
|
179
|
+
);
|
|
180
|
+
const results = [];
|
|
175
181
|
|
|
176
|
-
|
|
177
|
-
|
|
182
|
+
for (const item of items) {
|
|
183
|
+
const scored = yield* scoreInboxItemWithOpenAIEffect({
|
|
184
|
+
entityKind: item.entityKind,
|
|
185
|
+
title: item.title,
|
|
186
|
+
text: item.text,
|
|
187
|
+
influenceScore: item.influenceScore,
|
|
188
|
+
participant: {
|
|
189
|
+
handle: item.participant.handle,
|
|
190
|
+
displayName: item.participant.displayName,
|
|
191
|
+
bio: item.participant.bio,
|
|
192
|
+
followersCount: item.participant.followersCount,
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
yield* trySync(() =>
|
|
197
|
+
db
|
|
198
|
+
.prepare(
|
|
199
|
+
`
|
|
178
200
|
insert into ai_scores (
|
|
179
201
|
entity_kind, entity_id, model, score, summary, reasoning, updated_at
|
|
180
202
|
) values (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -185,26 +207,35 @@ export async function scoreInbox({
|
|
|
185
207
|
reasoning = excluded.reasoning,
|
|
186
208
|
updated_at = excluded.updated_at
|
|
187
209
|
`,
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
210
|
+
)
|
|
211
|
+
.run(
|
|
212
|
+
item.entityKind,
|
|
213
|
+
item.entityId,
|
|
214
|
+
scored.model,
|
|
215
|
+
scored.score,
|
|
216
|
+
scored.summary,
|
|
217
|
+
scored.reasoning,
|
|
218
|
+
new Date().toISOString(),
|
|
219
|
+
),
|
|
220
|
+
);
|
|
197
221
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
222
|
+
results.push({
|
|
223
|
+
id: item.id,
|
|
224
|
+
score: scored.score,
|
|
225
|
+
source: "openai",
|
|
226
|
+
});
|
|
227
|
+
}
|
|
204
228
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
scored: results.length,
|
|
232
|
+
items: results,
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function scoreInbox(
|
|
238
|
+
options: Pick<InboxQuery, "kind" | "account" | "limit"> = {},
|
|
239
|
+
) {
|
|
240
|
+
return runEffectPromise(scoreInboxEffect(options));
|
|
210
241
|
}
|
package/src/lib/link-index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
1
2
|
import { getNativeDb } from "./db";
|
|
3
|
+
import { runEffectPromise, tryPromise } from "./effect-runtime";
|
|
2
4
|
import type { Database } from "./sqlite";
|
|
3
5
|
import {
|
|
4
6
|
normalizeUrlExpansionForIndex,
|
|
@@ -246,11 +248,19 @@ function rebuildOccurrences(
|
|
|
246
248
|
return { occurrences, entityExpansions };
|
|
247
249
|
}
|
|
248
250
|
|
|
249
|
-
|
|
251
|
+
function expandWithConcurrencyEffect(
|
|
250
252
|
db: Database,
|
|
251
253
|
urls: string[],
|
|
252
254
|
options: LinkBackfillOptions,
|
|
253
|
-
)
|
|
255
|
+
): Effect.Effect<
|
|
256
|
+
{
|
|
257
|
+
networkExpansions: number;
|
|
258
|
+
cacheExpansions: number;
|
|
259
|
+
misses: number;
|
|
260
|
+
errors: number;
|
|
261
|
+
},
|
|
262
|
+
unknown
|
|
263
|
+
> {
|
|
254
264
|
const concurrency = Math.max(
|
|
255
265
|
1,
|
|
256
266
|
Math.min(options.concurrency ?? DEFAULT_EXPAND_CONCURRENCY, 64),
|
|
@@ -261,62 +271,57 @@ async function expandWithConcurrency(
|
|
|
261
271
|
misses: 0,
|
|
262
272
|
errors: 0,
|
|
263
273
|
};
|
|
264
|
-
let nextIndex = 0;
|
|
265
274
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
const result = (
|
|
274
|
-
await expandUrls([url], {
|
|
275
|
+
return Effect.forEach(
|
|
276
|
+
urls,
|
|
277
|
+
(url) =>
|
|
278
|
+
tryPromise(() =>
|
|
279
|
+
expandUrls([url], {
|
|
275
280
|
refresh: options.refresh,
|
|
276
281
|
fetchImpl: options.fetchImpl,
|
|
277
282
|
timeoutMs: options.timeoutMs,
|
|
278
|
-
})
|
|
279
|
-
)
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
);
|
|
298
|
-
return counts;
|
|
283
|
+
}),
|
|
284
|
+
).pipe(
|
|
285
|
+
Effect.map((results) => {
|
|
286
|
+
const result = results[0]!;
|
|
287
|
+
if (result.source === "network") {
|
|
288
|
+
counts.networkExpansions++;
|
|
289
|
+
} else {
|
|
290
|
+
counts.cacheExpansions++;
|
|
291
|
+
}
|
|
292
|
+
if (result.status === "miss") {
|
|
293
|
+
counts.misses++;
|
|
294
|
+
}
|
|
295
|
+
if (result.status === "error") {
|
|
296
|
+
counts.errors++;
|
|
297
|
+
}
|
|
298
|
+
upsertUrlExpansion(db, normalizeUrlExpansionForIndex(result));
|
|
299
|
+
}),
|
|
300
|
+
),
|
|
301
|
+
{ concurrency },
|
|
302
|
+
).pipe(Effect.as(counts));
|
|
299
303
|
}
|
|
300
304
|
|
|
301
|
-
export
|
|
305
|
+
export function backfillLinkIndexEffect(
|
|
302
306
|
options: LinkBackfillOptions = {},
|
|
303
|
-
):
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
307
|
+
): Effect.Effect<LinkBackfillResult, unknown> {
|
|
308
|
+
return Effect.gen(function* () {
|
|
309
|
+
const db = getNativeDb({ seedDemoData: false });
|
|
310
|
+
const { occurrences, entityExpansions } = rebuildOccurrences(
|
|
311
|
+
db,
|
|
312
|
+
Boolean(options.includeAllUrls),
|
|
313
|
+
options.source,
|
|
314
|
+
);
|
|
315
|
+
const limit =
|
|
316
|
+
typeof options.limit === "number" && Number.isFinite(options.limit)
|
|
317
|
+
? Math.max(0, Math.trunc(options.limit))
|
|
318
|
+
: undefined;
|
|
319
|
+
const needsExpansionClause = options.refresh
|
|
320
|
+
? "1 = 1"
|
|
321
|
+
: "(e.short_url is null or e.status in ('error', 'miss'))";
|
|
322
|
+
|
|
323
|
+
const urlsToExpand = db
|
|
324
|
+
.prepare(`
|
|
320
325
|
select distinct o.short_url
|
|
321
326
|
from link_occurrences o
|
|
322
327
|
left join url_expansions e on e.short_url = o.short_url
|
|
@@ -325,43 +330,50 @@ export async function backfillLinkIndex(
|
|
|
325
330
|
order by o.short_url
|
|
326
331
|
${limit === undefined ? "" : "limit ?"}
|
|
327
332
|
`)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
333
|
+
.all(
|
|
334
|
+
...(options.source ? [options.source] : []),
|
|
335
|
+
...(limit === undefined ? [] : [limit]),
|
|
336
|
+
) as Array<{
|
|
337
|
+
short_url: string;
|
|
338
|
+
}>;
|
|
339
|
+
|
|
340
|
+
const expansionCounts = yield* expandWithConcurrencyEffect(
|
|
341
|
+
db,
|
|
342
|
+
urlsToExpand.map((row) => row.short_url),
|
|
343
|
+
options,
|
|
344
|
+
);
|
|
340
345
|
|
|
341
|
-
|
|
342
|
-
|
|
346
|
+
const uniqueUrls = db
|
|
347
|
+
.prepare(`
|
|
343
348
|
select count(distinct short_url) as count
|
|
344
349
|
from link_occurrences
|
|
345
350
|
${options.source ? "where source_kind = ?" : ""}
|
|
346
351
|
`)
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
352
|
+
.get(...(options.source ? [options.source] : [])) as { count: number };
|
|
353
|
+
const remaining = db
|
|
354
|
+
.prepare(`
|
|
350
355
|
select count(distinct o.short_url) as count
|
|
351
356
|
from link_occurrences o
|
|
352
357
|
left join url_expansions e on e.short_url = o.short_url
|
|
353
358
|
where (e.short_url is null or e.status in ('error', 'miss'))
|
|
354
359
|
${options.source ? "and o.source_kind = ?" : ""}
|
|
355
360
|
`)
|
|
356
|
-
|
|
361
|
+
.get(...(options.source ? [options.source] : [])) as { count: number };
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
occurrences,
|
|
365
|
+
uniqueUrls: Number(uniqueUrls.count),
|
|
366
|
+
entityExpansions,
|
|
367
|
+
...expansionCounts,
|
|
368
|
+
remainingUnexpanded: Number(remaining.count),
|
|
369
|
+
};
|
|
370
|
+
});
|
|
371
|
+
}
|
|
357
372
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
...expansionCounts,
|
|
363
|
-
remainingUnexpanded: Number(remaining.count),
|
|
364
|
-
};
|
|
373
|
+
export function backfillLinkIndex(
|
|
374
|
+
options: LinkBackfillOptions = {},
|
|
375
|
+
): Promise<LinkBackfillResult> {
|
|
376
|
+
return runEffectPromise(backfillLinkIndexEffect(options));
|
|
365
377
|
}
|
|
366
378
|
|
|
367
379
|
function likePattern(value: string) {
|
package/src/lib/link-insights.ts
CHANGED
|
@@ -635,6 +635,30 @@ export function getLinkInsights(
|
|
|
635
635
|
conditions.push("o.source_kind = ?");
|
|
636
636
|
params.push(source);
|
|
637
637
|
}
|
|
638
|
+
if (query.account && query.account !== "all") {
|
|
639
|
+
conditions.push(`(
|
|
640
|
+
(o.source_kind = 'dm' and o.account_id = ?)
|
|
641
|
+
or (
|
|
642
|
+
o.source_kind = 'tweet'
|
|
643
|
+
and (
|
|
644
|
+
o.account_id = ?
|
|
645
|
+
or exists (
|
|
646
|
+
select 1
|
|
647
|
+
from tweet_account_edges edge
|
|
648
|
+
where edge.account_id = ?
|
|
649
|
+
and edge.tweet_id = o.source_id
|
|
650
|
+
)
|
|
651
|
+
or exists (
|
|
652
|
+
select 1
|
|
653
|
+
from tweet_collections collection
|
|
654
|
+
where collection.account_id = ?
|
|
655
|
+
and collection.tweet_id = o.source_id
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
)
|
|
659
|
+
)`);
|
|
660
|
+
params.push(query.account, query.account, query.account, query.account);
|
|
661
|
+
}
|
|
638
662
|
if (kind === "videos") {
|
|
639
663
|
addVideoUrlPrefilter(conditions);
|
|
640
664
|
}
|