diff --git a/lib/apify/__tests__/sendApifyWebhookEmail.test.ts b/lib/apify/__tests__/sendApifyWebhookEmail.test.ts index a2044f63..f5ed448b 100644 --- a/lib/apify/__tests__/sendApifyWebhookEmail.test.ts +++ b/lib/apify/__tests__/sendApifyWebhookEmail.test.ts @@ -2,13 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { sendApifyWebhookEmail } from "@/lib/apify/sendApifyWebhookEmail"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; -vi.mock("@/lib/ai/generateText", () => ({ - default: vi.fn(async () => ({ text: "

body

" })), -})); const sendEmailWithResend = vi.fn(); vi.mock("@/lib/emails/sendEmail", () => ({ sendEmailWithResend: (...a: unknown[]) => sendEmailWithResend(...a), })); +vi.mock("@/lib/composio/getFrontendBaseUrl", () => ({ + getFrontendBaseUrl: () => "https://chat.recoupable.dev", +})); const PROFILE = { fullName: "Ashnikko", @@ -18,8 +18,16 @@ const PROFILE = { biography: "bio", followersCount: 100, followsCount: 10, - latestPosts: [], + latestPosts: [ + { + url: "https://instagram.com/p/new1", + caption: "New tour dates", + displayUrl: "https://cdn.ig/new1.jpg", + timestamp: "2026-07-08T12:00:00.000Z", + }, + ], } as never; +const NEW_URLS = ["https://instagram.com/p/new1"]; beforeEach(() => { vi.clearAllMocks(); @@ -28,12 +36,8 @@ beforeEach(() => { describe("sendApifyWebhookEmail", () => { it("BCCs recipients so no account can see another's address (chat#1855)", async () => { - const recipients = [ - "manager@customer-a.com", - "label@customer-b.com", - "internal@recoupable.com", - ]; - await sendApifyWebhookEmail(PROFILE, recipients); + const recipients = ["manager@customer-a.com", "label@customer-b.com"]; + await sendApifyWebhookEmail(PROFILE, recipients, NEW_URLS); const payload = sendEmailWithResend.mock.calls[0][0] as Record; expect(payload.bcc).toEqual(recipients); expect(payload.to).toEqual([RECOUP_FROM_EMAIL]); @@ -41,14 +45,32 @@ describe("sendApifyWebhookEmail", () => { it("never carries more than our own address in to/cc, regardless of recipient count", async () => { const recipients = Array.from({ length: 25 }, (_, i) => `user${i}@example.com`); - await sendApifyWebhookEmail(PROFILE, recipients); + await sendApifyWebhookEmail(PROFILE, recipients, NEW_URLS); const payload = sendEmailWithResend.mock.calls[0][0] as Record; - const visible = [payload.to, payload.cc].flat().filter(Boolean); - expect(visible).toEqual([RECOUP_FROM_EMAIL]); + expect([payload.to, payload.cc].flat().filter(Boolean)).toEqual([RECOUP_FROM_EMAIL]); + }); + + it("links the new posts in the deterministic body — no vendor jargon", async () => { + await sendApifyWebhookEmail(PROFILE, ["a@b.com"], NEW_URLS); + const payload = sendEmailWithResend.mock.calls[0][0] as Record; + expect(payload.html).toContain('href="https://instagram.com/p/new1"'); + expect((payload.html + payload.subject).toLowerCase()).not.toContain("apify"); + }); + + it("enriches the body with the post's media and caption from the dataset in hand", async () => { + await sendApifyWebhookEmail(PROFILE, ["a@b.com"], NEW_URLS); + const payload = sendEmailWithResend.mock.calls[0][0] as Record; + expect(payload.html).toContain('src="https://cdn.ig/new1.jpg"'); + expect(payload.html).toContain("New tour dates"); }); it("returns null and sends nothing when there are no recipients", async () => { - expect(await sendApifyWebhookEmail(PROFILE, [])).toBeNull(); + expect(await sendApifyWebhookEmail(PROFILE, [], NEW_URLS)).toBeNull(); + expect(sendEmailWithResend).not.toHaveBeenCalled(); + }); + + it("returns null and sends nothing when no post is genuinely new", async () => { + expect(await sendApifyWebhookEmail(PROFILE, ["a@b.com"], [])).toBeNull(); expect(sendEmailWithResend).not.toHaveBeenCalled(); }); }); diff --git a/lib/apify/digest/__tests__/extractArtistNameFromDatasetItems.test.ts b/lib/apify/digest/__tests__/extractArtistNameFromDatasetItems.test.ts new file mode 100644 index 00000000..ab3d1881 --- /dev/null +++ b/lib/apify/digest/__tests__/extractArtistNameFromDatasetItems.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect } from "vitest"; +import { extractArtistNameFromDatasetItems } from "@/lib/apify/digest/extractArtistNameFromDatasetItems"; + +describe("extractArtistNameFromDatasetItems", () => { + it("uses the Instagram profile fullName, falling back to username", () => { + expect( + extractArtistNameFromDatasetItems("instagram", [{ fullName: "National Geographic" }]), + ).toBe("National Geographic"); + expect(extractArtistNameFromDatasetItems("instagram", [{ username: "natgeo" }])).toBe("natgeo"); + }); + + it("uses the TikTok author nickName, falling back to handle", () => { + expect( + extractArtistNameFromDatasetItems("tiktok", [ + { authorMeta: { name: "natgeo", nickName: "National Geographic" } }, + ]), + ).toBe("National Geographic"); + expect(extractArtistNameFromDatasetItems("tiktok", [{ authorMeta: { name: "natgeo" } }])).toBe( + "natgeo", + ); + }); + + it("returns null for unknown platforms or empty items", () => { + expect(extractArtistNameFromDatasetItems("youtube", [{ some: "shape" }])).toBeNull(); + expect(extractArtistNameFromDatasetItems("instagram", [])).toBeNull(); + }); +}); + +describe("extractArtistNameFromDatasetItems — twitter", () => { + it("uses the tweet author's display name, falling back to handle", () => { + expect( + extractArtistNameFromDatasetItems("twitter", [ + { author: { name: "Sweetman", userName: "sweetman_eth" } }, + ]), + ).toBe("Sweetman"); + expect(extractArtistNameFromDatasetItems("x", [{ author: { userName: "sweetman_eth" } }])).toBe( + "sweetman_eth", + ); + }); +}); diff --git a/lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts b/lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts new file mode 100644 index 00000000..60e44e9f --- /dev/null +++ b/lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { extractPostsFromDatasetItems } from "@/lib/apify/digest/extractPostsFromDatasetItems"; + +const IG_ITEMS = [ + { + latestPosts: [ + { + url: "https://instagram.com/p/new1", + caption: "cap one", + displayUrl: "https://cdn.ig/new1.jpg", + timestamp: "2026-07-08T12:00:00.000Z", + likesCount: 100, + commentsCount: 5, + videoViewCount: 9000, + }, + { + url: "https://instagram.com/p/old", + caption: "old", + displayUrl: "https://cdn.ig/old.jpg", + timestamp: "2026-06-01T00:00:00.000Z", + }, + ], + }, +]; + +const TIKTOK_ITEMS = [ + { + webVideoUrl: "https://tiktok.com/@a/video/1", + text: "tt caption", + createTimeISO: "2026-07-09T09:00:00.000Z", + videoMeta: { coverUrl: "https://cdn.tt/1.jpg" }, + diggCount: 200, + commentCount: 10, + playCount: 50000, + shareCount: 7, + }, + { + webVideoUrl: "https://tiktok.com/@a/video/2", + text: "known", + createTimeISO: "2026-01-01T00:00:00.000Z", + }, +]; + +describe("extractPostsFromDatasetItems", () => { + it("maps Instagram latestPosts limited to the new URLs, with media", () => { + const posts = extractPostsFromDatasetItems("instagram", IG_ITEMS, [ + "https://instagram.com/p/new1", + ]); + expect(posts).toEqual([ + { + url: "https://instagram.com/p/new1", + caption: "cap one", + thumbnailUrl: "https://cdn.ig/new1.jpg", + timestamp: "2026-07-08T12:00:00.000Z", + stats: { likes: 100, comments: 5, views: 9000 }, + }, + ]); + }); + + it("maps TikTok items limited to the new URLs, with cover media", () => { + const posts = extractPostsFromDatasetItems("tiktok", TIKTOK_ITEMS, [ + "https://tiktok.com/@a/video/1", + ]); + expect(posts).toEqual([ + { + url: "https://tiktok.com/@a/video/1", + caption: "tt caption", + thumbnailUrl: "https://cdn.tt/1.jpg", + timestamp: "2026-07-09T09:00:00.000Z", + stats: { likes: 200, comments: 10, views: 50000, shares: 7 }, + }, + ]); + }); + + it("falls back to URL-only posts for platforms without an extractor", () => { + const posts = extractPostsFromDatasetItems( + "youtube", + [{ some: "shape" }], + ["https://youtube.com/watch?v=1"], + ); + expect(posts).toEqual([{ url: "https://youtube.com/watch?v=1" }]); + }); + + it("falls back to URL-only for new URLs missing from the dataset items", () => { + const posts = extractPostsFromDatasetItems("instagram", IG_ITEMS, [ + "https://instagram.com/p/new1", + "https://instagram.com/p/not-in-items", + ]); + expect(posts).toHaveLength(2); + expect(posts[1]).toEqual({ url: "https://instagram.com/p/not-in-items" }); + }); +}); + +const TWEET_ITEMS = [ + { + url: "https://x.com/a/status/1", + fullText: "New single out on all platforms", + createdAt: "Wed Jul 08 19:43:50 +0000 2026", + likeCount: 5, + replyCount: 1, + retweetCount: 2, + viewCount: 748, + extendedEntities: { media: [{ media_url_https: "https://pbs.twimg.com/media/x.jpg" }] }, + }, +]; + +describe("extractPostsFromDatasetItems — twitter", () => { + it("maps tweets limited to the new URLs, with media and stats", () => { + const posts = extractPostsFromDatasetItems("twitter", TWEET_ITEMS, [ + "https://x.com/a/status/1", + ]); + expect(posts).toEqual([ + { + url: "https://x.com/a/status/1", + caption: "New single out on all platforms", + thumbnailUrl: "https://pbs.twimg.com/media/x.jpg", + timestamp: "2026-07-08T19:43:50.000Z", + stats: { likes: 5, comments: 1, views: 748, shares: 2 }, + }, + ]); + }); + + it("maps the x platform alias identically", () => { + const posts = extractPostsFromDatasetItems("x", TWEET_ITEMS, ["https://x.com/a/status/1"]); + expect(posts[0].caption).toBe("New single out on all platforms"); + }); +}); diff --git a/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts b/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts index 8b2dbaba..0608e53d 100644 --- a/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts +++ b/lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts @@ -10,6 +10,18 @@ vi.mock("@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns", () => ({ vi.mock("@/lib/apify/digest/getScrapeDigestRecipients", () => ({ getScrapeDigestRecipients: vi.fn(), })); +vi.mock("@/lib/apify/digest/getRunDigestSection", () => ({ + // URL-only translation of a run row — enrichment is unit-tested separately + getRunDigestSection: vi.fn(async (run: { platform: string; new_post_urls: string[] | null }) => + run.new_post_urls?.length + ? { + platform: run.platform, + posts: run.new_post_urls.map((url: string) => ({ url })), + artistName: run.platform === "instagram" ? "National Geographic" : null, + } + : null, + ), +})); vi.mock("@/lib/apify/digest/sendScrapeDigestEmail", () => ({ sendScrapeDigestEmail: vi.fn(), })); @@ -51,10 +63,15 @@ describe("maybeSendScrapeDigest", () => { 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"] }, + { + platform: "instagram", + posts: [{ url: "https://instagram.com/p/1" }], + artistName: "National Geographic", + }, + { platform: "tiktok", posts: [{ url: "https://tiktok.com/v/2" }], artistName: null }, ]); // x omitted — nothing new expect(arg.emails).toEqual(["owner@example.com"]); + expect(arg.artistName).toBe("National Geographic"); // digest addressed by artist, not "Your artist" }); it("sends nothing when the batch completes with zero new posts anywhere", async () => { diff --git a/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts b/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts new file mode 100644 index 00000000..de1083d3 --- /dev/null +++ b/lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml"; + +const SECTIONS = [ + { + platform: "instagram", + posts: [ + { + url: "https://instagram.com/p/abc", + caption: "Behind the scenes tour & more", + thumbnailUrl: "https://cdn.example.com/thumb-abc.jpg", + timestamp: "2026-07-08T12:00:00.000Z", + stats: { likes: 12345, comments: 678, views: 1200000 }, + }, + { url: "https://instagram.com/p/def", caption: null, thumbnailUrl: null, timestamp: null }, + ], + }, + { + platform: "tiktok", + posts: [ + { + url: "https://tiktok.com/@a/video/1", + caption: "New single out now", + thumbnailUrl: "https://cdn.example.com/cover-1.jpg", + timestamp: "2026-07-09T09:30:00.000Z", + }, + ], + }, +]; + +describe("renderScrapeDigestHtml", () => { + it("links every new post, grouped under its platform label", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + for (const s of SECTIONS) for (const p of s.posts) expect(html).toContain(`href="${p.url}"`); + expect(html).toContain("Instagram"); + expect(html).toContain("TikTok"); + }); + + it("labels every wired platform, including LinkedIn", () => { + const { html } = renderScrapeDigestHtml({ + sections: [{ platform: "linkedin", posts: [{ url: "https://linkedin.com/posts/x" }] }], + artistName: "A", + }); + expect(html).toContain("LinkedIn"); + }); + + it("renders post media and caption excerpts when available", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(html).toContain('src="https://cdn.example.com/thumb-abc.jpg"'); + expect(html).toContain('src="https://cdn.example.com/cover-1.jpg"'); + expect(html).toContain("New single out now"); + }); + + it("escapes HTML in captions so scraped content cannot inject markup", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(html).not.toContain("tour"); + expect(html).toContain("<b>tour</b> & more"); + }); + + it("renders compact engagement stats when available", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(html).toContain("12.3K likes"); + expect(html).toContain("678 comments"); + expect(html).toContain("1.2M views"); + }); + + it("still renders a linked card when a post has no media or caption", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(html).toContain('href="https://instagram.com/p/def"'); + }); + + it("is deterministic — identical input renders identical output", () => { + const a = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + const b = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(a).toEqual(b); + }); + + it("uses the house style — achromatic chrome, no ad-hoc colors", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(html).toContain("#0a0a0a"); + expect(html).toContain("#e8e8e8"); + }); + + it("CTA always points at the chat app, never the API deployment", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(html).toContain('href="https://chat.recoupable.dev/?q='); + }); + + it("shows the Recoup logo linking to recoupable.com", () => { + const { html } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(html).toContain('href="https://recoupable.com"'); + expect(html).toContain('src="https://chat.recoupable.dev/icon-with-background.png"'); + }); + + it("names the artist in the roster footer, with a safe fallback", () => { + const named = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(named.html).toContain("because Ashnikko is in your roster on Recoup"); + const anon = renderScrapeDigestHtml({ sections: SECTIONS }); + expect(anon.html).toContain("because this artist is in your roster on Recoup"); + }); + + it("never leaks internal vendor jargon to customers", () => { + const { html, subject } = renderScrapeDigestHtml({ + sections: SECTIONS, + artistName: "Ashnikko", + }); + expect((html + subject).toLowerCase()).not.toContain("apify"); + expect((html + subject).toLowerCase()).not.toContain("dataset"); + }); + + it("counts posts and platforms in the subject", () => { + const { subject } = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Ashnikko" }); + expect(subject).toBe("Ashnikko: 3 new posts across 2 platforms"); + }); +}); diff --git a/lib/apify/digest/__tests__/sendScrapeDigestEmail.test.ts b/lib/apify/digest/__tests__/sendScrapeDigestEmail.test.ts new file mode 100644 index 00000000..2eed9b8e --- /dev/null +++ b/lib/apify/digest/__tests__/sendScrapeDigestEmail.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail"; +import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; + +const sendEmailWithResend = vi.fn(); +vi.mock("@/lib/emails/sendEmail", () => ({ + sendEmailWithResend: (...a: unknown[]) => sendEmailWithResend(...a), +})); +vi.mock("@/lib/composio/getFrontendBaseUrl", () => ({ + getFrontendBaseUrl: () => "https://chat.recoupable.dev", +})); + +const SECTIONS = [ + { + platform: "instagram", + posts: [{ url: "https://instagram.com/p/a" }, { url: "https://instagram.com/p/b" }], + }, + { platform: "tiktok", posts: [{ url: "https://tiktok.com/@x/video/1" }] }, +]; + +beforeEach(() => { + vi.clearAllMocks(); + sendEmailWithResend.mockResolvedValue({ id: "email-1" }); +}); + +describe("sendScrapeDigestEmail", () => { + it("sends the deterministic renderer's output, not an ad-hoc body (chat#1855)", async () => { + await sendScrapeDigestEmail({ emails: ["a@b.com"], sections: SECTIONS, artistName: "Nat" }); + const payload = sendEmailWithResend.mock.calls[0][0] as Record; + const rendered = renderScrapeDigestHtml({ sections: SECTIONS, artistName: "Nat" }); + expect(payload.subject).toBe(rendered.subject); + expect(payload.html).toBe(rendered.html); + }); + + it("BCCs recipients so no account can see another's address", async () => { + const recipients = ["a@b.com", "c@d.com"]; + await sendScrapeDigestEmail({ emails: recipients, sections: SECTIONS }); + const payload = sendEmailWithResend.mock.calls[0][0] as Record; + expect(payload.bcc).toEqual(recipients); + expect(payload.to).toEqual([RECOUP_FROM_EMAIL]); + }); + + it("sends nothing without recipients or sections", async () => { + expect(await sendScrapeDigestEmail({ emails: [], sections: SECTIONS })).toBeNull(); + expect(await sendScrapeDigestEmail({ emails: ["a@b.com"], sections: [] })).toBeNull(); + expect(sendEmailWithResend).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/apify/digest/asNumberOrNull.ts b/lib/apify/digest/asNumberOrNull.ts new file mode 100644 index 00000000..6b82b5b5 --- /dev/null +++ b/lib/apify/digest/asNumberOrNull.ts @@ -0,0 +1,4 @@ +/** Coerces an unknown dataset value to a finite number, or null. */ +export function asNumberOrNull(v: unknown): number | null { + return typeof v === "number" && isFinite(v) ? v : null; +} diff --git a/lib/apify/digest/asRecord.ts b/lib/apify/digest/asRecord.ts new file mode 100644 index 00000000..25d91f8f --- /dev/null +++ b/lib/apify/digest/asRecord.ts @@ -0,0 +1,4 @@ +/** Coerces an unknown dataset value to a record, or an empty one. */ +export function asRecord(v: unknown): Record { + return v && typeof v === "object" ? (v as Record) : {}; +} diff --git a/lib/apify/digest/asStringOrNull.ts b/lib/apify/digest/asStringOrNull.ts new file mode 100644 index 00000000..34e2b955 --- /dev/null +++ b/lib/apify/digest/asStringOrNull.ts @@ -0,0 +1,4 @@ +/** Coerces an unknown dataset value to a non-empty string, or null. */ +export function asStringOrNull(v: unknown): string | null { + return typeof v === "string" && v ? v : null; +} diff --git a/lib/apify/digest/buildPostStats.ts b/lib/apify/digest/buildPostStats.ts new file mode 100644 index 00000000..48ca14c8 --- /dev/null +++ b/lib/apify/digest/buildPostStats.ts @@ -0,0 +1,8 @@ +import type { ScrapeDigestPostStats } from "@/lib/apify/digest/renderScrapeDigestHtml"; + +/** Prunes null counts; returns undefined when the scraper reported none. */ +export function buildPostStats(stats: ScrapeDigestPostStats): ScrapeDigestPostStats | undefined { + return Object.values(stats).some(v => v != null) + ? Object.fromEntries(Object.entries(stats).filter(([, v]) => v != null)) + : undefined; +} diff --git a/lib/apify/digest/extractArtistNameFromDatasetItems.ts b/lib/apify/digest/extractArtistNameFromDatasetItems.ts new file mode 100644 index 00000000..1d3eddf5 --- /dev/null +++ b/lib/apify/digest/extractArtistNameFromDatasetItems.ts @@ -0,0 +1,31 @@ +const asRecord = (v: unknown): Record => + v && typeof v === "object" ? (v as Record) : {}; + +const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null); + +/** + * Pulls the scraped profile's display name out of a platform's raw dataset + * items, so the digest can be addressed by artist instead of "Your artist" + * (chat#1855). Returns null for platforms without a known shape. + */ +export function extractArtistNameFromDatasetItems( + platform: string, + items: unknown[], +): string | null { + const first = asRecord(items[0]); + switch (platform.toLowerCase()) { + case "instagram": + return str(first.fullName) ?? str(first.username); + case "tiktok": { + const author = asRecord(first.authorMeta); + return str(author.nickName) ?? str(author.name); + } + case "twitter": + case "x": { + const author = asRecord(first.author); + return str(author.name) ?? str(author.userName); + } + default: + return null; + } +} diff --git a/lib/apify/digest/extractInstagramPosts.ts b/lib/apify/digest/extractInstagramPosts.ts new file mode 100644 index 00000000..7ae7fca0 --- /dev/null +++ b/lib/apify/digest/extractInstagramPosts.ts @@ -0,0 +1,29 @@ +import { asRecord } from "@/lib/apify/digest/asRecord"; +import { asStringOrNull } from "@/lib/apify/digest/asStringOrNull"; +import { asNumberOrNull } from "@/lib/apify/digest/asNumberOrNull"; +import { buildPostStats } from "@/lib/apify/digest/buildPostStats"; +import type { ScrapeDigestPost } from "@/lib/apify/digest/renderScrapeDigestHtml"; + +/** Instagram profile-scraper dataset: posts ride on item[0].latestPosts. */ +export function extractInstagramPosts(items: unknown[]): Map { + const map = new Map(); + const latest = asRecord(items[0]).latestPosts; + for (const raw of Array.isArray(latest) ? latest : []) { + const post = asRecord(raw); + const url = asStringOrNull(post.url); + if (!url) continue; + const stats = buildPostStats({ + likes: asNumberOrNull(post.likesCount), + comments: asNumberOrNull(post.commentsCount), + views: asNumberOrNull(post.videoViewCount), + }); + map.set(url, { + url, + caption: asStringOrNull(post.caption), + thumbnailUrl: asStringOrNull(post.displayUrl), + timestamp: asStringOrNull(post.timestamp), + ...(stats && { stats }), + }); + } + return map; +} diff --git a/lib/apify/digest/extractPostsFromDatasetItems.ts b/lib/apify/digest/extractPostsFromDatasetItems.ts new file mode 100644 index 00000000..2ed8793d --- /dev/null +++ b/lib/apify/digest/extractPostsFromDatasetItems.ts @@ -0,0 +1,27 @@ +import { extractInstagramPosts } from "@/lib/apify/digest/extractInstagramPosts"; +import { extractTiktokPosts } from "@/lib/apify/digest/extractTiktokPosts"; +import { extractTwitterPosts } from "@/lib/apify/digest/extractTwitterPosts"; +import type { ScrapeDigestPost } from "@/lib/apify/digest/renderScrapeDigestHtml"; + +const EXTRACTORS: Record Map> = { + instagram: extractInstagramPosts, + tiktok: extractTiktokPosts, + twitter: extractTwitterPosts, + x: extractTwitterPosts, +}; + +/** + * Builds rich digest posts (caption, media, stats, timestamp) for a + * platform's genuinely-new post URLs from its raw scraper dataset items. + * Platforms without an extractor — and URLs missing from the items — + * degrade to URL-only posts, so enrichment can never lose a link (chat#1855). + */ +export function extractPostsFromDatasetItems( + platform: string, + items: unknown[], + newPostUrls: string[], +): ScrapeDigestPost[] { + const extractor = EXTRACTORS[platform.toLowerCase()]; + const byUrl = extractor ? extractor(items) : new Map(); + return newPostUrls.map(url => byUrl.get(url) ?? { url }); +} diff --git a/lib/apify/digest/extractTiktokPosts.ts b/lib/apify/digest/extractTiktokPosts.ts new file mode 100644 index 00000000..a3346b22 --- /dev/null +++ b/lib/apify/digest/extractTiktokPosts.ts @@ -0,0 +1,29 @@ +import { asRecord } from "@/lib/apify/digest/asRecord"; +import { asStringOrNull } from "@/lib/apify/digest/asStringOrNull"; +import { asNumberOrNull } from "@/lib/apify/digest/asNumberOrNull"; +import { buildPostStats } from "@/lib/apify/digest/buildPostStats"; +import type { ScrapeDigestPost } from "@/lib/apify/digest/renderScrapeDigestHtml"; + +/** TikTok scraper dataset: one item per video. */ +export function extractTiktokPosts(items: unknown[]): Map { + const map = new Map(); + for (const raw of items) { + const item = asRecord(raw); + const url = asStringOrNull(item.webVideoUrl); + if (!url) continue; + const stats = buildPostStats({ + likes: asNumberOrNull(item.diggCount), + comments: asNumberOrNull(item.commentCount), + views: asNumberOrNull(item.playCount), + shares: asNumberOrNull(item.shareCount), + }); + map.set(url, { + url, + caption: asStringOrNull(item.text), + thumbnailUrl: asStringOrNull(asRecord(item.videoMeta).coverUrl), + timestamp: asStringOrNull(item.createTimeISO), + ...(stats && { stats }), + }); + } + return map; +} diff --git a/lib/apify/digest/extractTwitterPosts.ts b/lib/apify/digest/extractTwitterPosts.ts new file mode 100644 index 00000000..e9320f34 --- /dev/null +++ b/lib/apify/digest/extractTwitterPosts.ts @@ -0,0 +1,32 @@ +import { asRecord } from "@/lib/apify/digest/asRecord"; +import { asStringOrNull } from "@/lib/apify/digest/asStringOrNull"; +import { asNumberOrNull } from "@/lib/apify/digest/asNumberOrNull"; +import { buildPostStats } from "@/lib/apify/digest/buildPostStats"; +import { toIsoDate } from "@/lib/apify/toIsoDate"; +import type { ScrapeDigestPost } from "@/lib/apify/digest/renderScrapeDigestHtml"; + +/** X/Twitter scraper dataset (apidojo): one item per tweet. */ +export function extractTwitterPosts(items: unknown[]): Map { + const map = new Map(); + for (const raw of items) { + const item = asRecord(raw); + const url = asStringOrNull(item.url); + if (!url) continue; + const media = asRecord(item.extendedEntities).media; + const firstMedia = asRecord(Array.isArray(media) ? media[0] : undefined); + const stats = buildPostStats({ + likes: asNumberOrNull(item.likeCount), + comments: asNumberOrNull(item.replyCount), + views: asNumberOrNull(item.viewCount), + shares: asNumberOrNull(item.retweetCount), + }); + map.set(url, { + url, + caption: asStringOrNull(item.fullText) ?? asStringOrNull(item.text), + thumbnailUrl: asStringOrNull(firstMedia.media_url_https), + timestamp: toIsoDate(asStringOrNull(item.createdAt) ?? undefined) ?? null, + ...(stats && { stats }), + }); + } + return map; +} diff --git a/lib/apify/digest/formatCompactCount.ts b/lib/apify/digest/formatCompactCount.ts new file mode 100644 index 00000000..4194fba3 --- /dev/null +++ b/lib/apify/digest/formatCompactCount.ts @@ -0,0 +1,6 @@ +/** Deterministic compact count: 678, 12.3K, 1.2M (trailing .0 stripped). */ +export function formatCompactCount(n: number): string { + if (n < 1000) return String(n); + const [value, unit] = n < 1_000_000 ? [n / 1000, "K"] : [n / 1_000_000, "M"]; + return `${value.toFixed(1).replace(/\.0$/, "")}${unit}`; +} diff --git a/lib/apify/digest/formatPostStats.ts b/lib/apify/digest/formatPostStats.ts new file mode 100644 index 00000000..09e5a1f2 --- /dev/null +++ b/lib/apify/digest/formatPostStats.ts @@ -0,0 +1,12 @@ +import { formatCompactCount } from "@/lib/apify/digest/formatCompactCount"; +import type { ScrapeDigestPostStats } from "@/lib/apify/digest/renderScrapeDigestHtml"; + +/** "12.3K likes · 678 comments · 1.2M views" from whichever stats exist. */ +export function formatPostStats(stats: ScrapeDigestPostStats): string { + const parts: string[] = []; + if (stats.likes != null) parts.push(`${formatCompactCount(stats.likes)} likes`); + if (stats.comments != null) parts.push(`${formatCompactCount(stats.comments)} comments`); + if (stats.views != null) parts.push(`${formatCompactCount(stats.views)} views`); + if (stats.shares != null) parts.push(`${formatCompactCount(stats.shares)} shares`); + return parts.join(" · "); +} diff --git a/lib/apify/digest/formatUtcDateLabel.ts b/lib/apify/digest/formatUtcDateLabel.ts new file mode 100644 index 00000000..14f7100a --- /dev/null +++ b/lib/apify/digest/formatUtcDateLabel.ts @@ -0,0 +1,8 @@ +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** Deterministic "Jul 8" date label (UTC — no locale/timezone variance). */ +export function formatUtcDateLabel(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return ""; + return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; +} diff --git a/lib/apify/digest/getPlatformLabel.ts b/lib/apify/digest/getPlatformLabel.ts new file mode 100644 index 00000000..48f0a51f --- /dev/null +++ b/lib/apify/digest/getPlatformLabel.ts @@ -0,0 +1,15 @@ +const PLATFORM_LABELS: Record = { + instagram: "Instagram", + tiktok: "TikTok", + x: "X", + twitter: "X", + youtube: "YouTube", + linkedin: "LinkedIn", + facebook: "Facebook", + threads: "Threads", +}; + +/** Customer-facing label for a scraper platform key. */ +export function getPlatformLabel(platform: string): string { + return PLATFORM_LABELS[platform.toLowerCase()] ?? platform; +} diff --git a/lib/apify/digest/getRunDigestSection.ts b/lib/apify/digest/getRunDigestSection.ts new file mode 100644 index 00000000..96b103bc --- /dev/null +++ b/lib/apify/digest/getRunDigestSection.ts @@ -0,0 +1,40 @@ +import apifyClient from "@/lib/apify/client"; +import { parseNewPostUrls } from "@/lib/apify/digest/parseNewPostUrls"; +import { extractPostsFromDatasetItems } from "@/lib/apify/digest/extractPostsFromDatasetItems"; +import { extractArtistNameFromDatasetItems } from "@/lib/apify/digest/extractArtistNameFromDatasetItems"; +import type { ScrapeDigestSection } from "@/lib/apify/digest/renderScrapeDigestHtml"; +import type { Tables } from "@/types/database.types"; + +/** A digest section plus the profile display name the platform reported. */ +export type RunDigestSection = ScrapeDigestSection & { artistName: string | null }; + +/** + * Builds one platform's digest section for a completed scrape run, enriching + * the stored genuinely-new URLs with caption/media/stats and the profile's + * display name by re-reading the run's dataset from Apify (the source of + * truth for scrape content — only the URL diff is persisted, chat#1855). + * Enrichment must never block the digest: any failure degrades to URL-only + * posts. Returns null when the run found nothing new. + */ +export async function getRunDigestSection( + run: Tables<"apify_scraper_runs">, +): Promise { + const urls = parseNewPostUrls(run.new_post_urls); + if (!urls.length) return null; + const platform = run.platform ?? "other"; + + try { + const runInfo = await apifyClient.run(run.run_id).get(); + const datasetId = runInfo?.defaultDatasetId; + if (!datasetId) return { platform, posts: urls.map(url => ({ url })), artistName: null }; + const { items } = await apifyClient.dataset(datasetId).listItems(); + return { + platform, + posts: extractPostsFromDatasetItems(platform, items, urls), + artistName: extractArtistNameFromDatasetItems(platform, items), + }; + } catch (error) { + console.error("[WARN] digest enrichment failed; sending URL-only links:", error); + return { platform, posts: urls.map(url => ({ url })), artistName: null }; + } +} diff --git a/lib/apify/digest/maybeSendScrapeDigest.ts b/lib/apify/digest/maybeSendScrapeDigest.ts index cd14c13f..c68b5179 100644 --- a/lib/apify/digest/maybeSendScrapeDigest.ts +++ b/lib/apify/digest/maybeSendScrapeDigest.ts @@ -1,5 +1,6 @@ import { selectApifyScraperRuns } from "@/lib/supabase/apify_scraper_runs/selectApifyScraperRuns"; -import { parseNewPostUrls } from "@/lib/apify/digest/parseNewPostUrls"; +import { getRunDigestSection } from "@/lib/apify/digest/getRunDigestSection"; +import type { RunDigestSection } from "@/lib/apify/digest/getRunDigestSection"; import { getScrapeDigestRecipients } from "@/lib/apify/digest/getScrapeDigestRecipients"; import { sendScrapeDigestEmail } from "@/lib/apify/digest/sendScrapeDigestEmail"; @@ -16,13 +17,15 @@ export async function maybeSendScrapeDigest(batchId: string | null | undefined) const runs = await selectApifyScraperRuns({ batchId }); if (!runs.length || runs.some(r => !r.completed_at)) return null; - const sections = runs - .map(r => ({ platform: r.platform ?? "other", postUrls: parseNewPostUrls(r.new_post_urls) })) - .filter(s => s.postUrls.length > 0); + const sections = (await Promise.all(runs.map(getRunDigestSection))).filter( + (s): s is RunDigestSection => s !== null, + ); 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 }); + // A batch is one artist's scrape — any platform's profile name addresses it. + const artistName = sections.map(s => s.artistName).find(Boolean) ?? null; + return await sendScrapeDigestEmail({ emails, sections, artistName }); } diff --git a/lib/apify/digest/renderScrapeDigestHtml.ts b/lib/apify/digest/renderScrapeDigestHtml.ts new file mode 100644 index 00000000..0745b1f9 --- /dev/null +++ b/lib/apify/digest/renderScrapeDigestHtml.ts @@ -0,0 +1,84 @@ +import { CHAT_APP_URL, RECOUP_LOGO_URL, WEBSITE_URL } from "@/lib/const"; +import { escapeHtml } from "@/lib/emails/escapeHtml"; +import { formatUtcDateLabel } from "@/lib/apify/digest/formatUtcDateLabel"; +import { formatPostStats } from "@/lib/apify/digest/formatPostStats"; +import { getPlatformLabel } from "@/lib/apify/digest/getPlatformLabel"; +import { truncateText } from "@/lib/apify/digest/truncateText"; + +export type ScrapeDigestPostStats = { + likes?: number | null; + comments?: number | null; + views?: number | null; + shares?: number | null; +}; + +export type ScrapeDigestPost = { + url: string; + caption?: string | null; + thumbnailUrl?: string | null; + timestamp?: string | null; + stats?: ScrapeDigestPostStats | null; +}; + +export type ScrapeDigestSection = { platform: string; posts: ScrapeDigestPost[] }; + +/** + * Deterministic house-style renderer for the new-posts digest (chat#1855). + * Replaces the per-send LLM body: identical input renders identical output, + * every genuinely-new post is a linked card with its media, caption, and + * engagement stats, and internal vendor jargon never reaches customers. + * Styling follows DESIGN.md — achromatic chrome (#0a0a0a on #ffffff, #e8e8e8 + * borders, #6b6b6b muted); color comes from the post media, not the chrome. + * Email-safe: tables + inline styles only, system font stack. The CTA is the + * fixed CHAT_APP_URL — never a derived deployment URL (previews would point + * customers at the API deployment itself). + */ +export function renderScrapeDigestHtml({ + sections, + artistName, +}: { + sections: ScrapeDigestSection[]; + artistName?: string | null; +}): { subject: string; html: string } { + const who = artistName || "Your artist"; + const total = sections.reduce((n, s) => n + s.posts.length, 0); + const subject = `${who}: ${total} new post${total === 1 ? "" : "s"} across ${sections.length} platform${sections.length === 1 ? "" : "s"}`; + + const sectionHtml = sections + .map(section => { + const cards = section.posts + .map(post => { + const href = post.url.startsWith("http") ? post.url : `https://${post.url}`; + const caption = post.caption ? escapeHtml(truncateText(post.caption, 110)) : ""; + const date = post.timestamp ? formatUtcDateLabel(post.timestamp) : ""; + const stats = post.stats ? formatPostStats(post.stats) : ""; + const thumbCell = post.thumbnailUrl + ? `` + : ""; + return `
${thumbCell}
${caption ? `

${caption}

` : ""}${stats ? `

${stats}

` : ""}

${date ? `${date} · ` : ""}View post →

`; + }) + .join(""); + return `

${getPlatformLabel(section.platform)}

${cards}`; + }) + .join(""); + + const chatUrl = `${CHAT_APP_URL}/?q=${encodeURIComponent("tell me about my artist's latest posts")}`; + const html = `
+ +
+ + + +
+

New posts

+

${escapeHtml(who)}

+

${total} new post${total === 1 ? "" : "s"} since we last checked

+
Recoup
+${sectionHtml} +
Ask Recoup about these posts →
+

You're receiving this because ${artistName ? escapeHtml(artistName) : "this artist"} is in your roster on Recoup.

+
+
`; + + return { subject, html }; +} diff --git a/lib/apify/digest/sendScrapeDigestEmail.ts b/lib/apify/digest/sendScrapeDigestEmail.ts index c75df694..3d1cfcf2 100644 --- a/lib/apify/digest/sendScrapeDigestEmail.ts +++ b/lib/apify/digest/sendScrapeDigestEmail.ts @@ -1,7 +1,8 @@ import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; +import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml"; +import type { ScrapeDigestSection } from "@/lib/apify/digest/renderScrapeDigestHtml"; -export type ScrapeDigestSection = { platform: string; postUrls: string[] }; export type ScrapeDigestInput = { emails: string[]; sections: ScrapeDigestSection[]; @@ -17,22 +18,13 @@ export type ScrapeDigestInput = { 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 => - `

${s.platform}

    ${s.postUrls - .map(u => `
  • ${u}
  • `) - .join("")}
`, - ) - .join(""); + const { subject, html } = renderScrapeDigestHtml({ sections, artistName }); 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: `

New posts found for ${who}:

${sectionHtml}`, + subject, + html, }); } diff --git a/lib/apify/digest/truncateText.ts b/lib/apify/digest/truncateText.ts new file mode 100644 index 00000000..13c36986 --- /dev/null +++ b/lib/apify/digest/truncateText.ts @@ -0,0 +1,4 @@ +/** Truncates to `max` characters with a trailing ellipsis. */ +export function truncateText(text: string, max: number): string { + return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`; +} diff --git a/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts b/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts index 2a985014..d8310dbd 100644 --- a/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts +++ b/lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts @@ -97,7 +97,7 @@ describe("handleInstagramProfileScraperResults", () => { expect(upsertPosts).toHaveBeenCalledOnce(); expect(upsertSocialPosts).toHaveBeenCalledOnce(); - expect(sendApifyWebhookEmail).toHaveBeenCalledWith(expect.any(Object), ["x@y.com"]); + expect(sendApifyWebhookEmail).toHaveBeenCalledWith(expect.any(Object), ["x@y.com"], ["u1"]); expect(handleInstagramProfileFollowUpRuns).toHaveBeenCalledOnce(); expect(result.social).toEqual({ id: "s1" }); expect(result.posts).toEqual(posts); diff --git a/lib/apify/instagram/handleInstagramProfileScraperResults.ts b/lib/apify/instagram/handleInstagramProfileScraperResults.ts index 3244e8eb..de38d4c4 100644 --- a/lib/apify/instagram/handleInstagramProfileScraperResults.ts +++ b/lib/apify/instagram/handleInstagramProfileScraperResults.ts @@ -112,6 +112,7 @@ export async function handleInstagramProfileScraperResults(parsed: ApifyWebhookP sentEmails = await sendApifyWebhookEmail( firstResult, accountEmails.map(e => e.email).filter(Boolean), + newPostUrls, ); } } catch (error) { diff --git a/lib/apify/sendApifyWebhookEmail.ts b/lib/apify/sendApifyWebhookEmail.ts index 3510dccd..1f07ce8d 100644 --- a/lib/apify/sendApifyWebhookEmail.ts +++ b/lib/apify/sendApifyWebhookEmail.ts @@ -1,48 +1,37 @@ -import generateText from "@/lib/ai/generateText"; import { sendEmailWithResend } from "@/lib/emails/sendEmail"; import { RECOUP_FROM_EMAIL } from "@/lib/const"; -import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; +import { renderScrapeDigestHtml } from "@/lib/apify/digest/renderScrapeDigestHtml"; +import { extractPostsFromDatasetItems } from "@/lib/apify/digest/extractPostsFromDatasetItems"; import type { ApifyInstagramProfileResult } from "@/lib/apify/types"; /** - * Sends an Apify-webhook summary email to the given recipients using - * Resend. Generates the email body via an LLM based on the first - * profile result. + * Sends the legacy single-platform Instagram alert (non-batch scrape runs). + * Batch runs get the consolidated digest instead. Body comes from the shared + * deterministic renderer — the per-send LLM body is gone (chat#1855): stable + * branding, direct links to the new posts, no vendor jargon. * * @param profile - First dataset result from the profile scraper. - * @param emails - Recipient email addresses. - * @returns The Resend response, or `null` when there are no recipients. + * @param emails - Recipient email addresses (cross-tenant — BCC only). + * @param newPostUrls - Post URLs genuinely new to the platform. + * @returns The Resend response, or `null` when there is nothing to send. */ export async function sendApifyWebhookEmail( profile: ApifyInstagramProfileResult, emails: string[], + newPostUrls: string[] = [], ) { - if (!emails?.length) return null; + if (!emails?.length || !newPostUrls.length) return null; - const prompt = `You have a new Apify dataset update. Here is the data: - -Key Data -Full Name: ${profile.fullName} -Username: ${profile.username} -Profile URL: ${profile.url} -Profile Picture: ${profile.profilePicUrl} -Biography: ${profile.biography} -Followers: ${profile.followersCount} -Following: ${profile.followsCount} -Latest Posts: ${(Array.isArray(profile.latestPosts) ? profile.latestPosts : []).map(p => JSON.stringify(p)).join(", ")} -`; - - const { text } = await generateText({ - system: `you are a record label services manager for Recoup. - write beautiful html email. - subject: New Apify Dataset Notification. you're notifying music managers about new posts being available for one of their roster musician's Instagram profile. - include a link to view the instagram profile. - call to action is to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents): ${getFrontendBaseUrl()}/?q=tell%20me%20about%20my%20latest%20Ig%20posts - You'll be passed a dataset summary for a musician profile and their latest posts on instagram. - your goal is to get the recipient to click a cta link to open a chat link to learn more about the latest posts using Recoup Chat (AI Agents). - only include the email body html. - no headers or subject`, - prompt, + // The profile IS dataset item 0, so the shared extractor enriches the new + // URLs with caption/media/timestamp from latestPosts already in hand. + const { subject, html } = renderScrapeDigestHtml({ + sections: [ + { + platform: "instagram", + posts: extractPostsFromDatasetItems("instagram", [profile], newPostUrls), + }, + ], + artistName: profile.fullName ?? profile.username ?? null, }); // Recipients span multiple customer accounts (the social's watchers are @@ -52,7 +41,7 @@ Latest Posts: ${(Array.isArray(profile.latestPosts) ? profile.latestPosts : []). from: RECOUP_FROM_EMAIL, to: [RECOUP_FROM_EMAIL], bcc: emails, - subject: `${profile.fullName ?? profile.username ?? "Your artist"} has new posts on Instagram`, - html: text, + subject, + html, }); } diff --git a/lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts b/lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts index 0497e058..3d53e946 100644 --- a/lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts +++ b/lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts @@ -76,4 +76,22 @@ describe("handleTwitterProfileScraperResults", () => { expect(upsertSocials).not.toHaveBeenCalled(); expect(persistPostsForSocial).not.toHaveBeenCalled(); }); + + it("excludes retweets and replies from persisted posts and the newness diff", async () => { + listItems.mockResolvedValue({ + items: [ + { ...REAL_ITEM, url: "https://x.com/a/status/1" }, + { ...REAL_ITEM, url: "https://x.com/other/status/2", isRetweet: true }, + { ...REAL_ITEM, url: "https://x.com/a/status/3", isReply: true }, + ], + }); + await handleTwitterProfileScraperResults(payload as never); + expect(persistPostsForSocial).toHaveBeenCalledWith( + expect.objectContaining({ + postRows: [ + { post_url: "https://x.com/a/status/1", updated_at: "2026-07-02T17:21:21.000Z" }, + ], + }), + ); + }); }); diff --git a/lib/apify/twitter/__tests__/isOriginalTweet.test.ts b/lib/apify/twitter/__tests__/isOriginalTweet.test.ts new file mode 100644 index 00000000..6ac47cec --- /dev/null +++ b/lib/apify/twitter/__tests__/isOriginalTweet.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { isOriginalTweet } from "@/lib/apify/twitter/isOriginalTweet"; + +describe("isOriginalTweet", () => { + it("keeps original tweets", () => { + expect(isOriginalTweet({ isRetweet: false, isQuote: false, isReply: false })).toBe(true); + }); + it("keeps quote tweets — the artist adds their own words and earns the stats", () => { + expect(isOriginalTweet({ isRetweet: false, isQuote: true, isReply: false })).toBe(true); + }); + it("drops retweets — the content and metrics belong to someone else", () => { + expect(isOriginalTweet({ isRetweet: true, isQuote: false, isReply: false })).toBe(false); + }); + it("drops replies — conversational noise, not roster posts", () => { + expect(isOriginalTweet({ isRetweet: false, isQuote: false, isReply: true })).toBe(false); + }); + it("keeps items without flags (defensive default)", () => { + expect(isOriginalTweet({})).toBe(true); + }); +}); diff --git a/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts b/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts index 325e1f48..43fc1605 100644 --- a/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts +++ b/lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts @@ -11,11 +11,11 @@ beforeEach(() => { }); describe("startTwitterProfileScraping", () => { - it("defaults to the legacy single-item snapshot (maxItems: 1) when posts is omitted", async () => { + it("defaults to a 10-item fetch when posts is omitted — retweets/replies are filtered AFTER the fetch, so depth 1 would rarely surface an authored post", async () => { const r = await startTwitterProfileScraping("sweetman_eth"); expect(apifyClient.actor).toHaveBeenCalledWith("apidojo/twitter-scraper-lite"); expect(start).toHaveBeenCalledWith( - { twitterHandles: ["sweetman_eth"], sort: "Latest", maxItems: 1 }, + { twitterHandles: ["sweetman_eth"], sort: "Latest", maxItems: 10 }, { webhooks: expect.any(Array) }, ); expect(r).toEqual({ runId: "run-1", datasetId: "ds-1" }); diff --git a/lib/apify/twitter/handleTwitterProfileScraperResults.ts b/lib/apify/twitter/handleTwitterProfileScraperResults.ts index 4634fae0..766aa6e1 100644 --- a/lib/apify/twitter/handleTwitterProfileScraperResults.ts +++ b/lib/apify/twitter/handleTwitterProfileScraperResults.ts @@ -4,12 +4,16 @@ import { normalizeProfileUrl } from "@/lib/socials/normalizeProfileUrl"; import { persistPostsForSocial } from "@/lib/apify/persistPostsForSocial"; import { filterNewPostUrls } from "@/lib/socials/filterNewPostUrls"; import { toIsoDate } from "@/lib/apify/toIsoDate"; +import { isOriginalTweet } from "@/lib/apify/twitter/isOriginalTweet"; import type { ApifyWebhookPayload } from "@/lib/apify/validateApifyWebhookRequest"; import type { TablesInsert } from "@/types/database.types"; /** Tweet item from apidojo~twitter-scraper-lite (real shape, run ALVMZYXkh3WHgeGfT; * tweet fields verified on run bx3asRqfbNnkKgogG). */ type TweetItem = { + isRetweet?: boolean; + isQuote?: boolean; + isReply?: boolean; url?: string; createdAt?: string; author?: { @@ -50,8 +54,12 @@ export async function handleTwitterProfileScraperResults(parsed: ApifyWebhookPay // Tweet URLs keep the author's display casing — the path segment is the // case-sensitive status id's context, and unlike profile keys they are // stored as-is (posts upsert keys on exact post_url). + // Only the artist's own posts count — retweets are someone else's content + // and stats, replies are conversation (chat#1855). const postRows: TablesInsert<"posts">[] = (items as TweetItem[]).flatMap(item => - item.url ? [{ post_url: item.url, updated_at: toIsoDate(item.createdAt) }] : [], + item.url && isOriginalTweet(item) + ? [{ post_url: item.url, updated_at: toIsoDate(item.createdAt) }] + : [], ); // Diff before persisting so the digest can report genuinely new posts (chat#1855). const newPostUrls = await filterNewPostUrls(postRows.map(p => p.post_url)); diff --git a/lib/apify/twitter/isOriginalTweet.ts b/lib/apify/twitter/isOriginalTweet.ts new file mode 100644 index 00000000..22d435db --- /dev/null +++ b/lib/apify/twitter/isOriginalTweet.ts @@ -0,0 +1,14 @@ +type TweetFlags = { isRetweet?: boolean; isQuote?: boolean; isReply?: boolean }; + +/** + * True for tweets the artist actually authored — originals and quote tweets + * (their own words on top). Retweets are someone else's content and carry the + * ORIGINAL author's engagement stats, so persisting or reporting them + * misattributes both (chat#1855 feedback: an all-retweet digest section + * showed "hundreds of likes" the artist never earned). Replies are excluded + * as conversational noise rather than roster posts. Items without flags pass + * (defensive default — never silently drop a scrape result on schema drift). + */ +export function isOriginalTweet(item: TweetFlags): boolean { + return !item.isRetweet && !item.isReply; +} diff --git a/lib/apify/twitter/startTwitterProfileScraping.ts b/lib/apify/twitter/startTwitterProfileScraping.ts index 16821355..5de2e1bf 100644 --- a/lib/apify/twitter/startTwitterProfileScraping.ts +++ b/lib/apify/twitter/startTwitterProfileScraping.ts @@ -15,7 +15,10 @@ const startTwitterProfileScraping = async ( const input = { twitterHandles: [cleanHandle], sort: "Latest", - maxItems: posts ?? 1, + // Timeline items include retweets/replies, which are dropped AFTER the + // fetch (isOriginalTweet) — depth 1 would rarely surface an authored + // post for active retweeters (chat#1855). + maxItems: posts ?? 10, }; const run = await apifyClient diff --git a/lib/const.ts b/lib/const.ts index 5b178dd0..4a05312c 100644 --- a/lib/const.ts +++ b/lib/const.ts @@ -15,6 +15,16 @@ export const PRIVY_PROJECT_SECRET = process.env.PRIVY_PROJECT_SECRET; /** Base URL for the public API documentation site */ export const DOCS_BASE_URL = "https://docs.recoupable.dev"; +/** Public marketing site. */ +export const WEBSITE_URL = "https://recoupable.com"; + +/** Chat app — customer-facing emails must always link here, never a derived + * deployment URL (previews would point at the API deployment itself). */ +export const CHAT_APP_URL = "https://chat.recoupable.dev"; + +/** Brand icon hosted on prod chat (email clients need an absolute PNG). */ +export const RECOUP_LOGO_URL = "https://chat.recoupable.dev/icon-with-background.png"; + /** Domain for receiving inbound emails (e.g., support@recoupable.dev) */ export const INBOUND_EMAIL_DOMAIN = "@recoupable.dev"; diff --git a/lib/emails/escapeHtml.ts b/lib/emails/escapeHtml.ts new file mode 100644 index 00000000..c0d8bf6d --- /dev/null +++ b/lib/emails/escapeHtml.ts @@ -0,0 +1,11 @@ +/** + * Escapes text for safe interpolation into email/notification HTML, so + * user- or scraper-supplied content can never inject markup. + */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +}