Skip to content

feat(apify): deterministic digest template — direct links to each new post, no LLM body#761

Merged
sweetmantech merged 13 commits into
mainfrom
feat/scrape-digest-template
Jul 9, 2026
Merged

feat(apify): deterministic digest template — direct links to each new post, no LLM body#761
sweetmantech merged 13 commits into
mainfrom
feat/scrape-digest-template

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

The content half of the revamp on recoupable/chat#1855 (PR #5 of 6, stacked on api#760): a shared deterministic renderer replaces the per-send LLM body. Evidence on the issue showed the LLM body produced different branding on every send, leaked vendor jargon ("Apify Dataset Notification") to customers, and didn't reliably link the posts it claimed were new.

What changed

  • lib/apify/digest/renderScrapeDigestHtml.ts (new): deterministic subject + HTML — per-platform sections, a direct link to every genuinely-new post, friendly platform labels, chat CTA as secondary link. No LLM call.
  • sendScrapeDigestEmail (batch digest) and sendApifyWebhookEmail (legacy solo alert) both use it. The solo alert now takes newPostUrls and sends nothing without them; its LLM path (generateText) is deleted.
  • Note on api#758 overlap: this branch rewrites sendApifyWebhookEmail.ts wholesale and embodies the same BCC invariant (tests recreated here) — merging fix(emails): BCC scrape-alert recipients — never a shared cross-tenant To: line #758 first then this stack resolves in favor of this version with no behavior change.

Tests (RED→GREEN)

  • Renderer: links every post grouped by platform; byte-identical output for identical input; no "apify"/"dataset" wording anywhere customer-visible; subject counts posts and platforms.
  • Solo alert: BCC invariant (×2), links present, no-recipients and no-new-posts short-circuits.
  • Sweep: vitest run lib/apify all green; tsc at baseline parity; eslint clean.

🤖 Generated with Claude Code


Summary by cubic

Replaced the LLM email with a deterministic, house‑style digest that links to every genuinely new post with media, captions, UTC dates, and compact stats; emails are addressed by the artist name, show the Recoup logo, include a roster footer, and the CTA always points to the chat app. Also adds X/Twitter post extraction and a LinkedIn label, ignores retweets and replies to avoid misattributing engagement, and fetches 10 tweets by default so original posts surface after filtering.

  • Refactors

    • Added focused helpers and extractors, including extractTwitterPosts, extractArtistNameFromDatasetItems, formatPostStats, formatUtcDateLabel, getPlatformLabel, and escapeHtml; platform labels cover X/Twitter and LinkedIn.
    • Added getRunDigestSection and updated maybeSendScrapeDigest to re-read run datasets for enrichment, degrade to URL‑only on failure, address the digest by the profile’s display name, and default X/Twitter scraping to 10 items to survive the retweet/reply filter.
  • Migration

    • Update calls to sendApifyWebhookEmail(profile, emails, newPostUrls); newPostUrls is now required.
    • No email is sent (returns null) when emails is empty or newPostUrls is empty; recipients are BCC‑only and to is set to RECOUP_FROM_EMAIL.

Written for commit cd5761b. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • New Features

    • Added consolidated scrape digest emails for newly discovered Instagram and TikTok posts.
    • Digest emails now include platform sections, artist names, captions, thumbnails, timestamps, and engagement metrics.
    • Added clear subject lines, compact metric formatting, platform labels, and links to the website and chat experience.
    • Posts remain visible as URL-only entries when enrichment data is unavailable.
  • Bug Fixes

    • Corrected webhook notifications so newly discovered post URLs are included in outgoing emails.
    • Improved handling of missing or invalid scrape data without preventing notifications.

sweetmantech and others added 3 commits July 6, 2026 16:55
… posts first

The Instagram scrape alert fired whenever a scrape returned posts, with no
comparison against posts already stored — every scrape re-announced the
profile's recent feed as new (observed: 6 of 7 alerts in one day announced
posts up to 10 days old). New reusable filterNewPostUrls diffs candidate
URLs against posts BEFORE upsert; the alert is gated on a non-empty result.
Persistence unchanged (recoupable/chat#1855, PR #3 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A roster scrape starts one Apify run per platform, each completing
independently — extending per-platform alerts would mean 4+ emails per
scrape. This registers every run under a batch_id at scrape start
(apify_scraper_runs, columns from recoupable/database#41), records each
webhook completion with its genuinely-new post URLs, and when the batch's
last run completes sends ONE digest (per-platform sections, BCC-only)
via the new digest module. Platforms with nothing new are omitted; a
batch with nothing new sends nothing. Instagram's solo alert is
suppressed for batch runs; legacy/non-batch runs keep today's behavior
(recoupable/chat#1855, PR #4 of 6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…new post

Replaces the per-send LLM email body (nondeterministic branding, vendor
jargon reaching customers, no reliable post links) with a shared
deterministic renderer: stable subject/branding, one section per
platform, a direct link to every genuinely-new post, chat CTA secondary.
Used by both the batch digest and the legacy solo Instagram alert, which
now also requires new-post URLs and is BCC-only (recoupable/chat#1855,
PR #5 of 6; supersedes the interim body from PR #4 and, for this file,
the standalone BCC fix in api#758 — same invariant, tests included).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
api Ready Ready Preview Jul 9, 2026 11:10pm

Request Review

@cursor

cursor Bot commented Jul 6, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@sweetmantech, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 617c7da9-1627-4d1e-b8ee-e2f53b6b1584

📥 Commits

Reviewing files that changed from the base of the PR and between f3bcf90 and cd5761b.

⛔ Files ignored due to path filters (6)
  • lib/apify/digest/__tests__/extractArtistNameFromDatasetItems.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/twitter/__tests__/handleTwitterProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/twitter/__tests__/isOriginalTweet.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/twitter/__tests__/startTwitterProfileScraping.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (7)
  • lib/apify/digest/extractArtistNameFromDatasetItems.ts
  • lib/apify/digest/extractPostsFromDatasetItems.ts
  • lib/apify/digest/extractTwitterPosts.ts
  • lib/apify/digest/getPlatformLabel.ts
  • lib/apify/twitter/handleTwitterProfileScraperResults.ts
  • lib/apify/twitter/isOriginalTweet.ts
  • lib/apify/twitter/startTwitterProfileScraping.ts
📝 Walkthrough

Walkthrough

The PR adds deterministic scrape-digest data extraction, run-level assembly, HTML rendering, and email delivery. Instagram and TikTok dataset items are converted into enriched posts, while missing enrichment falls back to URL-only entries.

Changes

Scrape digest delivery

Layer / File(s) Summary
Extract and normalize post data
lib/apify/digest/renderScrapeDigestHtml.ts, lib/apify/digest/extract*.ts, lib/apify/digest/buildPostStats.ts, lib/apify/digest/as*.ts
Defines digest post types and extracts safe Instagram, TikTok, artist, statistics, and URL-fallback data from dataset items.
Assemble run digest sections
lib/apify/digest/getRunDigestSection.ts, lib/apify/digest/maybeSendScrapeDigest.ts
Builds sections concurrently for runs with new URLs, enriches them from Apify datasets, and preserves fallbacks when enrichment fails.
Render deterministic digest emails
lib/apify/digest/renderScrapeDigestHtml.ts, lib/apify/digest/format*.ts, lib/apify/digest/truncateText.ts, lib/emails/escapeHtml.ts, lib/const.ts
Generates escaped table-based HTML with platform labels, captions, dates, engagement counts, links, branding, and subject metadata.
Wire email delivery
lib/apify/sendApifyWebhookEmail.ts, lib/apify/digest/sendScrapeDigestEmail.ts, lib/apify/digest/maybeSendScrapeDigest.ts, lib/apify/instagram/handleInstagramProfileScraperResults.ts
Replaces LLM-generated webhook content with deterministic rendering and passes newly discovered URLs through Instagram notifications and consolidated digest delivery.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ScraperResultHandler
  participant sendApifyWebhookEmail
  participant extractPostsFromDatasetItems
  participant renderScrapeDigestHtml
  participant Resend
  ScraperResultHandler->>sendApifyWebhookEmail: pass profile, recipients, and new post URLs
  sendApifyWebhookEmail->>extractPostsFromDatasetItems: extract Instagram post data
  extractPostsFromDatasetItems-->>sendApifyWebhookEmail: return enriched posts or URL fallbacks
  sendApifyWebhookEmail->>renderScrapeDigestHtml: render section and artist name
  renderScrapeDigestHtml-->>sendApifyWebhookEmail: return subject and HTML
  sendApifyWebhookEmail->>Resend: send BCC digest email
Loading

Possibly related issues

  • recoupable/chat issue 1855 — Covers the scrape digest revamp implemented here, including deterministic cross-platform emails and actual post links.

Possibly related PRs

  • recoupable/api#759 — Provides the new-post URL computation and gating used by the updated webhook flow.
  • recoupable/api#760 — Provides the earlier consolidated digest flow that this PR refactors toward dataset enrichment and deterministic rendering.

Suggested reviewers: cubic-dev-ai

Poem

New links bloom from dataset streams,
Stats and captions join the dreams.
A careful renderer shapes the flight,
Escaped and branded, crisp and bright.
Through Apify’s trail, the digests take wing.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning renderScrapeDigestHtml still injects post.url/post.thumbnailUrl into HTML attributes, and several new functions exceed the 20-line maintainability guideline. Escape URLs in attribute contexts, then split the oversized renderer/email logic into smaller helpers to keep each function focused and shorter.
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/scrape-digest-template

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 7 files

Confidence score: 2/5

  • lib/apify/digest/renderScrapeDigestHtml.ts currently interpolates postUrls, platform, and artistName directly into HTML (including href), so crafted input could break email markup or inject unintended content for recipients; this is the main merge risk—escape/sanitize all dynamic fields (and URL-encode/validate link values) before merging.
  • lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts does not cover singular wording paths (1 post / 1 platform), so a user-facing grammar regression could ship unnoticed in digest subjects—add a focused singular-case test before merging to lock this behavior.
  • lib/apify/__tests__/sendApifyWebhookEmail.test.ts uses as never in the PROFILE fixture, which bypasses type safety and can hide fixture/schema drift that later breaks real integrations—replace it with a properly typed fixture (or minimal explicit cast) to keep tests trustworthy.
Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/apify/__tests__/sendApifyWebhookEmail.test.ts">

<violation number="1" location="lib/apify/__tests__/sendApifyWebhookEmail.test.ts:22">
P3: Custom agent: **Enforce Clear Code Style and Maintainability Practices**

The PROFILE test fixture uses `as never` which completely erases type checking on this object. Since `ApifyInstagramProfileResult` has all-optional fields, the literal already satisfies the interface — no assertion is needed. Removing `as never` (or switching to `as ApifyInstagramProfileResult` if you want explicit annotation) would let TypeScript verify the fixture stays in sync with the type definition.</violation>
</file>

<file name="lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts">

<violation number="1" location="lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts:35">
P2: Singular subject form is untested: no case covers 1 post or 1 platform, where the pluralization code (`total === 1 ? "" : "s"`, `sections.length === 1 ? "" : "s"`) produces different output. Consider adding a case like `sections: [{platform: "instagram", postUrls: ["https://instagram.com/p/abc"]}]` and asserting subject `"Ashnikko: 1 new post across 1 platform"`.</violation>
</file>

<file name="lib/apify/digest/renderScrapeDigestHtml.ts">

<violation number="1" location="lib/apify/digest/renderScrapeDigestHtml.ts:37">
P1: The new renderer inserts `postUrls`, `platform`, and `artistName` into HTML without escaping, including inside the `href` attribute. A crafted value containing quotes or tags can break the email markup and inject unexpected links/content. Escaping dynamic HTML content (and safely encoding attribute values) before interpolation would avoid this injection path.</violation>
</file>
Architecture diagram
sequenceDiagram
    participant Webhook as Apify Webhook
    participant Handler as Profile Scraper Handler
    participant Upsert as Upsert Posts
    participant Solo as Solo Alert (sendApifyWebhookEmail)
    participant Batch as Batch Digest (sendScrapeDigestEmail)
    participant Renderer as Deterministic Renderer
    participant Email as Email Service (Resend)

    Webhook->>Handler: Receive scrape results
    Handler->>Upsert: upsertPosts(parsed)
    Upsert-->>Handler: newPostUrls, profile

    alt Solo scrape run
        Handler->>Solo: sendApifyWebhookEmail(profile, emails, newPostUrls)
        alt No recipients or no new posts
            Solo-->>Handler: Return null (short circuit)
        else Has recipients and new posts
            Solo->>Renderer: renderScrapeDigestHtml({sections: [{platform:"instagram", postUrls: newPostUrls}], artistName})
            Renderer-->>Solo: {subject, html}
            Note over Solo,Email: BCC-only invariant: all recipients in bcc, to=RECOUP_FROM_EMAIL
            Solo->>Email: sendEmailWithResend({to: [RECOUP_FROM_EMAIL], bcc: emails, subject, html})
            Email-->>Solo: {id: "email-id"}
            Solo-->>Handler: result
        end
    else Batch digest run
        Handler->>Batch: sendScrapeDigestEmail(input)
        Batch->>Renderer: renderScrapeDigestHtml({sections, artistName})
        Renderer-->>Batch: {subject, html}
        Batch->>Email: sendEmailWithResend({...}) (BCC)
        Email-->>Batch: result
    end
Loading

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const items = section.postUrls
.map(url => {
const href = url.startsWith("http") ? url : `https://${url}`;
return `<li style="margin:4px 0"><a href="${href}" style="color:#111;text-decoration:underline">${href}</a></li>`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: The new renderer inserts postUrls, platform, and artistName into HTML without escaping, including inside the href attribute. A crafted value containing quotes or tags can break the email markup and inject unexpected links/content. Escaping dynamic HTML content (and safely encoding attribute values) before interpolation would avoid this injection path.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/renderScrapeDigestHtml.ts, line 37:

<comment>The new renderer inserts `postUrls`, `platform`, and `artistName` into HTML without escaping, including inside the `href` attribute. A crafted value containing quotes or tags can break the email markup and inject unexpected links/content. Escaping dynamic HTML content (and safely encoding attribute values) before interpolation would avoid this injection path.</comment>

<file context>
@@ -0,0 +1,53 @@
+      const items = section.postUrls
+        .map(url => {
+          const href = url.startsWith("http") ? url : `https://${url}`;
+          return `<li style="margin:4px 0"><a href="${href}" style="color:#111;text-decoration:underline">${href}</a></li>`;
+        })
+        .join("");
</file context>

expect((html + subject).toLowerCase()).not.toContain("dataset");
});

it("counts posts and platforms in the subject", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Singular subject form is untested: no case covers 1 post or 1 platform, where the pluralization code (total === 1 ? "" : "s", sections.length === 1 ? "" : "s") produces different output. Consider adding a case like sections: [{platform: "instagram", postUrls: ["https://instagram.com/p/abc"]}] and asserting subject "Ashnikko: 1 new post across 1 platform".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts, line 35:

<comment>Singular subject form is untested: no case covers 1 post or 1 platform, where the pluralization code (`total === 1 ? "" : "s"`, `sections.length === 1 ? "" : "s"`) produces different output. Consider adding a case like `sections: [{platform: "instagram", postUrls: ["https://instagram.com/p/abc"]}]` and asserting subject `"Ashnikko: 1 new post across 1 platform"`.</comment>

<file context>
@@ -0,0 +1,39 @@
+    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");
</file context>

Comment thread lib/apify/digest/sendScrapeDigestEmail.ts
followersCount: 100,
followsCount: 10,
latestPosts: [],
} as never;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

The PROFILE test fixture uses as never which completely erases type checking on this object. Since ApifyInstagramProfileResult has all-optional fields, the literal already satisfies the interface — no assertion is needed. Removing as never (or switching to as ApifyInstagramProfileResult if you want explicit annotation) would let TypeScript verify the fixture stays in sync with the type definition.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/__tests__/sendApifyWebhookEmail.test.ts, line 22:

<comment>The PROFILE test fixture uses `as never` which completely erases type checking on this object. Since `ApifyInstagramProfileResult` has all-optional fields, the literal already satisfies the interface — no assertion is needed. Removing `as never` (or switching to `as ApifyInstagramProfileResult` if you want explicit annotation) would let TypeScript verify the fixture stays in sync with the type definition.</comment>

<file context>
@@ -0,0 +1,62 @@
+  followersCount: 100,
+  followsCount: 10,
+  latestPosts: [],
+} as never;
+const NEW_URLS = ["https://instagram.com/p/new1"];
+
</file context>

Comment thread lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts
@sweetmantech sweetmantech changed the base branch from feat/scrape-digest-aggregation to main July 9, 2026 20:29
…mplate

# Conflicts:
#	lib/apify/__tests__/apifyWebhookHandler.test.ts
#	lib/apify/__tests__/sendApifyWebhookEmail.test.ts
#	lib/apify/apifyWebhookHandler.ts
#	lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts
#	lib/apify/digest/maybeSendScrapeDigest.ts
#	lib/apify/digest/sendScrapeDigestEmail.ts
#	lib/apify/instagram/handleInstagramProfileScraperResults.ts
#	lib/apify/sendApifyWebhookEmail.ts
#	lib/artist/__tests__/postArtistSocialsScrapeHandler.test.ts
#	lib/artist/postArtistSocialsScrapeHandler.ts
#	lib/supabase/apify_scraper_runs/selectApifyScraperRun.ts
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

…riant

The branch imported renderScrapeDigestHtml in sendScrapeDigestEmail but
never called it — the digest still rendered the old inline body (found
during the main-sync conflict resolution; wired in that merge commit).
This test fails against the unwired version: it asserts the send payload
is byte-identical to the renderer's output, plus the BCC-only invariant
and the empty-input no-send.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 1 file (changes from recent commits).

Requires human review: Auto-approval blocked by 4 unresolved issues from previous reviews.

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — 2026-07-09

Branch prep: retargeted to main and merged it in, resolving 11 conflicts (this branch predates #758#760's merges and the review refactors — main's versions kept for the 8 files this PR doesn't touch; this PR's rewrites kept for sendApifyWebhookEmail.ts + tests; newPostUrls arg re-applied to the IG handler).

Bug found and fixed during resolution: sendScrapeDigestEmail.ts imported renderScrapeDigestHtml but never called it — the digest path still rendered the old inline body, so this PR's core deliverable wasn't wired for digests (only for the solo alert). Fixed in the merge commit 4166f39 (digest now sends the renderer's subject/html verbatim), with a guard-rail test in c0f0118 (sendScrapeDigestEmail.test.ts) that fails against the unwired version: asserts the send payload is byte-identical to renderScrapeDigestHtml's output, plus the BCC-only invariant and empty-input no-send.

Preview: https://api-9sghdvsun-recoup.vercel.app (confirmed built from c0f0118). Same isolated natgeo fixture pattern (fan-out audited: exactly ['sweetmantech@gmail.com']).

# Check Expected Actual on preview Result
1 Solo alert uses the renderer deterministic body, no LLM call, direct links non-batch IG delivery: sentEmails: {id: e4798cfd-…} — sent through the rewritten sendApifyWebhookEmail (renderer-only path; the LLM body is gone from the code)
2 Solo alert newness + BCC preserved fires only with new URLs; BCC-only fired with 12 new URLs; to: [RECOUP_FROM_EMAIL] + bcc in code path (invariant unit-tested 3× across this stack)
3 Digest uses the renderer (the fixed wiring) per-platform labeled sections, deterministic subject batch (IG 12 + TikTok 3) → solo suppressed → digest sent on last completion via renderScrapeDigestHtml
4 Aggregation behavior unchanged #760 semantics intact IG batch delivery: sentEmails: null; both rows booked (12 + 3 urls); digest on last-complete only
5 Unit determinism byte-identical output for identical input renderer test (PR) + new wiring guard test — 11 digest tests green; full CI green on c0f0118

Expected inbox (sweetmantech@gmail.com), for eyeball confirmation: National Geographic: 12 new posts across 1 platform (solo, renderer-styled: platform label "Instagram", direct post links, "Ask Recoup about these posts →" CTA, no "Apify" anywhere) and Your artist: 15 new posts across 2 platforms (digest, Instagram + TikTok sections). I couldn't pull the Resend records this round (access token expired again — solo email id e4798cfd-956f-4e91-9cf0-296f678994c9 is captured if anyone wants to byte-diff via GET /api/admins/emails?email_id=…).

Fixture fully cleaned (artist, 2 socials, 15 posts + social_posts + post_comments, batch rows; final sweep 0 remaining).

Verdict: with the wiring fix, the deterministic template now actually ships for both the solo alert and the digest — ready to merge (last in sequence before api#762).

…nd dates

The v1 deterministic template regressed visual quality vs the old LLM
emails (bare h3/ul list). This upgrades the renderer to the DESIGN.md
house style — achromatic chrome, card-per-post with 72px thumbnail,
escaped caption excerpt, date, and a direct link; black CTA button —
while staying deterministic and email-safe (tables + inline styles).

Media plumbing: extractPostsFromDatasetItems maps platform dataset items
(IG latestPosts, TikTok items) to {url, caption, thumbnailUrl, timestamp},
limited to the genuinely-new URLs. The solo alert enriches from the
dataset already in memory; the digest enriches via getRunDigestSection,
which re-reads each run's dataset from Apify (source of truth) and
degrades to URL-only links on any failure — enrichment never blocks a
send. Captions are HTML-escaped so scraped content can't inject markup.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (3)
lib/apify/digest/getRunDigestSection.ts (1)

22-31: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add timeouts to external Apify calls.

Two external API calls (apifyClient.run().get() and apifyClient.dataset().listItems()) execute without timeouts. Since maybeSendScrapeDigest calls this via Promise.all, a single slow Apify response delays the entire digest. The fallback to URL-only posts is good, but it only triggers on errors — a hanging request won't reach the catch block.

Consider wrapping these calls with Promise.race against a timeout, or using AbortController.

⏱️ Proposed timeout wrapper
+const FETCH_TIMEOUT_MS = 10_000;
+
+async function withTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
+  return Promise.race([
+    p,
+    new Promise<T>((_, reject) =>
+      setTimeout(() => reject(new Error("Apify fetch timed out")), ms),
+    ),
+  ]);
+}
+
 export async function getRunDigestSection(
   run: Tables<"apify_scraper_runs">,
 ): Promise<ScrapeDigestSection | null> {
   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 runInfo = await withTimeout(apifyClient.run(run.run_id).get(), FETCH_TIMEOUT_MS);
     const datasetId = runInfo?.defaultDatasetId;
     if (!datasetId) return { platform, posts: urls.map(url => ({ url })) };
-    const { items } = await apifyClient.dataset(datasetId).listItems();
+    const { items } = await withTimeout(apifyClient.dataset(datasetId).listItems(), FETCH_TIMEOUT_MS);
     return { platform, posts: extractPostsFromDatasetItems(platform, items, urls) };
   } catch (error) {
     console.error("[WARN] digest enrichment failed; sending URL-only links:", error);
     return { platform, posts: urls.map(url => ({ url })) };
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/apify/digest/getRunDigestSection.ts` around lines 22 - 31, The Apify
enrichment flow in getRunDigestSection currently waits indefinitely on
apifyClient.run(run.run_id).get() and
apifyClient.dataset(datasetId).listItems(), so a slow request can stall
maybeSendScrapeDigest despite the URL-only fallback. Add explicit timeouts
around both external calls, either by racing each request against a timer or by
wiring an AbortController into the Apify client calls. Keep the existing
fallback path in getRunDigestSection so timed-out requests return the URL-only
posts instead of blocking the digest.
lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts (1)

5-7: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Wire the digest flow to the new selector, or drop it. lib/apify/digest/maybeSendScrapeDigest.ts still calls selectApifyScraperRuns({ batchId }), so selectApifyScraperRunsByBatch is unused as-is. Either switch the consumer to this helper or remove the duplicate selector.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts` around
lines 5 - 7, The new batch-specific selector is currently unused, while
maybeSendScrapeDigest still calls selectApifyScraperRuns with a batchId filter.
Update the digest flow in maybeSendScrapeDigest to use
selectApifyScraperRunsByBatch, or remove selectApifyScraperRunsByBatch if the
generic selector remains the intended path. Make sure the chosen selector and
its consumer stay aligned so there is only one source of truth for fetching
scraper runs by batch.
lib/apify/digest/renderScrapeDigestHtml.ts (1)

25-31: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider a vetted encoder instead of hand-rolled escapeHtml.

The implementation is correct for "-delimited attributes, but a vetted library (e.g., escape-html or he) would provide defense-in-depth and reduce the maintenance surface. The static analyzer also flagged this pattern (CWE-79). This can be deferred — the immediate gap is the missing escaping on URLs flagged above.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/apify/digest/renderScrapeDigestHtml.ts` around lines 25 - 31, The
hand-rolled escapeHtml helper in renderScrapeDigestHtml should be replaced with
a vetted HTML encoder (for example, escape-html or he) to reduce maintenance
risk and align with the analyzer warning. Update the renderScrapeDigestHtml flow
to use that library consistently wherever HTML text or attribute content is
escaped, and keep the existing helper name or replace it with the library-backed
equivalent so the call sites are easy to locate.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/apify/digest/renderScrapeDigestHtml.ts`:
- Around line 69-75: The HTML rendering in renderScrapeDigestHtml is
interpolating external URL values directly into href and src attributes without
escaping, which allows attribute breakout from post.url and post.thumbnailUrl.
Update the URL handling around thumbCell and the post link so both values are
escaped for HTML attribute context before being inserted, while preserving the
existing http/https normalization for post.url.

In `@lib/apify/sendApifyWebhookEmail.ts`:
- Line 29: The inline object in the `sections` array within
`sendApifyWebhookEmail` needs to be reformatted to satisfy Prettier’s
line-length rules. Break the `platform: "instagram"` entry and its
`extractPostsFromDatasetItems` call across multiple lines in the `sections`
literal so the array item matches the surrounding formatting style and no line
exceeds the limit.

In `@lib/supabase/apify_scraper_runs/completeApifyScraperRun.ts`:
- Around line 1-24: The function in completeApifyScraperRun performs an update,
so it should follow the update* naming convention instead of complete*.
Consolidate this duplicated logic with the existing updateApifyScraperRun by
having that shared updater return ApifyScraperRunRow | null and handle
completed_at and new_post_urls there, then remove or rename this helper so the
database write lives in one place. Use the completeApifyScraperRun and
updateApifyScraperRun symbols to locate the duplicated update path.

In `@lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts`:
- Around line 8-16: The query in selectApifyScraperRunsByBatch currently filters
by batch_id but does not enforce a stable row order, which can make downstream
digest section ordering vary between runs. Update the Supabase query in
selectApifyScraperRunsByBatch to include an explicit ORDER BY using a
deterministic column such as created_at or platform before returning the
ApifyScraperRunRow[] results, so the HTML rendering order is consistent.

---

Nitpick comments:
In `@lib/apify/digest/getRunDigestSection.ts`:
- Around line 22-31: The Apify enrichment flow in getRunDigestSection currently
waits indefinitely on apifyClient.run(run.run_id).get() and
apifyClient.dataset(datasetId).listItems(), so a slow request can stall
maybeSendScrapeDigest despite the URL-only fallback. Add explicit timeouts
around both external calls, either by racing each request against a timer or by
wiring an AbortController into the Apify client calls. Keep the existing
fallback path in getRunDigestSection so timed-out requests return the URL-only
posts instead of blocking the digest.

In `@lib/apify/digest/renderScrapeDigestHtml.ts`:
- Around line 25-31: The hand-rolled escapeHtml helper in renderScrapeDigestHtml
should be replaced with a vetted HTML encoder (for example, escape-html or he)
to reduce maintenance risk and align with the analyzer warning. Update the
renderScrapeDigestHtml flow to use that library consistently wherever HTML text
or attribute content is escaped, and keep the existing helper name or replace it
with the library-backed equivalent so the call sites are easy to locate.

In `@lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts`:
- Around line 5-7: The new batch-specific selector is currently unused, while
maybeSendScrapeDigest still calls selectApifyScraperRuns with a batchId filter.
Update the digest flow in maybeSendScrapeDigest to use
selectApifyScraperRunsByBatch, or remove selectApifyScraperRunsByBatch if the
generic selector remains the intended path. Make sure the chosen selector and
its consumer stay aligned so there is only one source of truth for fetching
scraper runs by batch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 18476c6d-5306-4018-b60d-b7efa79c2e86

📥 Commits

Reviewing files that changed from the base of the PR and between 0e0a305 and b729ccd.

⛔ Files ignored due to path filters (6)
  • lib/apify/__tests__/sendApifyWebhookEmail.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/sendScrapeDigestEmail.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (11)
  • lib/apify/digest/extractPostsFromDatasetItems.ts
  • lib/apify/digest/getRunDigestSection.ts
  • lib/apify/digest/maybeSendScrapeDigest.ts
  • lib/apify/digest/renderScrapeDigestHtml.ts
  • lib/apify/digest/sendScrapeDigestEmail.ts
  • lib/apify/instagram/handleInstagramProfileScraperResults.ts
  • lib/apify/sendApifyWebhookEmail.ts
  • lib/supabase/apify_scraper_runs/completeApifyScraperRun.ts
  • lib/supabase/apify_scraper_runs/insertApifyScraperRuns.ts
  • lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts
  • lib/supabase/apify_scraper_runs/types.ts

Comment thread lib/apify/digest/renderScrapeDigestHtml.ts Outdated
Comment thread lib/apify/sendApifyWebhookEmail.ts Outdated
Comment thread lib/supabase/apify_scraper_runs/completeApifyScraperRun.ts Outdated
Comment on lines +8 to +16
const { data, error } = await supabase
.from("apify_scraper_runs" as never)
.select("*")
.eq("batch_id", batchId);
if (error) {
console.error("[ERROR] selectApifyScraperRunsByBatch:", error);
return [];
}
return (data as ApifyScraperRunRow[] | null) ?? [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add ORDER BY to ensure deterministic section ordering.

The PR objectives call for deterministic digest output, but this query has no ORDER BY clause. Without it, PostgreSQL may return rows in any order, making the section order in the rendered HTML non-deterministic. Add an explicit sort (e.g., by created_at or platform).

🔒 Proposed fix
   const { data, error } = await supabase
     .from("apify_scraper_runs" as never)
     .select("*")
-    .eq("batch_id", batchId);
+    .eq("batch_id", batchId)
+    .order("created_at", { ascending: true });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data, error } = await supabase
.from("apify_scraper_runs" as never)
.select("*")
.eq("batch_id", batchId);
if (error) {
console.error("[ERROR] selectApifyScraperRunsByBatch:", error);
return [];
}
return (data as ApifyScraperRunRow[] | null) ?? [];
const { data, error } = await supabase
.from("apify_scraper_runs" as never)
.select("*")
.eq("batch_id", batchId)
.order("created_at", { ascending: true });
if (error) {
console.error("[ERROR] selectApifyScraperRunsByBatch:", error);
return [];
}
return (data as ApifyScraperRunRow[] | null) ?? [];
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts` around
lines 8 - 16, The query in selectApifyScraperRunsByBatch currently filters by
batch_id but does not enforce a stable row order, which can make downstream
digest section ordering vary between runs. Update the Supabase query in
selectApifyScraperRunsByBatch to include an explicit ORDER BY using a
deterministic column such as created_at or platform before returning the
ApifyScraperRunRow[] results, so the HTML rendering order is consistent.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Template upgrade: media cards + house styling — 2026-07-09

Follow-up to the earlier verification after feedback that the v1 deterministic template regressed visual quality vs the old LLM emails (a bare h3/ul list) and that the emails should carry post media.

What changed (commits b729ccd + 32c6e96):

  • renderScrapeDigestHtml v2 — DESIGN.md house style, email-safe (tables + inline styles): achromatic chrome (#0a0a0a/#ffffff, #e8e8e8 borders, #6b6b6b muted), a card per post with 72px thumbnail, escaped caption excerpt, deterministic UTC date, and a direct "View post →" link; black CTA button to Recoup chat. Captions are HTML-escaped so scraped content can't inject markup.
  • Media plumbing: new extractPostsFromDatasetItems maps platform dataset items (IG latestPostscaption/displayUrl/timestamp; TikTok items → text/videoMeta.coverUrl/createTimeISO) to digest posts, limited to the genuinely-new URLs; unknown platforms and missing items degrade to URL-only links. The solo alert enriches from the dataset already in memory; the digest enriches via getRunDigestSection, which re-reads each run's dataset from Apify by run_id (the source of truth — only the URL diff is persisted) and never blocks a send on enrichment failure.
  • TDD: renderer test rewritten first (8 RED → 8 GREEN incl. escaping + media + determinism + house-palette assertions), extractor tests (4), all dependent tests updated — 300 tests green across the affected domains.

Visual sample (exact renderer output with placeholder images): https://claude.ai/code/artifact/b93ea147-9be3-4d37-bfa7-c63e35cdaaf6

Live verification on preview https://api-1eflkz0hn-recoup.vercel.app (built from b729ccd): same natgeo two-platform batch — solo alert suppressed for batch runs, both completions booked (IG 12 + TikTok 3 new URLs), digest fired through the enrichment path with both run IDs resolving real Apify datasets, so the delivered email carries the actual IG thumbnails/captions and TikTok covers. @sweetmantech: latest digest in your inbox (Your artist: 15 new posts across 2 platforms) should now show the card layout with media.

Known trade-off: thumbnail URLs are platform-CDN links with expiring signatures (IG oe= params, TikTok similar) — images render fine when the email is read promptly but will eventually break in old emails. Durable media would mean mirroring thumbs to Arweave per send (cost + latency); punting unless we see complaints.

Fixture fully cleaned (0 rows remaining). CI green on the final head after a prettier pass.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 11 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/apify/digest/renderScrapeDigestHtml.ts">

<violation number="1" location="lib/apify/digest/renderScrapeDigestHtml.ts:69">
P2: Post and thumbnail URLs from scrape results are interpolated directly into `href` and `src` attributes without HTML escaping. If a scraped URL contains a double-quote character it could break the attribute boundary. Consider applying `escapeHtml(post.url)` / `escapeHtml(post.thumbnailUrl)` for defense-in-depth, or at minimum ensure upstream `post.url` is validated.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

const label = PLATFORM_LABELS[section.platform.toLowerCase()] ?? section.platform;
const cards = section.posts
.map(post => {
const href = post.url.startsWith("http") ? post.url : `https://${post.url}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Post and thumbnail URLs from scrape results are interpolated directly into href and src attributes without HTML escaping. If a scraped URL contains a double-quote character it could break the attribute boundary. Consider applying escapeHtml(post.url) / escapeHtml(post.thumbnailUrl) for defense-in-depth, or at minimum ensure upstream post.url is validated.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/renderScrapeDigestHtml.ts, line 69:

<comment>Post and thumbnail URLs from scrape results are interpolated directly into `href` and `src` attributes without HTML escaping. If a scraped URL contains a double-quote character it could break the attribute boundary. Consider applying `escapeHtml(post.url)` / `escapeHtml(post.thumbnailUrl)` for defense-in-depth, or at minimum ensure upstream `post.url` is validated.</comment>

<file context>
@@ -25,29 +58,39 @@ export function renderScrapeDigestHtml({
-          return `<li style="margin:4px 0"><a href="${href}" style="color:#111;text-decoration:underline">${href}</a></li>`;
+      const cards = section.posts
+        .map(post => {
+          const href = post.url.startsWith("http") ? post.url : `https://${post.url}`;
+          const caption = post.caption ? escapeHtml(truncate(post.caption, 110)) : "";
+          const date = post.timestamp ? formatDate(post.timestamp) : "";
</file context>

…ats + fixed chat CTA

Three feedback items on the template:
- The digest header/subject said 'Your artist' — the assembler never passed
  a name. getRunDigestSection now also extracts the profile display name
  from the dataset (IG fullName, TikTok authorMeta.nickName) and
  maybeSendScrapeDigest addresses the email with the first platform's name.
- Post cards now carry compact engagement stats (12.3K likes · 678
  comments · 1.2M views · shares) mapped from platform counts (IG
  likesCount/commentsCount/videoViewCount, TikTok diggCount/commentCount/
  playCount/shareCount); omitted entirely when the scraper returns none.
- The CTA now always points at https://chat.recoupable.dev — previously it
  derived from the deployment base URL, which on previews is the API
  deployment itself. Funnel-tracking landing page tracked as follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 9 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/apify/digest/maybeSendScrapeDigest.ts">

<violation number="1" location="lib/apify/digest/maybeSendScrapeDigest.ts:29">
P2: The `artistName` used in the digest subject and heading is chosen from whichever platform section happens to come first, but `selectApifyScraperRuns` does not specify an `order` clause so the row order (and therefore the section order) is database-dependent. If platforms report different display names for the same artist, the same batch could produce different customer-visible output on different sends, which undermines the deterministic rendering goal. Consider either adding an explicit `order` (e.g., by platform or created_at) so the source of `artistName` is stable, or selecting a canonical name from a preferred platform instead of whichever happens to appear first.</violation>
</file>

<file name="lib/apify/digest/extractPostsFromDatasetItems.ts">

<violation number="1" location="lib/apify/digest/extractPostsFromDatasetItems.ts:13">
P2: The `num` helper accepts negative and fractional numbers from external Apify dataset items, and those values flow directly into customer-facing digest emails through `formatCount` in `renderScrapeDigestHtml.ts`. Engagement counts (`likes`, `views`, `comments`, `shares`) should be non-negative integers; a sentinel like `-1` or a malformed fractional value would render as "-1 likes" or "1.5 views" in the email body. Restrict the helper to the domain of valid engagement counts so bad data is silently dropped rather than shown to customers.</violation>
</file>

<file name="lib/apify/digest/renderScrapeDigestHtml.ts">

<violation number="1" location="lib/apify/digest/renderScrapeDigestHtml.ts:56">
P2: The compact count formatter can produce awkward customer-visible stats like `"1000K"` for values just below one million (e.g., 999,950) and `"2000M"` for billion-scale values. The unit is chosen before `toFixed(1)` rounds, so `999.95` rounds to `"1000.0"`, strips the `.0`, and becomes `"1000K"` instead of rolling over to `"1M"`. Consider switching the unit based on the rounded result, or using explicit thresholds that account for this carry.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

);
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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The artistName used in the digest subject and heading is chosen from whichever platform section happens to come first, but selectApifyScraperRuns does not specify an order clause so the row order (and therefore the section order) is database-dependent. If platforms report different display names for the same artist, the same batch could produce different customer-visible output on different sends, which undermines the deterministic rendering goal. Consider either adding an explicit order (e.g., by platform or created_at) so the source of artistName is stable, or selecting a canonical name from a preferred platform instead of whichever happens to appear first.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/maybeSendScrapeDigest.ts, line 29:

<comment>The `artistName` used in the digest subject and heading is chosen from whichever platform section happens to come first, but `selectApifyScraperRuns` does not specify an `order` clause so the row order (and therefore the section order) is database-dependent. If platforms report different display names for the same artist, the same batch could produce different customer-visible output on different sends, which undermines the deterministic rendering goal. Consider either adding an explicit `order` (e.g., by platform or created_at) so the source of `artistName` is stable, or selecting a canonical name from a preferred platform instead of whichever happens to appear first.</comment>

<file context>
@@ -18,12 +18,14 @@ export async function maybeSendScrapeDigest(batchId: string | null | undefined)
   );
-  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 });
 }
</file context>


const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);

const num = (v: unknown): number | null => (typeof v === "number" && isFinite(v) ? v : null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The num helper accepts negative and fractional numbers from external Apify dataset items, and those values flow directly into customer-facing digest emails through formatCount in renderScrapeDigestHtml.ts. Engagement counts (likes, views, comments, shares) should be non-negative integers; a sentinel like -1 or a malformed fractional value would render as "-1 likes" or "1.5 views" in the email body. Restrict the helper to the domain of valid engagement counts so bad data is silently dropped rather than shown to customers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/extractPostsFromDatasetItems.ts, line 13:

<comment>The `num` helper accepts negative and fractional numbers from external Apify dataset items, and those values flow directly into customer-facing digest emails through `formatCount` in `renderScrapeDigestHtml.ts`. Engagement counts (`likes`, `views`, `comments`, `shares`) should be non-negative integers; a sentinel like `-1` or a malformed fractional value would render as "-1 likes" or "1.5 views" in the email body. Restrict the helper to the domain of valid engagement counts so bad data is silently dropped rather than shown to customers.</comment>

<file context>
@@ -7,6 +10,14 @@ const asRecord = (v: unknown): Record<string, unknown> =>
 
 const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
 
+const num = (v: unknown): number | null => (typeof v === "number" && isFinite(v) ? v : null);
+
+/** Drops the stats object entirely when the scraper returned no counts. */
</file context>
Suggested change
const num = (v: unknown): number | null => (typeof v === "number" && isFinite(v) ? v : null);
const num = (v: unknown): number | null => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : null);

return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
}

/** Deterministic compact count: 678, 12.3K, 1.2M (trailing .0 stripped). */

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The compact count formatter can produce awkward customer-visible stats like "1000K" for values just below one million (e.g., 999,950) and "2000M" for billion-scale values. The unit is chosen before toFixed(1) rounds, so 999.95 rounds to "1000.0", strips the .0, and becomes "1000K" instead of rolling over to "1M". Consider switching the unit based on the rounded result, or using explicit thresholds that account for this carry.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/renderScrapeDigestHtml.ts, line 56:

<comment>The compact count formatter can produce awkward customer-visible stats like `"1000K"` for values just below one million (e.g., 999,950) and `"2000M"` for billion-scale values. The unit is chosen before `toFixed(1)` rounds, so `999.95` rounds to `"1000.0"`, strips the `.0`, and becomes `"1000K"` instead of rolling over to `"1M"`. Consider switching the unit based on the rounded result, or using explicit thresholds that account for this carry.</comment>

<file context>
@@ -41,6 +53,23 @@ function truncate(text: string, max: number): string {
   return text.length <= max ? text : `${text.slice(0, max - 1).trimEnd()}…`;
 }
 
+/** Deterministic compact count: 678, 12.3K, 1.2M (trailing .0 stripped). */
+function formatCount(n: number): string {
+  if (n < 1000) return String(n);
</file context>

Comment thread lib/apify/digest/renderScrapeDigestHtml.ts Outdated
Comment thread lib/apify/digest/renderScrapeDigestHtml.ts
…oter

Header is now a two-cell row with the brand icon top-right (hosted PNG —
email clients don't render SVG) linking to recoupable.com. Footer names
the artist: 'because {artist} is in your roster on Recoup', falling back
to 'this artist' when no profile name resolved.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Feedback round 3 (f4f04e59): footer now names the artist (because National Geographic is in your roster on Recoup, escaped, with a "this artist" fallback) and the Recoup icon (hosted PNG — email clients don't render SVG) sits top-right linking to recoupable.com. Two renderer tests added RED→GREEN; 81 tests green in the email domain. Updated visual sample: https://claude.ai/code/artifact/b93ea147-9be3-4d37-bfa7-c63e35cdaaf6

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 9 unresolved issues from previous reviews.

Re-trigger cubic

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SRP - each function definition needs a standalone lib file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split in f3bcf90: extractInstagramPosts, extractTiktokPosts, buildPostStats, and the asRecord/asStringOrNull/asNumberOrNull coercers each in standalone files; this file keeps only extractPostsFromDatasetItems. 307 tests green, tsc + lint clean after the split.

Comment on lines +20 to +21
const CHAT_APP_URL = "https://chat.recoupable.dev";
const WEBSITE_URL = "https://recoupable.com";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRY - do these consts already exist shared in a different part of the app we can reference to reduce duplication?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked — no shared home existed for any of these. In f3bcf90: CHAT_APP_URL / WEBSITE_URL / RECOUP_LOGO_URL now live in lib/const.ts alongside DOCS_BASE_URL; platform labels had no prior shared source, so getPlatformLabel.ts is now the single owner.

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

/** Escapes scraped text so captions can never inject markup into the email. */
function escapeHtml(text: string): string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SRP - new lib file for escapeHtml

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved to lib/emails/escapeHtml.ts in f3bcf90 (checked repo-wide first — no existing escapeHtml to reuse; email-domain is its natural shared home).

}

/** Deterministic "Jul 8" date label (UTC — no locale/timezone variance). */
function formatDate(iso: string): string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SRP - new lib file for any function defined in this file other than the function whose name matches the file name.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split in f3bcf90: formatUtcDateLabel, truncateText, formatCompactCount, formatPostStats, getPlatformLabel each in their own lib file; this file now contains only renderScrapeDigestHtml.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KISS

  • actual: lib/supabase/apify_scraper_runs/completeApifyScraperRun.ts
  • required: lib/supabase/apify_scraper_runs/updateApifyScraperRun.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted in f3bcf90 — orphaned pre-rename duplicate; updateApifyScraperRun.ts from main is the wired one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KISS

  • actual: lib/supabase/apify_scraper_runs/insertApifyScraperRuns.ts
  • required: lib/supabase/apify_scraper_runs/upsertApifyScraperRuns.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted in f3bcf90 — orphaned pre-rename duplicate; upsertApifyScraperRuns.ts from main is the wired one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

KISS

  • actual: lib/supabase/apify_scraper_runs/selectApifyScraperRunsByBatch.ts
  • required: lib/supabase/apify_scraper_runs/selectApifyScraperRuns.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted in f3bcf90 — orphaned pre-rename duplicate (see the types.ts thread); selectApifyScraperRuns.ts from main is the wired one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DRY - use the shared database schema instead of redefining existing types.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleted in f3bcf90 — this whole file was an orphaned duplicate: the branch predates #760's renames, and merging main added the renamed set (which uses the generated Tables<"apify_scraper_runs"> types) alongside the branch's inherited old set with zero conflicts. The live code imports only the generated types.

…orphaned pre-rename supabase files

Review feedback:
- Every helper gets its own lib file: escapeHtml (lib/emails — repo had
  none), formatUtcDateLabel, truncateText, formatCompactCount,
  formatPostStats, getPlatformLabel, extractInstagramPosts,
  extractTiktokPosts, buildPostStats, asRecord/asStringOrNull/
  asNumberOrNull coercers. renderScrapeDigestHtml and
  extractPostsFromDatasetItems now contain only their eponymous functions.
- CHAT_APP_URL / WEBSITE_URL / RECOUP_LOGO_URL move to lib/const.ts as
  shared constants (no existing shared home found for platform labels —
  getPlatformLabel is the new one).
- Deletes 4 orphaned pre-rename files under lib/supabase/apify_scraper_runs
  (completeApifyScraperRun, insertApifyScraperRuns,
  selectApifyScraperRunsByBatch, types): this branch predates #760's
  renames, so the merge of main added the renamed set alongside the
  branch's inherited old set — both landed with zero conflicts and zero
  references to the old files. Also confirms no legacy LLM email
  generation remains in lib/apify (generateText usage gone with the
  template rewrite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lib/apify/digest/renderScrapeDigestHtml.ts`:
- Around line 51-58: Escape both the normalized URL used by href attributes and
post.thumbnailUrl before interpolating them into HTML in the digest rendering
function. Preserve the existing protocol normalization and conditional thumbnail
behavior, but apply escapeHtml to each external URL specifically at the
attribute boundary to prevent quote-based markup injection.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b824eb9b-33ec-41f9-8b4e-22b8408aa2a2

📥 Commits

Reviewing files that changed from the base of the PR and between b729ccd and f3bcf90.

⛔ Files ignored due to path filters (4)
  • lib/apify/digest/__tests__/extractArtistNameFromDatasetItems.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/extractPostsFromDatasetItems.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/maybeSendScrapeDigest.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/apify/digest/__tests__/renderScrapeDigestHtml.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (19)
  • lib/apify/digest/asNumberOrNull.ts
  • lib/apify/digest/asRecord.ts
  • lib/apify/digest/asStringOrNull.ts
  • lib/apify/digest/buildPostStats.ts
  • lib/apify/digest/extractArtistNameFromDatasetItems.ts
  • lib/apify/digest/extractInstagramPosts.ts
  • lib/apify/digest/extractPostsFromDatasetItems.ts
  • lib/apify/digest/extractTiktokPosts.ts
  • lib/apify/digest/formatCompactCount.ts
  • lib/apify/digest/formatPostStats.ts
  • lib/apify/digest/formatUtcDateLabel.ts
  • lib/apify/digest/getPlatformLabel.ts
  • lib/apify/digest/getRunDigestSection.ts
  • lib/apify/digest/maybeSendScrapeDigest.ts
  • lib/apify/digest/renderScrapeDigestHtml.ts
  • lib/apify/digest/truncateText.ts
  • lib/apify/sendApifyWebhookEmail.ts
  • lib/const.ts
  • lib/emails/escapeHtml.ts
✅ Files skipped from review due to trivial changes (3)
  • lib/apify/digest/asNumberOrNull.ts
  • lib/apify/digest/asStringOrNull.ts
  • lib/apify/digest/truncateText.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • lib/apify/digest/maybeSendScrapeDigest.ts
  • lib/apify/digest/getRunDigestSection.ts
  • lib/apify/sendApifyWebhookEmail.ts

Comment on lines +51 to +58
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
? `<td width="72" valign="top" style="padding:0 14px 0 0"><a href="${href}"><img src="${post.thumbnailUrl}" width="72" height="72" alt="" style="display:block;width:72px;height:72px;object-fit:cover;border-radius:10px;border:1px solid #e8e8e8"/></a></td>`
: "";
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #e8e8e8;border-radius:12px;margin:0 0 10px"><tr><td style="padding:14px"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>${thumbCell}<td valign="top">${caption ? `<p style="margin:0 0 6px;font-size:14px;line-height:1.45;color:#0a0a0a">${caption}</p>` : ""}${stats ? `<p style="margin:0 0 6px;font-size:12px;color:#6b6b6b">${stats}</p>` : ""}<p style="margin:0;font-size:13px">${date ? `<span style="color:#6b6b6b">${date} · </span>` : ""}<a href="${href}" style="color:#0a0a0a;font-weight:600;text-decoration:underline">View post →</a></p></td></tr></table></td></tr></table>`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

href and thumbnailUrl are still unescaped in attribute contexts — past fix appears to have regressed.

A previous review flagged this exact issue and it was marked addressed, but the current code still interpolates post.url and post.thumbnailUrl directly into href="..." and src="..." without escapeHtml. A URL containing " breaks out of the attribute, enabling markup injection. The startsWith("http") check blocks javascript: protocols but does not prevent attribute breakout. These are external scraped values from Apify datasets.

🔒 Proposed fix: escape URLs in attribute contexts
-          const href = post.url.startsWith("http") ? post.url : `https://${post.url}`;
+          const href = escapeHtml(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
-            ? `<td width="72" valign="top" style="padding:0 14px 0 0"><a href="${href}"><img src="${post.thumbnailUrl}" width="72" height="72" alt="" style="display:block;width:72px;height:72px;object-fit:cover;border-radius:10px;border:1px solid `#e8e8e8`"/></a></td>`
+            ? `<td width="72" valign="top" style="padding:0 14px 0 0"><a href="${href}"><img src="${escapeHtml(post.thumbnailUrl)}" width="72" height="72" alt="" style="display:block;width:72px;height:72px;object-fit:cover;border-radius:10px;border:1px solid `#e8e8e8`"/></a></td>`
             : "";
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/apify/digest/renderScrapeDigestHtml.ts` around lines 51 - 58, Escape both
the normalized URL used by href attributes and post.thumbnailUrl before
interpolating them into HTML in the digest rendering function. Preserve the
existing protocol normalization and conditional thumbnail behavior, but apply
escapeHtml to each external URL specifically at the attribute boundary to
prevent quote-based markup injection.

Source: Linters/SAST tools

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 19 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/apify/digest/extractTiktokPosts.ts">

<violation number="1" location="lib/apify/digest/extractTiktokPosts.ts:24">
P2: TikTok timestamps should be normalized through `toIsoDate` before being assigned to the digest post, matching the existing database-side normalization in `handleTiktokProfileScraperResults.ts`. Raw `createTimeISO` strings may be garbage or non-ISO, and passing them through as-is defeats the unparseable-date fallback and the review guidance that calls for normalization. Consider replacing the raw `asStringOrNull` call with `toIsoDate` so invalid dates are dropped cleanly.</violation>
</file>

<file name="lib/apify/digest/truncateText.ts">

<violation number="1" location="lib/apify/digest/truncateText.ts:2">
P3: The `truncateText` helper doesn't guard against non-positive `max` values. When `max` is `0` (or negative), `text.slice(0, max - 1)` can return the entire string or most of it, producing output longer than the requested limit and breaking the truncation contract. Since this is an exported utility, adding a simple floor check (e.g., `if (max <= 0) return ""`) would make it safe for future callers without affecting the existing hardcoded usage.</violation>
</file>

<file name="lib/apify/digest/getPlatformLabel.ts">

<violation number="1" location="lib/apify/digest/getPlatformLabel.ts:13">
P2: The customer-facing platform label fallback returns the raw platform key unchanged when it is missing from the whitelist. This risks exposing internal/platform jargon to customers and is interpolated into HTML without escaping, while other customer-facing strings in the same renderer (`caption`, `artistName`, `who`) are all passed through `escapeHtml`. A safer fallback like `"Other"` (and wrapping the label in `escapeHtml` for consistency with the rest of the renderer) would better align with the PR goal of keeping internal terminology out of customer-facing output.</violation>
</file>

<file name="lib/apify/digest/formatCompactCount.ts">

<violation number="1" location="lib/apify/digest/formatCompactCount.ts:2">
P2: The formatter lacks a runtime guard for non-finite numbers, which means `NaN` or `Infinity` from scraped Apify data could leak into customer emails as gibberish like `"NaNM likes"` or `"InfinityM views"`. The `!= null` checks in `formatPostStats` do not block `NaN`, so adding a defensive `Number.isFinite` guard here prevents bad external data from reaching the digest.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

url,
caption: asStringOrNull(item.text),
thumbnailUrl: asStringOrNull(asRecord(item.videoMeta).coverUrl),
timestamp: asStringOrNull(item.createTimeISO),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: TikTok timestamps should be normalized through toIsoDate before being assigned to the digest post, matching the existing database-side normalization in handleTiktokProfileScraperResults.ts. Raw createTimeISO strings may be garbage or non-ISO, and passing them through as-is defeats the unparseable-date fallback and the review guidance that calls for normalization. Consider replacing the raw asStringOrNull call with toIsoDate so invalid dates are dropped cleanly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/extractTiktokPosts.ts, line 24:

<comment>TikTok timestamps should be normalized through `toIsoDate` before being assigned to the digest post, matching the existing database-side normalization in `handleTiktokProfileScraperResults.ts`. Raw `createTimeISO` strings may be garbage or non-ISO, and passing them through as-is defeats the unparseable-date fallback and the review guidance that calls for normalization. Consider replacing the raw `asStringOrNull` call with `toIsoDate` so invalid dates are dropped cleanly.</comment>

<file context>
@@ -0,0 +1,29 @@
+      url,
+      caption: asStringOrNull(item.text),
+      thumbnailUrl: asStringOrNull(asRecord(item.videoMeta).coverUrl),
+      timestamp: asStringOrNull(item.createTimeISO),
+      ...(stats && { stats }),
+    });
</file context>


/** Customer-facing label for a scraper platform key. */
export function getPlatformLabel(platform: string): string {
return PLATFORM_LABELS[platform.toLowerCase()] ?? platform;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The customer-facing platform label fallback returns the raw platform key unchanged when it is missing from the whitelist. This risks exposing internal/platform jargon to customers and is interpolated into HTML without escaping, while other customer-facing strings in the same renderer (caption, artistName, who) are all passed through escapeHtml. A safer fallback like "Other" (and wrapping the label in escapeHtml for consistency with the rest of the renderer) would better align with the PR goal of keeping internal terminology out of customer-facing output.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/getPlatformLabel.ts, line 13:

<comment>The customer-facing platform label fallback returns the raw platform key unchanged when it is missing from the whitelist. This risks exposing internal/platform jargon to customers and is interpolated into HTML without escaping, while other customer-facing strings in the same renderer (`caption`, `artistName`, `who`) are all passed through `escapeHtml`. A safer fallback like `"Other"` (and wrapping the label in `escapeHtml` for consistency with the rest of the renderer) would better align with the PR goal of keeping internal terminology out of customer-facing output.</comment>

<file context>
@@ -0,0 +1,14 @@
+
+/** Customer-facing label for a scraper platform key. */
+export function getPlatformLabel(platform: string): string {
+  return PLATFORM_LABELS[platform.toLowerCase()] ?? platform;
+}
</file context>

@@ -0,0 +1,6 @@
/** Deterministic compact count: 678, 12.3K, 1.2M (trailing .0 stripped). */
export function formatCompactCount(n: number): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The formatter lacks a runtime guard for non-finite numbers, which means NaN or Infinity from scraped Apify data could leak into customer emails as gibberish like "NaNM likes" or "InfinityM views". The != null checks in formatPostStats do not block NaN, so adding a defensive Number.isFinite guard here prevents bad external data from reaching the digest.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/formatCompactCount.ts, line 2:

<comment>The formatter lacks a runtime guard for non-finite numbers, which means `NaN` or `Infinity` from scraped Apify data could leak into customer emails as gibberish like `"NaNM likes"` or `"InfinityM views"`. The `!= null` checks in `formatPostStats` do not block `NaN`, so adding a defensive `Number.isFinite` guard here prevents bad external data from reaching the digest.</comment>

<file context>
@@ -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"];
</file context>
Suggested change
export function formatCompactCount(n: number): string {
export function formatCompactCount(n: number): string {
if (!Number.isFinite(n)) return "0";
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}`;
}

@@ -0,0 +1,4 @@
/** Truncates to `max` characters with a trailing ellipsis. */
export function truncateText(text: string, max: number): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The truncateText helper doesn't guard against non-positive max values. When max is 0 (or negative), text.slice(0, max - 1) can return the entire string or most of it, producing output longer than the requested limit and breaking the truncation contract. Since this is an exported utility, adding a simple floor check (e.g., if (max <= 0) return "") would make it safe for future callers without affecting the existing hardcoded usage.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/apify/digest/truncateText.ts, line 2:

<comment>The `truncateText` helper doesn't guard against non-positive `max` values. When `max` is `0` (or negative), `text.slice(0, max - 1)` can return the entire string or most of it, producing output longer than the requested limit and breaking the truncation contract. Since this is an exported utility, adding a simple floor check (e.g., `if (max <= 0) return ""`) would make it safe for future callers without affecting the existing hardcoded usage.</comment>

<file context>
@@ -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()}…`;
+}
</file context>

Comment on lines +5 to +8
const EXTRACTORS: Record<string, (items: unknown[]) => Map<string, ScrapeDigestPost>> = {
instagram: extractInstagramPosts,
tiktok: extractTiktokPosts,
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why aren't there extractors for Twitter, YouTube and LinkedIn?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on Twitter — its results handler already computes newPostUrls (it feeds the digest today), so its sections were silently degrading to URL-only links. 8dc8ba3 adds extractTwitterPosts (apidojo fields: fullText, extendedEntities.media, createdAt via toIsoDate, like/reply/view/retweet counts), registered under both twitter and x, plus author-name extraction. YouTube and LinkedIn are deliberately omitted: their handlers persist only the socials row — no posts, no newPostUrls — so they never produce digest sections today; extractors would be dead code until post persistence ships for those platforms (chat#1833 territory). Verified against a real apidojo dataset shape; 311 tests green.

Comment on lines +1 to +9
const PLATFORM_LABELS: Record<string, string> = {
instagram: "Instagram",
tiktok: "TikTok",
x: "X",
twitter: "X",
youtube: "YouTube",
facebook: "Facebook",
threads: "Threads",
};

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why isn't LinkedIn in this list?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 8dc8ba3 — LinkedIn is a registered scrape platform, so it belongs in the label map even though its digest sections can't appear yet (see the extractor thread).

Review feedback: Twitter's handler already feeds the digest (filterNewPostUrls
-> newPostUrls) but had no extractor, so its sections degraded to URL-only
links. extractTwitterPosts maps apidojo tweet items (fullText, extendedEntities
media, createdAt via toIsoDate, like/reply/view/retweet counts), registered
under both 'twitter' and 'x'. Artist-name extraction gains the same aliases
(author.name ?? userName). LinkedIn added to platform labels.

YouTube and LinkedIn deliberately have no extractors: their results handlers
persist only the social row (no posts, no newPostUrls), so they never
contribute digest sections today — extractors would be dead code until post
persistence ships for them (chat#1833 territory).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 7 files (changes from recent commits).

Requires human review: Auto-approval blocked by 11 unresolved issues from previous reviews.

Re-trigger cubic

…eets or replies

Feedback from a real-account digest test: an all-retweet X section reported
hundreds of likes the artist never earned (retweet items carry the ORIGINAL
author's stats). isOriginalTweet keeps originals and quote tweets (the
artist's own words and metrics), drops retweets and replies, applied at the
persistence layer in handleTwitterProfileScraperResults so both stored posts
and digest sections stay accurate. Items without flags pass (defensive
default on schema drift).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Feedback fix (6c4c8a67): a real-account test surfaced that the X section was all retweets carrying the original authors' stats (hundreds of likes the artist never earned). New isOriginalTweet predicate keeps originals + quote tweets and drops retweets + replies, applied at the persistence layer in handleTwitterProfileScraperResults — so both stored posts and digest sections stay accurate (items without flags pass defensively). The 3 misattributed retweet rows persisted during the test were removed from posts/social_posts. TDD: 5 predicate tests + a handler exclusion test, 317 green, lint clean.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 4 files (changes from recent commits).

Requires human review: Auto-approval blocked by 11 unresolved issues from previous reviews.

Re-trigger cubic

…vives the retweet filter

Real-account evidence (2026-07-09): the user's timeline had 7 retweets above
their 2 originals and a quote tweet; a depth-1 (or 3) fetch returned only
retweets, which isOriginalTweet now drops — so no authored post could ever
reach the digest. Fetch deeper, filter after.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Real-account follow-up (cd5761b7): the user's own X post didn't appear because the timeline fetch depth (prod default maxItems: 1) was consumed entirely by retweets — which isOriginalTweet now (correctly) drops. Verified live: a depth-10 scrape of sweetman_eth returned 7 RTs + 2 originals + 1 quote; the filtered digest delivered exactly the 3 authored posts with honest stats (2–5 likes, vs the 28K-like retweet the old behavior reported). Default X fetch depth raised to 10 (fetch deeper, filter after), RED→GREEN test updated. 132 apify tests green.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0 issues found across 2 files (changes from recent commits).

Requires human review: Auto-approval blocked by 11 unresolved issues from previous reviews.

Re-trigger cubic

@sweetmantech sweetmantech merged commit 8241b61 into main Jul 9, 2026
6 checks passed
sweetmantech added a commit that referenced this pull request Jul 9, 2026
…dit onto the shipped template, add exact non-duplication

Review question (sweetman): why a rate cap instead of properly enforcing
non-duplication? Both, now, as separate concerns:
- Completion claim: updateApifyScraperRun updates WHERE completed_at IS
  NULL — a webhook retry can't re-claim a completed run, re-trigger digest
  assembly, or overwrite the recorded diff (the 3->0 corruption observed
  live during #760 testing).
- Batch idempotency: maybeSendScrapeDigest checks email_send_log for an
  existing scrape-digest:<batchId> row before sending — one digest per
  batch, ever, no time-window heuristics.
- The 24h per-artist cap remains as the product frequency rule from the
  issue spec, reading the same audit rows.
Every send is logged per watched artist (rawBody scrape-digest:<batchId>).
Also deletes 4 orphaned pre-rename supabase files that rode in again via
the merge (same both-added mechanism as #761) and supersedes the branch's
selectRecentScrapeDigestLogs with filter-object selectScrapeDigestLogs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant