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
package/src/lib/media-fetch.ts
CHANGED
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
} from "node:fs";
|
|
15
15
|
import { appendFile, copyFile, rename, rm, writeFile } from "node:fs/promises";
|
|
16
16
|
import path from "node:path";
|
|
17
|
+
import { Effect } from "effect";
|
|
17
18
|
import { getBirdclawPaths } from "./config";
|
|
18
19
|
import { getNativeDb } from "./db";
|
|
20
|
+
import { runEffectPromise } from "./effect-runtime";
|
|
19
21
|
|
|
20
22
|
type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
|
|
21
23
|
type Row = { id: string; media_json: string };
|
|
@@ -100,6 +102,24 @@ function defaultSleep(ms: number) {
|
|
|
100
102
|
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
|
101
103
|
}
|
|
102
104
|
|
|
105
|
+
function toMediaError(error: unknown) {
|
|
106
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function tryMediaSync<T>(try_: () => T) {
|
|
110
|
+
return Effect.try({
|
|
111
|
+
try: try_,
|
|
112
|
+
catch: toMediaError,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function tryMediaPromise<T>(try_: () => Promise<T>) {
|
|
117
|
+
return Effect.tryPromise({
|
|
118
|
+
try: try_,
|
|
119
|
+
catch: toMediaError,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
function fileSize(filePath: string) {
|
|
104
124
|
try {
|
|
105
125
|
return statSync(filePath).size;
|
|
@@ -454,24 +474,26 @@ function archivePathFor(item: Candidate, mediaOriginalsDir: string) {
|
|
|
454
474
|
);
|
|
455
475
|
}
|
|
456
476
|
|
|
457
|
-
|
|
477
|
+
function reuseFromArchiveEffect(
|
|
458
478
|
item: Candidate,
|
|
459
479
|
mediaOriginalsDir: string,
|
|
460
480
|
maxBytes: number,
|
|
461
|
-
) {
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
481
|
+
): Effect.Effect<FetchOneResult | null, Error> {
|
|
482
|
+
return Effect.gen(function* () {
|
|
483
|
+
const archivePath = archivePathFor(item, mediaOriginalsDir);
|
|
484
|
+
if (!archivePath || !existsSync(archivePath)) return null;
|
|
485
|
+
const bytes = fileSize(archivePath);
|
|
486
|
+
if (bytes > maxBytes) return fail(item, "max-bytes");
|
|
487
|
+
yield* tryMediaPromise(() => copyFile(archivePath, item.tmpPath));
|
|
488
|
+
yield* tryMediaPromise(() => rename(item.tmpPath, item.path));
|
|
489
|
+
return {
|
|
490
|
+
fetched: 1,
|
|
491
|
+
bytes,
|
|
492
|
+
rateLimited: false,
|
|
493
|
+
kind: item.kind,
|
|
494
|
+
reusedFromArchive: true,
|
|
495
|
+
} satisfies FetchOneResult;
|
|
496
|
+
});
|
|
475
497
|
}
|
|
476
498
|
|
|
477
499
|
function contentLength(response: Response) {
|
|
@@ -486,49 +508,61 @@ function contentRangeTotal(response: Response) {
|
|
|
486
508
|
return total ? Number(total) : null;
|
|
487
509
|
}
|
|
488
510
|
|
|
489
|
-
|
|
511
|
+
function writeResponseBodyEffect(
|
|
490
512
|
response: Response,
|
|
491
513
|
tmpPath: string,
|
|
492
514
|
append: boolean,
|
|
493
515
|
maxBytes: number,
|
|
494
516
|
initialBytes: number,
|
|
495
|
-
) {
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
try {
|
|
503
|
-
for (;;) {
|
|
504
|
-
const { done, value } = await reader.read();
|
|
505
|
-
if (done) {
|
|
506
|
-
break;
|
|
507
|
-
}
|
|
508
|
-
const chunk = Buffer.from(value);
|
|
509
|
-
bytes += chunk.length;
|
|
510
|
-
if (initialBytes + bytes > maxBytes) {
|
|
511
|
-
throw new Error("max-bytes");
|
|
512
|
-
}
|
|
513
|
-
await appendFile(tmpPath, chunk);
|
|
514
|
-
}
|
|
515
|
-
} catch (error) {
|
|
516
|
-
if (error instanceof Error && error.message === "max-bytes") {
|
|
517
|
-
await rm(tmpPath, { force: true });
|
|
518
|
-
}
|
|
519
|
-
try {
|
|
520
|
-
await reader.cancel(error);
|
|
521
|
-
} catch {
|
|
522
|
-
// The stream may already be errored; preserving the original failure matters.
|
|
517
|
+
): Effect.Effect<number, Error> {
|
|
518
|
+
return Effect.gen(function* () {
|
|
519
|
+
if (!response.body)
|
|
520
|
+
return yield* Effect.fail(new Error("missing response body"));
|
|
521
|
+
let bytes = 0;
|
|
522
|
+
if (!append) {
|
|
523
|
+
yield* tryMediaPromise(() => writeFile(tmpPath, Buffer.alloc(0)));
|
|
523
524
|
}
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
525
|
+
const reader = response.body.getReader();
|
|
526
|
+
const result = yield* Effect.gen(function* () {
|
|
527
|
+
for (;;) {
|
|
528
|
+
const { done, value } = yield* tryMediaPromise(() => reader.read());
|
|
529
|
+
if (done) {
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
const chunk = Buffer.from(value);
|
|
533
|
+
bytes += chunk.length;
|
|
534
|
+
if (initialBytes + bytes > maxBytes) {
|
|
535
|
+
yield* tryMediaPromise(() => rm(tmpPath, { force: true })).pipe(
|
|
536
|
+
Effect.catchAll(() => Effect.void),
|
|
537
|
+
);
|
|
538
|
+
return yield* Effect.fail(new Error("max-bytes"));
|
|
539
|
+
}
|
|
540
|
+
yield* tryMediaPromise(() => appendFile(tmpPath, chunk));
|
|
541
|
+
}
|
|
542
|
+
return bytes;
|
|
543
|
+
}).pipe(
|
|
544
|
+
Effect.map((writtenBytes) => ({ ok: true as const, writtenBytes })),
|
|
545
|
+
Effect.catchAll((error) =>
|
|
546
|
+
Effect.gen(function* () {
|
|
547
|
+
if (error.message === "max-bytes") {
|
|
548
|
+
yield* tryMediaPromise(() => rm(tmpPath, { force: true })).pipe(
|
|
549
|
+
Effect.catchAll(() => Effect.void),
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
yield* tryMediaPromise(() => reader.cancel(error)).pipe(
|
|
553
|
+
Effect.catchAll(() => Effect.void),
|
|
554
|
+
);
|
|
555
|
+
return { error, ok: false as const };
|
|
556
|
+
}),
|
|
557
|
+
),
|
|
558
|
+
Effect.ensuring(Effect.sync(() => reader.releaseLock())),
|
|
559
|
+
);
|
|
560
|
+
if (!result.ok) return yield* Effect.fail(result.error);
|
|
561
|
+
return result.writtenBytes;
|
|
562
|
+
});
|
|
529
563
|
}
|
|
530
564
|
|
|
531
|
-
|
|
565
|
+
function fetchOneEffect({
|
|
532
566
|
item,
|
|
533
567
|
fetchImpl,
|
|
534
568
|
sleep,
|
|
@@ -542,74 +576,85 @@ async function fetchOne({
|
|
|
542
576
|
retryMax: number;
|
|
543
577
|
userAgent: string;
|
|
544
578
|
maxBytes: number;
|
|
545
|
-
}):
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
579
|
+
}): Effect.Effect<FetchOneResult, Error> {
|
|
580
|
+
return Effect.gen(function* () {
|
|
581
|
+
let rateLimited = false;
|
|
582
|
+
for (let attempt = 0; attempt <= retryMax; attempt += 1) {
|
|
583
|
+
const partialBytes = item.kind === "image" ? 0 : fileSize(item.tmpPath);
|
|
584
|
+
if (partialBytes > maxBytes) {
|
|
585
|
+
yield* tryMediaPromise(() => rm(item.tmpPath, { force: true })).pipe(
|
|
586
|
+
Effect.catchAll(() => Effect.void),
|
|
587
|
+
);
|
|
588
|
+
return fail(item, "max-bytes");
|
|
589
|
+
}
|
|
590
|
+
const responseResult = yield* tryMediaPromise(() =>
|
|
591
|
+
fetchImpl(item.url, {
|
|
592
|
+
headers: {
|
|
593
|
+
"user-agent": userAgent,
|
|
594
|
+
...(partialBytes > 0 ? { range: `bytes=${partialBytes}-` } : {}),
|
|
595
|
+
},
|
|
596
|
+
}),
|
|
597
|
+
).pipe(
|
|
598
|
+
Effect.map((response) => ({ ok: true as const, response })),
|
|
599
|
+
Effect.catchAll((error) =>
|
|
600
|
+
Effect.succeed({ error, ok: false as const }),
|
|
601
|
+
),
|
|
566
602
|
);
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
603
|
+
if (!responseResult.ok) {
|
|
604
|
+
return fail(item, responseResult.error.message, rateLimited);
|
|
605
|
+
}
|
|
606
|
+
const { response } = responseResult;
|
|
607
|
+
if (response.status === 429) {
|
|
608
|
+
rateLimited = true;
|
|
609
|
+
if (attempt < retryMax) {
|
|
610
|
+
yield* tryMediaPromise(() => sleep(1000 * 2 ** attempt)).pipe(
|
|
611
|
+
Effect.catchAll(() => Effect.void),
|
|
612
|
+
);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
return fail(item, "429", true);
|
|
616
|
+
}
|
|
617
|
+
if (!response.ok && response.status !== 206) {
|
|
618
|
+
return fail(item, String(response.status), rateLimited);
|
|
573
619
|
}
|
|
574
|
-
return fail(item, "429", true);
|
|
575
|
-
}
|
|
576
|
-
if (!response.ok && response.status !== 206) {
|
|
577
|
-
return fail(item, String(response.status), rateLimited);
|
|
578
|
-
}
|
|
579
620
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
621
|
+
const expectedTotal =
|
|
622
|
+
contentRangeTotal(response) ??
|
|
623
|
+
(contentLength(response) ?? 0) +
|
|
624
|
+
(response.status === 206 ? partialBytes : 0);
|
|
625
|
+
if (expectedTotal > maxBytes) {
|
|
626
|
+
yield* tryMediaPromise(() => rm(item.tmpPath, { force: true })).pipe(
|
|
627
|
+
Effect.catchAll(() => Effect.void),
|
|
628
|
+
);
|
|
629
|
+
return fail(item, "max-bytes");
|
|
630
|
+
}
|
|
588
631
|
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
try {
|
|
592
|
-
bytes = await writeResponseBody(
|
|
632
|
+
const append = partialBytes > 0 && response.status === 206;
|
|
633
|
+
const bytesResult = yield* writeResponseBodyEffect(
|
|
593
634
|
response,
|
|
594
635
|
item.tmpPath,
|
|
595
636
|
append,
|
|
596
637
|
maxBytes,
|
|
597
638
|
append ? partialBytes : 0,
|
|
639
|
+
).pipe(
|
|
640
|
+
Effect.map((bytes) => ({ bytes, ok: true as const })),
|
|
641
|
+
Effect.catchAll((error) =>
|
|
642
|
+
Effect.succeed({ error, ok: false as const }),
|
|
643
|
+
),
|
|
598
644
|
);
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
return fail(item, "max-bytes", rateLimited);
|
|
645
|
+
if (!bytesResult.ok) {
|
|
646
|
+
return fail(item, bytesResult.error.message, rateLimited);
|
|
602
647
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
648
|
+
yield* tryMediaPromise(() => rename(item.tmpPath, item.path));
|
|
649
|
+
return {
|
|
650
|
+
fetched: 1,
|
|
651
|
+
bytes: bytesResult.bytes,
|
|
606
652
|
rateLimited,
|
|
607
|
-
|
|
653
|
+
kind: item.kind,
|
|
654
|
+
};
|
|
608
655
|
}
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
return fail(item, "retry exhausted", rateLimited);
|
|
656
|
+
return fail(item, "retry exhausted", rateLimited);
|
|
657
|
+
});
|
|
613
658
|
}
|
|
614
659
|
|
|
615
660
|
function applyFetched(result: MediaFetchResult, fetched: FetchOneResult) {
|
|
@@ -632,7 +677,7 @@ function applyFetched(result: MediaFetchResult, fetched: FetchOneResult) {
|
|
|
632
677
|
if (fetched.failure) result.failures.push(fetched.failure);
|
|
633
678
|
}
|
|
634
679
|
|
|
635
|
-
|
|
680
|
+
function runGroupEffect(
|
|
636
681
|
items: Candidate[],
|
|
637
682
|
parallel: number,
|
|
638
683
|
pacingMs: number,
|
|
@@ -640,127 +685,155 @@ async function runGroup(
|
|
|
640
685
|
sleep: (ms: number) => Promise<void>,
|
|
641
686
|
worker: (item: Candidate) => Promise<void>,
|
|
642
687
|
) {
|
|
643
|
-
let next = 0;
|
|
644
688
|
let lastStart: number | null = null;
|
|
645
689
|
let pace = Promise.resolve();
|
|
646
|
-
const
|
|
690
|
+
const pacedItems = items.map((item) => {
|
|
647
691
|
const previous = pace;
|
|
648
692
|
let release = () => {};
|
|
649
693
|
pace = new Promise<void>((resolve) => {
|
|
650
694
|
release = resolve;
|
|
651
695
|
});
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
696
|
+
return { item, previous, release };
|
|
697
|
+
});
|
|
698
|
+
const runPaced = ({
|
|
699
|
+
item,
|
|
700
|
+
previous,
|
|
701
|
+
release,
|
|
702
|
+
}: {
|
|
703
|
+
item: Candidate;
|
|
704
|
+
previous: Promise<void>;
|
|
705
|
+
release: () => void;
|
|
706
|
+
}) =>
|
|
707
|
+
tryMediaPromise(() =>
|
|
708
|
+
previous.then(() => {
|
|
709
|
+
const waitMs =
|
|
710
|
+
lastStart !== null ? Math.max(0, lastStart + pacingMs - now()) : 0;
|
|
711
|
+
const wait = waitMs > 0 ? sleep(waitMs) : Promise.resolve();
|
|
712
|
+
return wait.then(
|
|
713
|
+
() => {
|
|
714
|
+
let work: Promise<void>;
|
|
715
|
+
try {
|
|
716
|
+
lastStart = now();
|
|
717
|
+
work = worker(item);
|
|
718
|
+
} finally {
|
|
719
|
+
release();
|
|
720
|
+
}
|
|
721
|
+
return work;
|
|
722
|
+
},
|
|
723
|
+
(error: unknown) => {
|
|
724
|
+
release();
|
|
725
|
+
throw error;
|
|
726
|
+
},
|
|
727
|
+
);
|
|
728
|
+
}),
|
|
729
|
+
);
|
|
730
|
+
return Effect.forEach(pacedItems, runPaced, {
|
|
731
|
+
concurrency: Math.min(parallel, items.length),
|
|
732
|
+
discard: true,
|
|
733
|
+
});
|
|
674
734
|
}
|
|
675
735
|
|
|
676
|
-
export
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const sleep = options.sleep ?? defaultSleep;
|
|
680
|
-
const fetchImpl = options.fetchImpl ?? fetch;
|
|
681
|
-
const retryMax = Math.max(0, Math.floor(options.retryMax ?? 3));
|
|
682
|
-
const parallel = Math.min(5, Math.max(1, Math.floor(options.parallel ?? 1)));
|
|
683
|
-
const pacingMs = Math.max(0, Math.floor(options.pacingMs ?? 250));
|
|
684
|
-
const videoPacingMs = Math.max(
|
|
685
|
-
0,
|
|
686
|
-
Math.floor(options.videoPacingMs ?? pacingMs),
|
|
687
|
-
);
|
|
688
|
-
const maxBytes = Math.max(
|
|
689
|
-
0,
|
|
690
|
-
Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES),
|
|
691
|
-
);
|
|
692
|
-
const userAgent =
|
|
693
|
-
options.userAgent ??
|
|
694
|
-
`birdclaw/${packageVersion ?? "0.0.0"} (https://github.com/steipete/birdclaw)`;
|
|
695
|
-
const { mediaOriginalsDir } = getBirdclawPaths();
|
|
696
|
-
mkdirSync(mediaOriginalsDir, { recursive: true });
|
|
697
|
-
|
|
698
|
-
const { candidates, skipped_cached, would_fetch } = collect(
|
|
699
|
-
options,
|
|
700
|
-
mediaOriginalsDir,
|
|
701
|
-
);
|
|
702
|
-
const result: MediaFetchResult = {
|
|
703
|
-
ok: true,
|
|
704
|
-
fetched: 0,
|
|
705
|
-
images_fetched: 0,
|
|
706
|
-
videos_fetched: 0,
|
|
707
|
-
gifs_fetched: 0,
|
|
708
|
-
reused_from_archive: 0,
|
|
709
|
-
skipped_cached,
|
|
710
|
-
failed: 0,
|
|
711
|
-
rate_limited: 0,
|
|
712
|
-
bytes: 0,
|
|
713
|
-
image_bytes: 0,
|
|
714
|
-
video_bytes: 0,
|
|
715
|
-
gif_bytes: 0,
|
|
716
|
-
duration_ms: 0,
|
|
717
|
-
failures: [],
|
|
718
|
-
...(options.dryRun ? { dry_run: true as const, would_fetch } : {}),
|
|
719
|
-
};
|
|
736
|
+
export function fetchTweetMedia(options: MediaFetchOptions = {}) {
|
|
737
|
+
return runEffectPromise(fetchTweetMediaEffect(options));
|
|
738
|
+
}
|
|
720
739
|
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
740
|
+
export function fetchTweetMediaEffect(options: MediaFetchOptions = {}) {
|
|
741
|
+
return Effect.gen(function* () {
|
|
742
|
+
const now = options.now ?? Date.now;
|
|
743
|
+
const startedAt = now();
|
|
744
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
745
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
746
|
+
const retryMax = Math.max(0, Math.floor(options.retryMax ?? 3));
|
|
747
|
+
const parallel = Math.min(
|
|
748
|
+
5,
|
|
749
|
+
Math.max(1, Math.floor(options.parallel ?? 1)),
|
|
750
|
+
);
|
|
751
|
+
const pacingMs = Math.max(0, Math.floor(options.pacingMs ?? 250));
|
|
752
|
+
const videoPacingMs = Math.max(
|
|
753
|
+
0,
|
|
754
|
+
Math.floor(options.videoPacingMs ?? pacingMs),
|
|
755
|
+
);
|
|
756
|
+
const maxBytes = Math.max(
|
|
757
|
+
0,
|
|
758
|
+
Math.floor(options.maxBytes ?? DEFAULT_MAX_BYTES),
|
|
759
|
+
);
|
|
760
|
+
const userAgent =
|
|
761
|
+
options.userAgent ??
|
|
762
|
+
`birdclaw/${packageVersion ?? "0.0.0"} (https://github.com/steipete/birdclaw)`;
|
|
763
|
+
const { mediaOriginalsDir } = getBirdclawPaths();
|
|
764
|
+
yield* tryMediaSync(() =>
|
|
765
|
+
mkdirSync(mediaOriginalsDir, { recursive: true }),
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
const { candidates, skipped_cached, would_fetch } = yield* tryMediaSync(
|
|
769
|
+
() => collect(options, mediaOriginalsDir),
|
|
770
|
+
);
|
|
771
|
+
const result: MediaFetchResult = {
|
|
772
|
+
ok: true,
|
|
773
|
+
fetched: 0,
|
|
774
|
+
images_fetched: 0,
|
|
775
|
+
videos_fetched: 0,
|
|
776
|
+
gifs_fetched: 0,
|
|
777
|
+
reused_from_archive: 0,
|
|
778
|
+
skipped_cached,
|
|
779
|
+
failed: 0,
|
|
780
|
+
rate_limited: 0,
|
|
781
|
+
bytes: 0,
|
|
782
|
+
image_bytes: 0,
|
|
783
|
+
video_bytes: 0,
|
|
784
|
+
gif_bytes: 0,
|
|
785
|
+
duration_ms: 0,
|
|
786
|
+
failures: [],
|
|
787
|
+
...(options.dryRun ? { dry_run: true as const, would_fetch } : {}),
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
if (!options.dryRun) {
|
|
791
|
+
const httpCandidates: Candidate[] = [];
|
|
792
|
+
for (const item of candidates) {
|
|
793
|
+
const reused = yield* reuseFromArchiveEffect(
|
|
735
794
|
item,
|
|
736
|
-
|
|
737
|
-
sleep,
|
|
738
|
-
retryMax,
|
|
739
|
-
userAgent,
|
|
795
|
+
mediaOriginalsDir,
|
|
740
796
|
maxBytes,
|
|
741
|
-
|
|
797
|
+
);
|
|
798
|
+
if (reused) {
|
|
799
|
+
applyFetched(result, reused);
|
|
800
|
+
} else {
|
|
801
|
+
httpCandidates.push(item);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
const fetchCandidate = (item: Candidate) =>
|
|
805
|
+
runEffectPromise(
|
|
806
|
+
fetchOneEffect({
|
|
807
|
+
item,
|
|
808
|
+
fetchImpl,
|
|
809
|
+
sleep,
|
|
810
|
+
retryMax,
|
|
811
|
+
userAgent,
|
|
812
|
+
maxBytes,
|
|
813
|
+
}).pipe(Effect.map((fetched) => applyFetched(result, fetched))),
|
|
814
|
+
);
|
|
815
|
+
yield* runGroupEffect(
|
|
816
|
+
httpCandidates.filter((item) => item.kind === "image"),
|
|
817
|
+
parallel,
|
|
818
|
+
pacingMs,
|
|
819
|
+
now,
|
|
820
|
+
sleep,
|
|
821
|
+
fetchCandidate,
|
|
742
822
|
);
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
httpCandidates.filter((item) => item.kind !== "image"),
|
|
753
|
-
1,
|
|
754
|
-
videoPacingMs,
|
|
755
|
-
now,
|
|
756
|
-
sleep,
|
|
757
|
-
fetchCandidate,
|
|
758
|
-
);
|
|
759
|
-
}
|
|
823
|
+
yield* runGroupEffect(
|
|
824
|
+
httpCandidates.filter((item) => item.kind !== "image"),
|
|
825
|
+
1,
|
|
826
|
+
videoPacingMs,
|
|
827
|
+
now,
|
|
828
|
+
sleep,
|
|
829
|
+
fetchCandidate,
|
|
830
|
+
);
|
|
831
|
+
}
|
|
760
832
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
833
|
+
result.failed = result.failures.length;
|
|
834
|
+
result.duration_ms = Math.max(0, Math.round(now() - startedAt));
|
|
835
|
+
return result;
|
|
836
|
+
});
|
|
764
837
|
}
|
|
765
838
|
|
|
766
839
|
export function formatMediaFetchResult(result: MediaFetchResult) {
|