-
Notifications
You must be signed in to change notification settings - Fork 9
feat(apify): one consolidated new-posts digest per scrape batch #760
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/scrape-alert-newness-diff
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { maybeSendScrapeDigest } from "@/lib/apify/digest/maybeSendScrapeDigest"; | ||
| import { selectApifyScraperRunsByBatch } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch"; | ||
| import { getScrapeDigestRecipients } from "@/lib/apify/digest/getScrapeDigestRecipients"; | ||
| import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail"; | ||
|
|
||
| vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch", () => ({ | ||
| selectApifyScraperRunsByBatch: vi.fn(), | ||
| })); | ||
| vi.mock("@/lib/apify/digest/getScrapeDigestRecipients", () => ({ | ||
| getScrapeDigestRecipients: vi.fn(), | ||
| })); | ||
| vi.mock("@/lib/apify/digest/sendScrapeDigestEmail", () => ({ | ||
| sendScrapeDigestEmail: vi.fn(), | ||
| })); | ||
|
|
||
| const run = (over: Record<string, unknown>) => ({ | ||
| run_id: "r1", | ||
| batch_id: "b1", | ||
| account_id: "acct-1", | ||
| social_id: "s1", | ||
| platform: "instagram", | ||
| completed_at: "2026-07-07T00:00:00Z", | ||
| new_post_urls: [], | ||
| ...over, | ||
| }); | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| vi.mocked(getScrapeDigestRecipients).mockResolvedValue(["owner@example.com"]); | ||
| vi.mocked(sendScrapeDigestEmail).mockResolvedValue({ id: "email-1" } as never); | ||
| }); | ||
|
|
||
| describe("maybeSendScrapeDigest", () => { | ||
| it("does nothing while sibling runs are still incomplete", async () => { | ||
| vi.mocked(selectApifyScraperRunsByBatch).mockResolvedValue([ | ||
| run({}), | ||
| run({ run_id: "r2", platform: "tiktok", completed_at: null }), | ||
| ] as never); | ||
| expect(await maybeSendScrapeDigest("b1")).toBeNull(); | ||
| expect(sendScrapeDigestEmail).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("sends ONE digest with per-platform new posts when the batch completes", async () => { | ||
| vi.mocked(selectApifyScraperRunsByBatch).mockResolvedValue([ | ||
| run({ new_post_urls: ["https://instagram.com/p/1"] }), | ||
| run({ run_id: "r2", platform: "tiktok", new_post_urls: ["https://tiktok.com/v/2"] }), | ||
| run({ run_id: "r3", platform: "x", new_post_urls: [] }), | ||
| ] as never); | ||
| await maybeSendScrapeDigest("b1"); | ||
| expect(sendScrapeDigestEmail).toHaveBeenCalledOnce(); | ||
| const arg = vi.mocked(sendScrapeDigestEmail).mock.calls[0][0]; | ||
| expect(arg.sections).toEqual([ | ||
| { platform: "instagram", postUrls: ["https://instagram.com/p/1"] }, | ||
| { platform: "tiktok", postUrls: ["https://tiktok.com/v/2"] }, | ||
| ]); // x omitted — nothing new | ||
| expect(arg.emails).toEqual(["owner@example.com"]); | ||
| }); | ||
|
|
||
| it("sends nothing when the batch completes with zero new posts anywhere", async () => { | ||
| vi.mocked(selectApifyScraperRunsByBatch).mockResolvedValue([ | ||
| run({}), | ||
| run({ run_id: "r2", platform: "tiktok" }), | ||
| ] as never); | ||
| expect(await maybeSendScrapeDigest("b1")).toBeNull(); | ||
| expect(sendScrapeDigestEmail).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("no-ops for a null batch id (legacy runs)", async () => { | ||
| expect(await maybeSendScrapeDigest(null)).toBeNull(); | ||
| expect(selectApifyScraperRunsByBatch).not.toHaveBeenCalled(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| import { selectAccountSocials } from "@/lib/supabase/account_socials/selectAccountSocials"; | ||
| import { getAccountArtistIds } from "@/lib/supabase/account_artist_ids/getAccountArtistIds"; | ||
| import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; | ||
|
|
||
| /** | ||
| * Resolves the digest recipients for a set of scraped socials: every owner | ||
| * of every artist watching any of them (same chain the per-platform alert | ||
| * used). Recipients span tenants — senders must BCC (chat#1855). | ||
| */ | ||
| export async function getScrapeDigestRecipients(socialIds: string[]): Promise<string[]> { | ||
| const uniqueSocialIds = Array.from(new Set(socialIds.filter(Boolean))); | ||
| if (!uniqueSocialIds.length) return []; | ||
|
|
||
| const accountSocials = ( | ||
| await Promise.all( | ||
| uniqueSocialIds.map(socialId => selectAccountSocials({ socialId, limit: 10000 })), | ||
| ) | ||
| ).flat(); | ||
|
|
||
| const accountArtistIds = await getAccountArtistIds({ | ||
| artistIds: accountSocials.map(a => a.account_id), | ||
| }); | ||
| const uniqueAccountIds = Array.from( | ||
| new Set(accountArtistIds.map(a => a.account_id).filter((id): id is string => Boolean(id))), | ||
| ); | ||
| const accountEmails = await selectAccountEmails({ accountIds: uniqueAccountIds }); | ||
| return Array.from(new Set(accountEmails.map(e => e.email).filter(Boolean))); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { selectApifyScraperRunsByBatch } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch"; | ||
| import { getScrapeDigestRecipients } from "@/lib/apify/digest/getScrapeDigestRecipients"; | ||
| import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail"; | ||
|
|
||
| /** | ||
| * Batch-completion check for the one-digest-per-scrape design (chat#1855): | ||
| * called after each platform run's results are processed. Sends the single | ||
| * consolidated digest when (a) every sibling run in the batch has completed | ||
| * and (b) at least one platform found genuinely new posts. Platforms with | ||
| * nothing new are omitted; a batch with nothing new sends nothing. | ||
| */ | ||
| export async function maybeSendScrapeDigest(batchId: string | null | undefined) { | ||
| if (!batchId) return null; | ||
|
|
||
| const runs = await selectApifyScraperRunsByBatch(batchId); | ||
| if (!runs.length || runs.some(r => !r.completed_at)) return null; | ||
|
|
||
| const sections = runs | ||
| .filter(r => (r.new_post_urls?.length ?? 0) > 0) | ||
| .map(r => ({ platform: r.platform ?? "other", postUrls: r.new_post_urls ?? [] })); | ||
| if (!sections.length) return null; | ||
|
|
||
| const emails = await getScrapeDigestRecipients( | ||
| runs.map(r => r.social_id).filter((id): id is string => Boolean(id)), | ||
| ); | ||
| return await sendScrapeDigestEmail({ emails, sections }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: A completed batch can send the consolidated digest more than once if the webhook for any registered run is delivered or replayed after all siblings are complete. Consider making the send gate idempotent with an atomic persisted marker/claim for the batch before calling Prompt for AI agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { sendEmailWithResend } from "@/lib/emails/sendEmail"; | ||
| import { RECOUP_FROM_EMAIL } from "@/lib/const"; | ||
|
|
||
| export type ScrapeDigestSection = { platform: string; postUrls: string[] }; | ||
| export type ScrapeDigestInput = { | ||
| emails: string[]; | ||
| sections: ScrapeDigestSection[]; | ||
| artistName?: string | null; | ||
| }; | ||
|
|
||
| /** | ||
| * Sends the consolidated new-posts digest: one email per scrape batch, | ||
| * one section per platform that found genuinely new posts. Deterministic | ||
| * body, direct links to each new post. Recipients span tenants — BCC only | ||
| * (chat#1855). | ||
| */ | ||
| export async function sendScrapeDigestEmail({ emails, sections, artistName }: ScrapeDigestInput) { | ||
| if (!emails.length || !sections.length) return null; | ||
|
|
||
| const total = sections.reduce((n, s) => n + s.postUrls.length, 0); | ||
| const who = artistName || "your artist"; | ||
| const sectionHtml = sections | ||
| .map( | ||
| s => | ||
| `<h3 style="margin:16px 0 4px">${s.platform}</h3><ul>${s.postUrls | ||
| .map(u => `<li><a href="${u.startsWith("http") ? u : `https://${u}`}">${u}</a></li>`) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Post URLs and platform names are interpolated into the email HTML body without any escaping. Both the href attribute and visible text content for each link use the raw URL directly, and the platform name goes into an Prompt for AI agents |
||
| .join("")}</ul>`, | ||
| ) | ||
| .join(""); | ||
|
|
||
| return await sendEmailWithResend({ | ||
| from: RECOUP_FROM_EMAIL, | ||
| to: [RECOUP_FROM_EMAIL], | ||
| bcc: emails, | ||
| subject: `${who}: ${total} new post${total === 1 ? "" : "s"} found across ${sections.length} platform${sections.length === 1 ? "" : "s"}`, | ||
| html: `<p>New posts found for ${who}:</p>${sectionHtml}`, | ||
| }); | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Digest recipients can be silently dropped for a social watched by more than 10,000 account links because this query reads only the first page. Consider paginating or moving this lookup into a DB-side joined query rather than encoding a larger fixed cap.
(Based on your team's feedback about DB-side pagination over raised Supabase limits.)
View Feedback
Prompt for AI agents