Skip to content

feat(apify): scrape alerts fire only on genuinely new posts (diff against stored posts)#759

Merged
sweetmantech merged 2 commits into
mainfrom
feat/scrape-alert-newness-diff
Jul 9, 2026
Merged

feat(apify): scrape alerts fire only on genuinely new posts (diff against stored posts)#759
sweetmantech merged 2 commits into
mainfrom
feat/scrape-alert-newness-diff

Conversation

@sweetmantech

@sweetmantech sweetmantech commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Kills the alert false positives: the notifier sent "new posts" whenever a scrape returned any posts — no diff against what's stored — so every scrape re-announced the profile's recent feed. Evidence on recoupable/chat#1855: 6 of 7 alerts to one customer in one day announced posts that were 2–10 days old and already in the database.

Tracking issue: recoupable/chat#1855 (PR #3 of 6 — independent; biggest notification-volume win).

What changed

  • lib/socials/filterNewPostUrls.ts (new, reusable across platforms): given candidate post URLs, returns those with no existing posts row. Documented constraint: must run before the upsert (afterwards nothing is distinguishable as new).
  • lib/apify/instagram/handleInstagramProfileScraperResults.ts: computes the diff pre-upsert and only sends the alert when ≥1 URL is genuinely new. Persistence is unaffected — posts/socials/social_posts/follow-up scrapes run exactly as before; only the notification is gated.

Tests (RED→GREEN)

  • filterNewPostUrls: known-vs-new partition, all-new passthrough, empty-input short-circuit (no query).
  • Handler: new case — all posts already stored → no email, everything else persists; existing happy-path (new posts → email) unchanged.
  • Suites: vitest run lib/socials lib/apify/instagram13 files, 63 tests passed; eslint clean.

Relationship to the digest PRs

The digest aggregation (PR #4) consumes this same helper per platform. This PR is deliberately shippable alone for immediate relief on the current per-platform email.

🤖 Generated with Claude Code


Summary by cubic

Stop Instagram scrape alerts from re-announcing old posts by diffing scraped URLs against stored posts before upsert. This gates emails on genuinely new posts and fixes false positives (recoupable/chat#1855).

  • Bug Fixes
    • Added filterNewPostUrls and used it in the Instagram handler pre-upsert to detect genuinely new URLs.
    • Only send alert emails when the diff is non-empty; persistence and follow-up runs unchanged, with tests covering the helper and the no-email path.

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

Review in cubic

Summary by CodeRabbit

  • New Features
    • Added smarter filtering so only newly discovered Instagram posts trigger follow-up notifications.
  • Bug Fixes
    • Prevented notification emails from being sent when scraped posts were already known.
    • Improved handling for empty scrape results by avoiding unnecessary notification attempts.

… 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>
@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 5: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

📝 Walkthrough

Walkthrough

A new filterNewPostUrls function is added to check which post URLs are not already stored, using getPosts. The Instagram profile scraper result handler now uses this function to compute new post URLs before upserting, and only sends the webhook notification email when new posts are found.

Changes

New Post Filtering for Email Notifications

Layer / File(s) Summary
New post URL filter utility
lib/socials/filterNewPostUrls.ts
Adds filterNewPostUrls(postUrls) which returns an empty array for empty input, otherwise queries existing posts via getPosts and filters out URLs already known.
Conditional email notification wiring
lib/apify/instagram/handleInstagramProfileScraperResults.ts
Imports filterNewPostUrls, computes newPostUrls from scraped post URLs before upserting, and only calls sendApifyWebhookEmail when newPostUrls.length > 0 instead of always sending.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related issues

Poem

A rabbit checked the posts anew,
"Have we seen these links? Just a few?"
Filter first, then send the mail,
No more noise on a stale trail. 🐇📬
Clean code hops, tidy and true.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Solid & Clean Code ⚠️ Warning filterNewPostUrls runs before upsert with no fallback; a read error now blocks persistence, violating graceful error handling and clean separation. Wrap the diff in try/catch and default to treating all URLs as new on failure so upsertPosts still runs; keep email best-effort.
✅ 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 docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/scrape-alert-newness-diff

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.

No issues found across 4 files

Confidence score: 5/5

  • Automated review surfaced no issues in the provided summaries.
  • No files require special attention.
Architecture diagram
sequenceDiagram
    participant ApifyCloud as Apify Cloud Webhook
    participant Handler as handleInstagramProfileScraperResults
    participant Filter as filterNewPostUrls (NEW)
    participant DB as Supabase (posts table)
    participant Upsert as upsertPosts / upsertSocials / upsertSocialPosts
    participant Email as sendApifyWebhookEmail
    participant FollowUp as handleInstagramProfileFollowUpRuns

    Note over ApifyCloud,FollowUp: Instragram scrape alert — NEW: only notify on genuinely new posts

    ApifyCloud->>Handler: POST /apify/instagram (scrape result)
    Handler->>Handler: Extract post URLs from latestPosts

    Note over Handler,Filter: Must run BEFORE upsert (chat#1855)
    Handler->>Filter: filterNewPostUrls(postUrls)
    Filter->>DB: getPosts({ postUrls })
    DB-->>Filter: existing posts (post_urls)
    Filter-->>Handler: newPostUrls (URLs not in DB)

    Handler->>Upsert: upsertPosts(postRows)  (all scraped URLs)
    Handler->>Handler: getPosts() to fetch upserted records

    alt newPostUrls.length > 0
        Handler->>Email: sendApifyWebhookEmail(firstResult, emails)
        Email-->>Handler: sentEmails
    else newPostUrls.length === 0
        Note over Handler: NEW: skip email — all posts already stored
    end

    Handler->>FollowUp: handleInstagramProfileFollowUpRuns(...)
    FollowUp-->>Handler: follow-up runs

    Handler-->>ApifyCloud: response (social, posts, etc.)
Loading

Auto-approved: Adds filterNewPostUrls to diff scraped URLs against stored posts, gating alert emails on genuinely new posts only. Tests pass for both the helper and the handler's no-email path.

Re-trigger cubic

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview verification — 2026-07-09

Preview: https://api-61j5b6q9b-recoup.vercel.app (confirmed built from PR head 360268b, deployment Ready 2026-07-06T21:57:03Z).

Method: same isolated-fixture pattern as api#758's verification — a [TEST] artist under my own account watching instagram.com/natgeo (fan-out audited pre-send via account_socials → account_artist_ids → account_emails: exactly ['sweetmantech@gmail.com']), with the 12 natgeo post URLs confirmed absent from posts before the first delivery. Deliveries were controlled webhook POSTs to the preview's /api/apify reusing a real Apify dataset (P70A1q03SQ0Ar24uq, 12 posts), so each response (incl. sentEmails) was captured in full.

# Check Expected Actual on preview Result
1 All URLs new → send email fires delivery 1 (0 of 12 URLs in posts): sentEmails: {id: c5f734bd-…}, Resend last_event: delivered
2 Zero new URLs → no send identical re-delivery suppressed delivery 2 (same payload, all 12 URLs now stored): sentEmails: null — no email
3 Persistence untouched by the gate posts still upserted/returned on suppressed sends both deliveries returned posts: 12, social matched (instagram.com/natgeo)
4 Partial newness → send ≥1 new URL fires deleted exactly 1 of the 12 posts rows, re-delivered: sentEmails: {id: eebc0bcf-…}, delivered
5 Zero-posts dataset unchanged early return, no send dataset with latestPosts: 0{"posts":[],"social":null}
6 Unit suite RED→GREEN, full suite green CI test check SUCCESS on 360268b (63 tests per PR body)

This is exactly the issue's Done-when: "re-running a full scrape twice back-to-back yields exactly one digest; a scrape finding zero unseen post URLs yields none" — observed live as send → suppress on identical back-to-back deliveries.

Notes:

  • Branch predates api#758's merge (2026-07-09): the preview's sends still carry the old recipient shape (to: [recipient], bcc: [] — verified via the Resend record). No conflict on merge (different files), and main already has fix(emails): BCC scrape-alert recipients — never a shared cross-tenant To: line #758, so the merged result carries both fixes — but if you want the combined behavior preview-verified before merging, rebase onto current main and I'll re-run the send check.
  • Test fixture fully cleaned: [TEST] artist deleted via the API; test socials row, all natgeo posts/social_posts/post_comments removed — including the rows the follow-up comments-scraper webhooks re-created after the first sweep (final check: 0 rows remaining).

Verdict: the newness gate does exactly what it claims on the preview — send on new, silence on seen, persistence unaffected. Ready in merge order (after database#41 per chat#1855 sequencing; no hard dependency on it, but the issue's ordering keeps the digest stack clean).

@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

🧹 Nitpick comments (1)
lib/apify/instagram/handleInstagramProfileScraperResults.ts (1)

33-122: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoff

Consider extracting sub-steps to reduce function length.

handleInstagramProfileScraperResults spans ~90 lines, well beyond the 20-line guideline. While this is pre-existing, the PR adds new logic to an already long function. Consider extracting cohesive blocks (e.g., social upsert + lookup, email + follow-up side effects) into named helpers to improve readability and testability.

🤖 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/instagram/handleInstagramProfileScraperResults.ts` around lines 33
- 122, `handleInstagramProfileScraperResults` has grown too long, and the new
logic makes it harder to read and test. Extract cohesive blocks from this
function into named helpers in the same module or nearby, especially the social
upsert/lookup flow and the email/follow-up side effects, while keeping the main
`handleInstagramProfileScraperResults` orchestration concise.

Source: Coding guidelines

🤖 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/instagram/handleInstagramProfileScraperResults.ts`:
- Around line 44-47: The new pre-upsert read in
handleInstagramProfileScraperResults can prevent persistence if
filterNewPostUrls (and its underlying getPosts read) throws, so update this flow
to fail open. Wrap the filterNewPostUrls call in a try-catch inside
handleInstagramProfileScraperResults, and on any read/error path default
newPostUrls to all post_urls from postRows so upsertPosts still runs
unconditionally. Keep the existing postRows handling intact and ensure the
fallback preserves the prior behavior where write success is not blocked by read
failures.

---

Nitpick comments:
In `@lib/apify/instagram/handleInstagramProfileScraperResults.ts`:
- Around line 33-122: `handleInstagramProfileScraperResults` has grown too long,
and the new logic makes it harder to read and test. Extract cohesive blocks from
this function into named helpers in the same module or nearby, especially the
social upsert/lookup flow and the email/follow-up side effects, while keeping
the main `handleInstagramProfileScraperResults` orchestration concise.
🪄 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: a92e147a-2b02-40ba-b1d4-8f8dfce2f1f1

📥 Commits

Reviewing files that changed from the base of the PR and between 69aa286 and b13343a.

⛔ Files ignored due to path filters (2)
  • lib/apify/instagram/__tests__/handleInstagramProfileScraperResults.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
  • lib/socials/__tests__/filterNewPostUrls.test.ts is excluded by !**/*.test.*, !**/__tests__/** and included by lib/**
📒 Files selected for processing (2)
  • lib/apify/instagram/handleInstagramProfileScraperResults.ts
  • lib/socials/filterNewPostUrls.ts

Comment on lines +44 to 47
// Diff BEFORE upserting — afterwards every scraped post exists and nothing
// is distinguishable as new (chat#1855).
const newPostUrls = await filterNewPostUrls(postRows.map(p => p.post_url));
await upsertPosts(postRows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Unguarded filterNewPostUrls call can block post persistence.

filterNewPostUrls runs before upsertPosts. If the DB read inside getPosts throws (transient network/DB error), upsertPosts never executes and scraped posts are lost. Previously, persistence was unconditional — the email logic came after. This introduces a new failure mode where a read error blocks the write.

Wrap the diff in a try-catch and default to all URLs being "new" on failure, so persistence proceeds and the worst case is a duplicate email (the pre-PR behavior).

🛡️ Proposed fix: degrade gracefully on diff failure
   // Diff BEFORE upserting — afterwards every scraped post exists and nothing
   // is distinguishable as new (chat#1855).
-  const newPostUrls = await filterNewPostUrls(postRows.map(p => p.post_url));
+  let newPostUrls: string[];
+  try {
+    newPostUrls = await filterNewPostUrls(postRows.map(p => p.post_url));
+  } catch (error) {
+    console.error("[WARN] filterNewPostUrls failed, assuming all posts are new:", error);
+    newPostUrls = postRows.map(p => p.post_url);
+  }
   await upsertPosts(postRows);
📝 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
// Diff BEFORE upserting — afterwards every scraped post exists and nothing
// is distinguishable as new (chat#1855).
const newPostUrls = await filterNewPostUrls(postRows.map(p => p.post_url));
await upsertPosts(postRows);
// Diff BEFORE upserting — afterwards every scraped post exists and nothing
// is distinguishable as new (chat#1855).
let newPostUrls: string[];
try {
newPostUrls = await filterNewPostUrls(postRows.map(p => p.post_url));
} catch (error) {
console.error("[WARN] filterNewPostUrls failed, assuming all posts are new:", error);
newPostUrls = postRows.map(p => p.post_url);
}
await upsertPosts(postRows);
🤖 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/instagram/handleInstagramProfileScraperResults.ts` around lines 44
- 47, The new pre-upsert read in handleInstagramProfileScraperResults can
prevent persistence if filterNewPostUrls (and its underlying getPosts read)
throws, so update this flow to fail open. Wrap the filterNewPostUrls call in a
try-catch inside handleInstagramProfileScraperResults, and on any read/error
path default newPostUrls to all post_urls from postRows so upsertPosts still
runs unconditionally. Keep the existing postRows handling intact and ensure the
fallback preserves the prior behavior where write success is not blocked by read
failures.

@sweetmantech

Copy link
Copy Markdown
Contributor Author

Preview re-verification after branch update — 2026-07-09

Branch updated with main (merge, not rebase — api#760/761/762 are stacked on this branch, so history was left intact). New head b13343a now includes api#758's BCC fix (verified: aba6835 is an ancestor, and sendApifyWebhookEmail.ts at b13343a sends to: [RECOUP_FROM_EMAIL] + bcc: emails).

New preview: https://api-5727or2zh-recoup.vercel.app (confirmed built from b13343a). Same isolated-fixture method as the previous run; fan-out audited pre-send: exactly ['sweetmantech@gmail.com'].

# Check Expected Actual on preview Result
1 All 12 URLs new → send email fires delivery 1: sentEmails: {id: 329889f0-…}, posts: 12, social matched
2 Zero new URLs → no send identical re-delivery suppressed delivery 2 (same payload): sentEmails: null, posts: 12 still returned (persistence untouched)
3 Branch carries the BCC fix #758 code in the deployed artifact git merge-base --is-ancestor aba6835 b13343a ✓; file at b13343a has to: [RECOUP_FROM_EMAIL], bcc: emails
4 CI on updated head all green format/lint/test/CodeRabbit/cubic/Vercel all SUCCESS on b13343a

One honest caveat: the live Resend header read for delivery 1 wasn't possible this round — the test-session Privy token expired and preview deployments salt API keys differently from prod, so the admin-emails endpoint was unreachable. The BCC behavior of this exact code path was live-verified on #758's preview (Resend record: to = our address only, recipient in bcc), and check #3 proves byte-identical code ships in this preview.

Fixture fully cleaned after the run (artist, social, 12 posts + social_posts + post_comments incl. rows re-created by follow-up comment-scrape webhooks; final sweep: 0 remaining).

Verdict: combined #758+#759 behavior confirmed on the updated branch — ready to merge (next in the chat#1855 sequence; database#47 is merged and applied).

@sweetmantech sweetmantech merged commit 1b81272 into main Jul 9, 2026
6 checks passed
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