diff --git a/app/(app)/projects/[id]/leads/page.tsx b/app/(app)/projects/[id]/leads/page.tsx
index 0469689a..26130eec 100644
--- a/app/(app)/projects/[id]/leads/page.tsx
+++ b/app/(app)/projects/[id]/leads/page.tsx
@@ -12,6 +12,7 @@ import { SeedLogins } from "@/components/leads/seed-logins";
import { FunnelPanel } from "@/components/leads/funnel-panel";
import { RepliesPanel, type ReplyRow } from "@/components/leads/replies-panel";
import { ContactsPanel } from "@/components/leads/contacts-panel";
+import { IntentPanel, type IntentSignalRow } from "@/components/leads/intent-panel";
import { contactNiches } from "@/lib/outreach/contactsExport";
import { campaignFunnels, projectFunnel } from "@/lib/outreach/funnel";
import { listSeedCredentials, type StoredSeedCredential } from "@/lib/outreach/seedCredentials";
@@ -91,7 +92,7 @@ export default async function LeadsPage({
supabase
.from("outreach_campaigns")
.select(
- "id, name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note, auth_required_hosts, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, angle, sender_name, reply_to",
+ "id, name, active, auto_send, daily_send_limit, max_score, queries, seed_urls, last_run_at, last_run_note, auth_required_hosts, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, min_intent, angle, sender_name, reply_to",
)
.eq("project_id", projectId)
.order("updated_at", { ascending: false })
@@ -180,6 +181,17 @@ export default async function LeadsPage({
.limit(20);
const replies = (replyData as ReplyRow[] | null) ?? [];
+ // Public requests to buy, strongest and freshest first. A separate queue
+ // from the pipeline: these are people on a platform, not domains to email.
+ const { data: intentData } = await supabase
+ .from("outreach_intent_signals")
+ .select("source, url, title, snippet, score, tier, reasons, posted_at, status")
+ .eq("project_id", projectId)
+ .neq("status", "dismissed")
+ .order("score", { ascending: false })
+ .limit(25);
+ const intentSignals = (intentData as IntentSignalRow[] | null) ?? [];
+
let seedCredentials: StoredSeedCredential[] = [];
if (orgId) seedCredentials = await listSeedCredentials(orgId);
@@ -239,6 +251,8 @@ export default async function LeadsPage({
+
+
diff --git a/app/(marketing)/lead-generation/page.tsx b/app/(marketing)/lead-generation/page.tsx
index bc561970..71416dd7 100644
--- a/app/(marketing)/lead-generation/page.tsx
+++ b/app/(marketing)/lead-generation/page.tsx
@@ -5,7 +5,7 @@ import { LEADS_PER_CHARGE } from "@/lib/outreach/billing";
export const metadata = {
title: "Lead generation & cold outreach",
description:
- "Point CrawlProof at a directory or a search, and it finds the companies, finds the people, finds the addresses, writes the email, and reads the replies — from your own mailbox, grounded in facts you supply. 3 credits a run.",
+ "Watch for people publicly asking to buy what you sell, and work them first. Then find the companies, the people and the addresses, write the email and read the replies — from your own mailbox, grounded in facts you supply.",
alternates: { canonical: "/lead-generation" },
openGraph: {
title: "Lead generation & cold outreach · CrawlProof",
@@ -85,6 +85,43 @@ export default function LeadGenerationPage() {
+
It reads the directory like a person would
diff --git a/app/actions/leads.ts b/app/actions/leads.ts
index 7a31e350..9ab0aa0e 100644
--- a/app/actions/leads.ts
+++ b/app/actions/leads.ts
@@ -505,6 +505,12 @@ export async function saveCampaignAction(input: {
pitchAsk?: string;
pitchFacts?: string;
scanProspects?: boolean;
+ /**
+ * Minimum public buying intent a lead must show, 0-100. Null or absent
+ * leaves the campaign selecting on resemblance alone, which is what every
+ * campaign did before intent existed.
+ */
+ minIntent?: number | null;
}): Promise | Err> {
const auth = await requireLeadAccess(input.projectId);
if (!auth.ok) return auth;
@@ -572,6 +578,12 @@ export async function saveCampaignAction(input: {
max_score: input.maxScore,
daily_send_limit: input.dailySendLimit,
auto_send: input.autoSend,
+ // Clamped rather than rejected: a bar above 100 silently matches
+ // nothing, which looks like the sweep being broken.
+ min_intent:
+ input.minIntent === null || input.minIntent === undefined
+ ? null
+ : Math.max(0, Math.min(100, Math.round(input.minIntent))),
angle: input.angle?.trim() || null,
sender_name: input.senderName?.trim() || null,
reply_to: input.replyTo?.trim() || null,
diff --git a/components/leads/campaign-panel.tsx b/components/leads/campaign-panel.tsx
index 5e411ed9..774cace4 100644
--- a/components/leads/campaign-panel.tsx
+++ b/components/leads/campaign-panel.tsx
@@ -35,6 +35,7 @@ export type CampaignSummary = {
pitch_ask: string | null;
pitch_facts: string[];
scan_prospects: boolean;
+ min_intent: number | null;
auth_required_hosts: string[];
runs: CampaignRun[];
};
@@ -83,6 +84,9 @@ export function CampaignPanel({
// scan of the prospect to contribute. Selecting the audit pitch turns it
// back on, because that pitch is built from findings.
const [scanProspects, setScanProspects] = useState(false);
+ // Null means "don't qualify on intent" — the behaviour every campaign had
+ // before this existed, and the default for anything already running.
+ const [minIntent, setMinIntent] = useState(null);
const [goal, setGoal] = useState("");
const [generating, setGenerating] = useState(false);
const [pitchNote, setPitchNote] = useState(null);
@@ -159,6 +163,7 @@ export function CampaignPanel({
setPitchAsk(c.pitch_ask ?? "");
setPitchFacts((c.pitch_facts ?? []).join("\n"));
setScanProspects(c.scan_prospects);
+ setMinIntent(c.min_intent ?? null);
// Every field the form submits has to be loaded, not just the ones that
// changed recently: save() sends the whole form, so anything left blank
// here is written back as blank and silently wipes the column.
@@ -191,6 +196,7 @@ export function CampaignPanel({
pitchAsk,
pitchFacts,
scanProspects,
+ minIntent,
});
if (result.ok) setEditing(null);
return result;
@@ -492,6 +498,42 @@ export function CampaignPanel({
>
)}
+
+ setMinIntent(e.target.checked ? 40 : null)}
+ />
+
+ Only work leads who publicly asked to buy
+
+ Sweeps Reddit, forums, Q&A sites and the social networks for people asking for
+ what you sell, and ranks them by how explicitly they asked and how recently. Cold
+ outreach to people who never asked converts badly however good the copy is.
+
+
+
+
+ {minIntent !== null && (
+
+ Minimum intent score: {minIntent}
+ setMinIntent(Number(e.target.value))}
+ />
+
+ Around 40 is a fresh “can anyone recommend…”. Above 70 is someone naming
+ a budget. Higher means fewer and better.
+
+
+ )}
+
= {
+ purchase: "badge-pass",
+ switching: "badge-pass",
+ solicitation: "badge-warn",
+ pain: "badge-unknown",
+};
+
+function age(posted: string | null): string {
+ if (!posted) return "undated";
+ const hours = (Date.now() - new Date(posted).getTime()) / 3_600_000;
+ if (hours < 1) return "just now";
+ if (hours < 24) return `${Math.round(hours)}h ago`;
+ return `${Math.round(hours / 24)}d ago`;
+}
+
+/**
+ * People who publicly asked to buy, strongest and freshest first.
+ *
+ * Deliberately a separate queue from the lead pipeline rather than a column on
+ * it. These are not companies with an address to email — they are individuals
+ * on a platform, and the reply goes where they asked, in public, as a reply.
+ * Treating them as prospects would file every Reddit thread under the domain
+ * reddit.com and lose the person entirely.
+ */
+export function IntentPanel({ signals }: { signals: IntentSignalRow[] }) {
+ if (!signals.length) return null;
+
+ return (
+
+
+
Asked to buy
+
+ Ranked by how explicitly they asked and how recently. Reply where they asked — not by
+ email.
+
+
+
+
+
+ );
+}
diff --git a/lib/outreach/intent.ts b/lib/outreach/intent.ts
new file mode 100644
index 00000000..41836fce
--- /dev/null
+++ b/lib/outreach/intent.ts
@@ -0,0 +1,266 @@
+// Buying intent, as a signal you can sort by.
+//
+// The premise this encodes, which came from someone who had already built a
+// business on it: cold outreach to people who have shown no intent converts
+// badly regardless of how good the copy is, and no amount of firmographic
+// targeting fixes that. Someone who posted "anyone know a good X, happy to
+// pay" four hours ago is a different prospect from a company that merely
+// resembles your customers — not a better-matched one, a categorically
+// different one.
+//
+// So intent and recency are scored as the primary signal, and everything the
+// rest of the pipeline does well — finding addresses, grounding drafts — is
+// downstream of choosing who to write to at all.
+//
+// Deliberately source-agnostic. A Reddit thread, a forum post, a job ad, a
+// tweet and a status page all reduce to "someone said this, at this time", and
+// a scorer that only understands one platform makes every new source a rewrite.
+// The adapters live in intentSources.ts; this file knows nothing about where
+// the words came from.
+
+/** Hours after which a signal has decayed to roughly half its strength. */
+const HALF_LIFE_HOURS = 36;
+/** Past this, treat a signal as stale regardless of how strong it was. */
+const MAX_AGE_HOURS = 24 * 14;
+
+export type IntentTier = "purchase" | "solicitation" | "switching" | "pain" | "none";
+
+type TierSpec = {
+ tier: Exclude;
+ /** Base score before recency is applied. */
+ weight: number;
+ label: string;
+ patterns: RegExp[];
+};
+
+/**
+ * Ordered strongest first. The tiers are distinct kinds of statement, not
+ * degrees of the same one:
+ *
+ * - purchase: money is already on the table.
+ * - switching: they have a supplier and are leaving. The budget exists and
+ * the decision to spend it has been made — which is why this outranks a
+ * general request for suggestions.
+ * - solicitation: asking the room for a recommendation.
+ * - pain: describing a problem without asking for anything.
+ */
+const TIERS: TierSpec[] = [
+ {
+ tier: "purchase",
+ weight: 100,
+ label: "ready to pay",
+ patterns: [
+ /\b(?:looking|want|wanting|ready|keen)\s+to\s+(?:buy|purchase|pay)\b/i,
+ /\b(?:willing|happy|glad)\s+to\s+pay\b/i,
+ /\b(?:we|i)\s+(?:have|got)\s+(?:a\s+)?budget\b/i,
+ /\bbudget\s+(?:of|for|is|around|up to)\b/i,
+ /\b(?:take|shut up and take)\s+my\s+money\b/i,
+ /\b(?:request for proposal|rfp|rfq)\b/i,
+ /\b(?:get|need|send|want)\s+(?:a\s+)?(?:quote|quotes|pricing)\b/i,
+ /\bwho\s+(?:do|should)\s+(?:i|we)\s+pay\b/i,
+ /\b(?:hire|hiring|contract|engage)\s+(?:someone|a\s+\w+|an\s+agency|a\s+freelancer)\b/i,
+ /\bpaid\s+(?:tool|service|plan|option|solution)\b/i,
+ /\bhappy\s+to\s+(?:pay|spend)\b/i,
+ ],
+ },
+ {
+ tier: "switching",
+ weight: 85,
+ label: "leaving their current supplier",
+ patterns: [
+ /\b(?:migrating|moving|switching|shifting)\s+(?:away\s+)?(?:from|off)\b/i,
+ /\blooking\s+(?:to|for)\s+(?:a\s+)?replace(?:ment)?\b/i,
+ /\b(?:replacing|replace)\s+(?:our|my|the)\b/i,
+ /\b(?:cancelled|canceling|cancelling|dropping|ditching)\s+(?:our|my)\b/i,
+ /\balternatives?\s+to\b/i,
+ /\bfed\s+up\s+with\b/i,
+ /\bcontract\s+(?:is\s+)?(?:up|expiring|ending|renewing)\b/i,
+ ],
+ },
+ {
+ tier: "solicitation",
+ weight: 70,
+ label: "asking for a recommendation",
+ patterns: [
+ /\b(?:anyone|anybody)\s+(?:know|use|used|recommend|tried)\b/i,
+ /\b(?:can|could)\s+(?:anyone|anybody|someone)\s+recommend\b/i,
+ /\brecommendations?\s+for\b/i,
+ /\b(?:looking|searching|hunting)\s+for\s+(?:a|an|some)\b/i,
+ /\bin\s+search\s+of\b/i,
+ /\bsuggestions?\s+for\b/i,
+ /\bwhat(?:'s| is| are)\s+the\s+best\b/i,
+ /\bwhich\s+(?:tool|service|vendor|provider|agency|platform)\b/i,
+ /\bwhat\s+(?:do|are)\s+you\s+(?:all\s+)?(?:use|using)\b/i,
+ /\bany\s+(?:tools?|services?|vendors?|agencies)\s+that\b/i,
+ ],
+ },
+ {
+ tier: "pain",
+ weight: 45,
+ label: "describing a problem",
+ patterns: [
+ /\b(?:struggling|struggle)\s+with\b/i,
+ /\b(?:sick|tired)\s+of\b/i,
+ /\b(?:frustrated|frustrating)\b/i,
+ /\bcan(?:'|no)?t\s+(?:figure|get|work)\b/i,
+ /\b(?:keeps|constantly)\s+(?:failing|breaking|crashing)\b/i,
+ /\b(?:manual|by hand)\s+(?:process|work|workflow)\b/i,
+ /\b(?:wasting|spending)\s+(?:hours|days|so much time)\b/i,
+ /\bis\s+there\s+(?:a|any)\s+(?:tool|way|service)\b/i,
+ ],
+ },
+];
+
+/**
+ * Statements that mean this is not a lead however well it otherwise scores.
+ *
+ * These are cheap to check and expensive to get wrong in the other direction:
+ * messaging someone who wrote "no vendors please" is the fastest way to lose
+ * a channel for everybody.
+ */
+const DISQUALIFIERS: Array<{ pattern: RegExp; reason: string }> = [
+ { pattern: /\bno\s+(?:vendors?|sales|solicitation|pitches|promos?|ads?)\b/i, reason: "asked not to be pitched" },
+ { pattern: /\b(?:do\s*n(?:o|')t|please\s+don'?t)\s+(?:dm|pm|message|contact|email)\s+me\b/i, reason: "asked not to be contacted" },
+ { pattern: /\bnot\s+looking\s+(?:to\s+(?:buy|pay)|for\s+(?:a\s+)?(?:vendor|tool|service))\b/i, reason: "explicitly not buying" },
+ { pattern: /\b(?:free|open[- ]source)\s+only\b/i, reason: "free/open-source only" },
+ { pattern: /\bno\s+paid\b/i, reason: "free/open-source only" },
+ { pattern: /\bthis\s+is\s+(?:an?\s+)?(?:ad|advert|sponsored)\b/i, reason: "the post is itself an advert" },
+ { pattern: /\bwe(?:'re| are)\s+hiring\b/i, reason: "a job ad, not a buyer" },
+];
+
+export type IntentSignal = {
+ /** 0–100, recency already applied. */
+ score: number;
+ tier: IntentTier;
+ /** Why it scored what it did, in the user's words where possible. */
+ reasons: string[];
+ /** Set when the text rules the lead out entirely. */
+ disqualified: string | null;
+ /** Age in hours, or null when the source gave no timestamp. */
+ ageHours: number | null;
+};
+
+/**
+ * How much of a signal's strength survives its age.
+ *
+ * Halves every HALF_LIFE_HOURS rather than falling off a cliff at some
+ * threshold, because a 25-hour-old post is not meaningfully worse than a
+ * 23-hour-old one and a step function would rank them as if it were.
+ */
+export function recencyFactor(ageHours: number | null): number {
+ // No timestamp is not the same as old. Most scraped directory entries have
+ // no date at all, and pretending they are fresh would rank them above a
+ // real post from yesterday — so they take a flat penalty instead.
+ if (ageHours === null) return 0.55;
+ if (ageHours < 0) return 1;
+ if (ageHours > MAX_AGE_HOURS) return 0;
+ return Math.pow(0.5, ageHours / HALF_LIFE_HOURS);
+}
+
+/**
+ * Score a piece of public text for buying intent.
+ *
+ * `keywords` are the campaign's own topic words. Intent without topic match is
+ * somebody else's lead: "anyone recommend a good accountant" is a perfect
+ * solicitation and worthless to a company selling load testing.
+ */
+export function scoreIntent(input: {
+ text: string;
+ /** When it was posted. Null when the source does not say. */
+ postedAt?: Date | null;
+ /** Campaign topic words. At least one must appear. */
+ keywords?: string[];
+ negativeKeywords?: string[];
+ now?: Date;
+}): IntentSignal {
+ const text = (input.text ?? "").slice(0, 8000);
+ const now = input.now ?? new Date();
+ const ageHours = input.postedAt
+ ? Math.max(0, (now.getTime() - input.postedAt.getTime()) / 3_600_000)
+ : null;
+
+ const base: IntentSignal = { score: 0, tier: "none", reasons: [], disqualified: null, ageHours };
+ if (!text.trim()) return { ...base, disqualified: "no text to read" };
+
+ for (const d of DISQUALIFIERS) {
+ if (d.pattern.test(text)) return { ...base, disqualified: d.reason };
+ }
+
+ const negatives = (input.negativeKeywords ?? []).filter((k) =>
+ k.trim() ? text.toLowerCase().includes(k.toLowerCase()) : false,
+ );
+ if (negatives.length) {
+ return { ...base, disqualified: `negative match: ${negatives.join(", ")}` };
+ }
+
+ const keywords = (input.keywords ?? []).filter((k) => k.trim());
+ if (keywords.length) {
+ const hits = keywords.filter((k) => text.toLowerCase().includes(k.toLowerCase()));
+ if (!hits.length) {
+ return { ...base, disqualified: "on-topic for nobody — no campaign keyword appears" };
+ }
+ base.reasons.push(`mentions ${hits.slice(0, 3).join(", ")}`);
+ }
+
+ const matched = TIERS.filter((t) => t.patterns.some((p) => p.test(text)));
+ if (!matched.length) {
+ return {
+ ...base,
+ score: 0,
+ tier: "none",
+ disqualified: null,
+ reasons: [...base.reasons, "no expression of intent — nobody asked for anything"],
+ };
+ }
+
+ // The strongest statement sets the score; the others add a little, because
+ // someone who is both leaving a supplier and asking for recommendations is
+ // a better prospect than someone doing either alone — but not twice as good.
+ const strongest = matched[0];
+ const extra = matched.slice(1).reduce((n, t) => n + t.weight * 0.08, 0);
+ const raw = Math.min(100, strongest.weight + extra);
+
+ const factor = recencyFactor(ageHours);
+ const reasons = [...base.reasons, strongest.label];
+ for (const t of matched.slice(1)) reasons.push(t.label);
+
+ if (ageHours === null) {
+ reasons.push("no date on the source, so treated as stale");
+ } else if (ageHours <= 6) {
+ reasons.push("posted in the last few hours");
+ } else if (ageHours <= 24) {
+ reasons.push("posted today");
+ } else {
+ reasons.push(`${Math.round(ageHours / 24)}d old`);
+ }
+
+ return {
+ score: Math.round(raw * factor),
+ tier: strongest.tier,
+ reasons,
+ disqualified: factor === 0 ? `older than ${Math.round(MAX_AGE_HOURS / 24)} days` : null,
+ ageHours,
+ };
+}
+
+/** Default bar for acting on a signal. Roughly "a fresh solicitation". */
+export const DEFAULT_MIN_INTENT = 40;
+
+/**
+ * Whether a lead clears a campaign's bar.
+ *
+ * A campaign with no bar set keeps the old behaviour, so turning intent
+ * scoring on does not silently empty anybody's pipeline.
+ */
+export function qualifies(signal: IntentSignal, minIntent: number | null | undefined): boolean {
+ if (signal.disqualified) return false;
+ if (minIntent === null || minIntent === undefined) return true;
+ return signal.score >= minIntent;
+}
+
+/** One-line explanation for the run log and the leads table. */
+export function describeIntent(signal: IntentSignal): string {
+ if (signal.disqualified) return `no intent: ${signal.disqualified}`;
+ if (signal.tier === "none") return "no intent signal";
+ return `${signal.score}/100 ${signal.tier} — ${signal.reasons.slice(0, 3).join("; ")}`;
+}
diff --git a/lib/outreach/intentSources.ts b/lib/outreach/intentSources.ts
new file mode 100644
index 00000000..fbac300c
--- /dev/null
+++ b/lib/outreach/intentSources.ts
@@ -0,0 +1,254 @@
+// Where public buying intent gets expressed, and how to go and read it.
+//
+// Reddit is the obvious one and the one this codebase already talks to
+// natively, but it is not the only place somebody says "we need X, who should
+// we pay". The same sentence turns up on Hacker News, Stack Exchange, Quora,
+// X, LinkedIn, Mastodon, Indie Hackers and a long tail of forums — and a
+// pipeline that only watches one of them inherits that one community's
+// demographics as its entire market.
+//
+// Everything except Reddit is read through the search index rather than a
+// platform API. That is a deliberate trade: platform APIs mean per-platform
+// auth, per-platform rate limits, per-platform review processes and a
+// per-platform reason to break, and the search index already crawls all of
+// them with a date filter attached. It costs one SERP call per site per run
+// and needs no credentials at all.
+//
+// The honest limitation, stated here because it decides how the results should
+// be read: a search index lags. Something posted twenty minutes ago may not be
+// indexed yet, so this finds hours-old intent reliably and minutes-old intent
+// only sometimes. Reddit's own API does not have that lag, which is why it
+// stays a first-class source rather than another site: filter.
+
+import { searchSerp } from "@/lib/alerts/valueserp";
+import { scoreIntent, type IntentSignal } from "./intent";
+
+export type IntentSource = {
+ id: string;
+ label: string;
+ /** Domains to restrict a search to. */
+ sites: string[];
+ /** Off by default when a platform rewards volume over relevance. */
+ defaultOn: boolean;
+};
+
+/**
+ * The places worth watching, and why each is on the list.
+ *
+ * Ordered by how often people state a budget outright. Q&A sites and founder
+ * communities do it constantly; the big social networks do it rarely but at
+ * enough volume to be worth one call.
+ */
+export const INTENT_SOURCES: IntentSource[] = [
+ {
+ id: "reddit",
+ label: "Reddit",
+ sites: ["reddit.com"],
+ defaultOn: true,
+ },
+ {
+ id: "forums",
+ label: "Q&A and developer forums",
+ sites: ["news.ycombinator.com", "stackexchange.com", "serverfault.com", "superuser.com"],
+ defaultOn: true,
+ },
+ {
+ id: "founders",
+ label: "Founder and indie communities",
+ sites: ["indiehackers.com", "producthunt.com", "lobste.rs"],
+ defaultOn: true,
+ },
+ {
+ id: "quora",
+ label: "Quora",
+ sites: ["quora.com"],
+ defaultOn: true,
+ },
+ {
+ id: "x",
+ label: "X",
+ sites: ["x.com", "twitter.com"],
+ // Public search coverage of X is patchy since the API changes, so it is
+ // on but expected to be thin rather than reliable.
+ defaultOn: true,
+ },
+ {
+ id: "linkedin",
+ label: "LinkedIn",
+ sites: ["linkedin.com"],
+ defaultOn: true,
+ },
+ {
+ id: "mastodon",
+ label: "Mastodon and Bluesky",
+ sites: ["mastodon.social", "bsky.app"],
+ defaultOn: false,
+ },
+ {
+ id: "jobs",
+ label: "Job and contract boards",
+ // A job ad for the thing you sell is a budget that has already been
+ // approved — it is intent wearing different clothes.
+ sites: ["upwork.com", "weworkremotely.com", "remoteok.com"],
+ defaultOn: false,
+ },
+];
+
+/**
+ * Search phrasings that find a request rather than an article about one.
+ *
+ * A bare topic search returns listicles and vendor pages; these return people
+ * talking. Each is combined with the campaign's own topic words.
+ */
+export const INTENT_PHRASINGS = [
+ '"anyone recommend"',
+ '"looking for a"',
+ '"any recommendations"',
+ '"willing to pay"',
+ '"alternatives to"',
+ '"what do you use"',
+];
+
+export type IntentHit = {
+ source: string;
+ url: string;
+ title: string;
+ snippet: string;
+ /** Parsed from the result where the index reports one. */
+ postedAt: Date | null;
+ signal: IntentSignal;
+};
+
+/**
+ * Turn the date a search index reports into a timestamp.
+ *
+ * Indexes report these inconsistently — an absolute date on one result and "3
+ * days ago" on the next — and a relative string is still worth parsing,
+ * because recency is the half of the score that a stale-but-strong post gets
+ * wrong.
+ */
+export function parseResultDate(raw: string | null | undefined, now = new Date()): Date | null {
+ if (!raw) return null;
+ const text = raw.trim();
+
+ const relative = text.match(/(\d+)\s*(minute|hour|day|week|month|year)s?\s+ago/i);
+ if (relative) {
+ const n = Number(relative[1]);
+ const unit = relative[2].toLowerCase();
+ const ms =
+ unit === "minute" ? 60_000
+ : unit === "hour" ? 3_600_000
+ : unit === "day" ? 86_400_000
+ : unit === "week" ? 604_800_000
+ : unit === "month" ? 2_592_000_000
+ : 31_536_000_000;
+ return new Date(now.getTime() - n * ms);
+ }
+
+ const parsed = Date.parse(text);
+ if (!Number.isNaN(parsed)) {
+ const d = new Date(parsed);
+ // A date in the future is the index misreporting, not a scoop.
+ return d.getTime() > now.getTime() + 86_400_000 ? null : d;
+ }
+ return null;
+}
+
+/** One search string per source, combining the topic with a request phrasing. */
+export function buildIntentQuery(input: {
+ source: IntentSource;
+ topic: string;
+ phrasing: string;
+}): string {
+ const sites = input.source.sites.map((s) => `site:${s}`).join(" OR ");
+ return `(${sites}) ${input.topic} ${input.phrasing}`;
+}
+
+export type IntentSweepResult = {
+ hits: IntentHit[];
+ /** SERP calls actually spent, for the caller's budget. */
+ calls: number;
+ notes: string[];
+};
+
+/**
+ * Sweep the enabled sources for people asking to buy.
+ *
+ * Bounded by an explicit call budget rather than by the number of sources, so
+ * adding a platform to the list above cannot quietly multiply what a run
+ * costs.
+ */
+export async function sweepIntent(input: {
+ topic: string;
+ keywords: string[];
+ negativeKeywords?: string[];
+ sources?: string[];
+ /** Maximum SERP calls this sweep may spend. */
+ maxCalls: number;
+ /** Only consider posts newer than this. */
+ recency?: "day" | "week" | "month";
+ minIntent?: number;
+ now?: Date;
+}): Promise {
+ const now = input.now ?? new Date();
+ const enabled = INTENT_SOURCES.filter((s) =>
+ input.sources?.length ? input.sources.includes(s.id) : s.defaultOn,
+ );
+ const notes: string[] = [];
+ const seen = new Set();
+ const hits: IntentHit[] = [];
+ let calls = 0;
+
+ // One phrasing at a time across every source, rather than every phrasing on
+ // the first source — so a small budget still covers the whole list instead
+ // of exhausting itself on Reddit.
+ outer: for (const phrasing of INTENT_PHRASINGS) {
+ for (const source of enabled) {
+ if (calls >= input.maxCalls) {
+ notes.push(`stopped at the ${input.maxCalls}-search budget`);
+ break outer;
+ }
+ const res = await searchSerp({
+ query: buildIntentQuery({ source, topic: input.topic, phrasing }),
+ recency: input.recency ?? "week",
+ num: 20,
+ });
+ calls += res.calls;
+ if (!res.ok) {
+ notes.push(`${source.label}: ${res.error ?? "search failed"}`);
+ continue;
+ }
+
+ for (const r of res.results) {
+ if (!r.url || seen.has(r.url)) continue;
+ seen.add(r.url);
+ const postedAt = parseResultDate(r.date, now);
+ const signal = scoreIntent({
+ // Title and snippet are all the index gives; it is also roughly what
+ // a human skims before deciding whether to open the thread.
+ text: `${r.title ?? ""}\n${r.snippet ?? ""}`,
+ postedAt,
+ keywords: input.keywords,
+ negativeKeywords: input.negativeKeywords,
+ now,
+ });
+ if (signal.disqualified) continue;
+ if (signal.score < (input.minIntent ?? 0)) continue;
+ hits.push({
+ source: source.id,
+ url: r.url,
+ title: r.title ?? "",
+ snippet: r.snippet ?? "",
+ postedAt,
+ signal,
+ });
+ }
+ }
+ }
+
+ // Strongest and freshest first — the score already folds both in, so this is
+ // simply the order to work them in.
+ hits.sort((a, b) => b.signal.score - a.signal.score);
+ notes.push(`${hits.length} intent signals from ${enabled.length} sources in ${calls} searches`);
+ return { hits, calls, notes };
+}
diff --git a/lib/outreach/runner.ts b/lib/outreach/runner.ts
index 55c16aba..4267e6db 100644
--- a/lib/outreach/runner.ts
+++ b/lib/outreach/runner.ts
@@ -26,6 +26,8 @@ import {
import { nextStepReadyAt, type OutreachStep } from "./cold";
import { leadRunBilling, outOfCreditsNote } from "./billing";
import { recordDiscoveredPeople } from "./contacts";
+import { sweepIntent } from "./intentSources";
+import { describeIntent } from "./intent";
import { LEAD_RUN_CREDITS } from "@/lib/credits";
export type CampaignRow = {
@@ -51,13 +53,17 @@ export type CampaignRow = {
pitch_ask: string | null;
pitch_facts: string[] | null;
scan_prospects: boolean;
+ /** Minimum intent score a lead must show. Null = no intent gate. */
+ min_intent: number | null;
+ intent_sources: string[] | null;
+ intent_recency: "day" | "week" | "month" | null;
sender_name: string | null;
reply_to: string | null;
last_run_at: string | null;
};
export const CAMPAIGN_COLUMNS =
- "id, project_id, owner_id, name, channel, active, queries, seed_urls, keywords, subreddits, negative_keywords, max_score, daily_send_limit, target_pipeline, auto_send, follow_ups, angle, sender_name, reply_to, last_run_at, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects";
+ "id, project_id, owner_id, name, channel, active, queries, seed_urls, keywords, subreddits, negative_keywords, max_score, daily_send_limit, target_pipeline, auto_send, follow_ups, angle, sender_name, reply_to, last_run_at, pitch_mode, pitch_intro, pitch_ask, pitch_facts, scan_prospects, min_intent, intent_sources, intent_recency";
export type TickResult = {
campaign: string;
@@ -75,6 +81,10 @@ export type TickResult = {
creditsSpent: number;
/** People named by this tick and written to the shared contact record. */
peopleRecorded: number;
+ /** Public requests to buy that this tick found and qualified. */
+ intentFound: number;
+ /** Candidates dropped for showing no intent, when the campaign requires it. */
+ intentRejected: number;
skipped: string[];
errors: string[];
};
@@ -89,6 +99,10 @@ const MAX_SEND_PER_TICK = 5;
// variable cost in a run — SERP calls scale with how many prospects publish
// no address — so it gets a ceiling like every other per-tick stage.
const MAX_CONTACT_SEARCHES_PER_TICK = 10;
+// Searches a tick may spend sweeping for public buying intent. Small, because
+// it runs every fifteen minutes and the window it looks at is days wide — the
+// same request does not need finding four times an hour.
+const MAX_INTENT_SEARCHES_PER_TICK = 6;
export async function runEmailCampaignTick(campaign: CampaignRow): Promise {
const sb = serviceClient();
@@ -103,6 +117,8 @@ export async function runEmailCampaignTick(campaign: CampaignRow): Promise new Date(NOW.getTime() - h * 3_600_000);
+const kw = ["load testing"];
+
+const score = (text: string, hours = 2) =>
+ scoreIntent({ text, postedAt: agoHours(hours), keywords: kw, now: NOW });
+
+describe("what counts as intent", () => {
+ it("ranks money on the table highest", () => {
+ const s = score("Anyone know a good load testing service? Willing to pay.");
+ expect(s.tier).toBe("purchase");
+ expect(s.score).toBeGreaterThan(70);
+ });
+
+ it("treats leaving a supplier as strong intent", () => {
+ // The budget exists and the decision to spend it has been made.
+ const s = score("We're migrating away from our load testing vendor.");
+ expect(s.tier).toBe("switching");
+ expect(s.score).toBeGreaterThan(50);
+ });
+
+ it("recognises a plain request for a recommendation", () => {
+ expect(score("Can anyone recommend a load testing tool?").tier).toBe("solicitation");
+ });
+
+ it("scores a bare complaint lowest of the four", () => {
+ const pain = score("Struggling with our load testing setup again.");
+ const ask = score("Can anyone recommend a load testing tool?");
+ expect(pain.tier).toBe("pain");
+ expect(pain.score).toBeLessThan(ask.score);
+ });
+
+ it("says plainly when nobody asked for anything", () => {
+ // A statement is not an invitation, and this is the case the whole
+ // feature exists to separate out.
+ const s = score("Here is our quarterly load testing report.");
+ expect(s.tier).toBe("none");
+ expect(s.score).toBe(0);
+ expect(s.reasons.join(" ")).toMatch(/nobody asked/);
+ });
+});
+
+describe("recency is half the signal", () => {
+ it("decays with age", () => {
+ const fresh = score("Willing to pay for load testing help", 1);
+ const old = score("Willing to pay for load testing help", 24 * 7);
+ expect(fresh.score).toBeGreaterThan(old.score * 2);
+ });
+
+ it("halves at the half-life", () => {
+ expect(recencyFactor(36)).toBeCloseTo(0.5, 2);
+ expect(recencyFactor(72)).toBeCloseTo(0.25, 2);
+ });
+
+ it("gives up entirely on something ancient", () => {
+ const s = score("Willing to pay for load testing", 24 * 30);
+ expect(s.score).toBe(0);
+ expect(s.disqualified).toMatch(/older than/);
+ });
+
+ it("penalises an undated source rather than assuming it is fresh", () => {
+ // Most scraped directory entries have no date. Treating them as new would
+ // rank them above a real post from yesterday.
+ const undated = scoreIntent({ text: "Willing to pay for load testing", keywords: kw, now: NOW });
+ const fresh = score("Willing to pay for load testing", 1);
+ expect(undated.score).toBeLessThan(fresh.score);
+ expect(undated.reasons.join(" ")).toMatch(/no date/);
+ });
+});
+
+describe("intent for somebody else is not a lead", () => {
+ it("drops a perfect request about the wrong topic", () => {
+ // "Anyone recommend a good accountant, happy to pay" is flawless
+ // solicitation and worthless to a company selling load testing.
+ const s = scoreIntent({
+ text: "Anyone recommend a good accountant? Happy to pay.",
+ postedAt: agoHours(1),
+ keywords: kw,
+ now: NOW,
+ });
+ expect(s.disqualified).toMatch(/no campaign keyword/);
+ });
+
+ it("honours the campaign's negative keywords", () => {
+ const s = scoreIntent({
+ text: "Anyone recommend free load testing for a student project?",
+ postedAt: agoHours(1),
+ keywords: kw,
+ negativeKeywords: ["student"],
+ now: NOW,
+ });
+ expect(s.disqualified).toMatch(/negative match/);
+ });
+});
+
+describe("people who said not to pitch them", () => {
+ it("refuses someone who asked not to be pitched", () => {
+ // The fastest way to lose a channel for everyone is to message the person
+ // who wrote this.
+ for (const text of [
+ "Looking for load testing advice. No vendors please.",
+ "Need load testing help — please don't DM me.",
+ "Load testing, free only, not looking to pay",
+ ]) {
+ expect(scoreIntent({ text, postedAt: agoHours(1), keywords: kw, now: NOW }).disqualified).toBeTruthy();
+ }
+ });
+
+ it("does not mistake a job ad for a buyer", () => {
+ const s = score("We're hiring a load testing engineer, budget approved.");
+ expect(s.disqualified).toMatch(/job ad/);
+ });
+});
+
+describe("qualifying against a campaign's bar", () => {
+ it("lets everything through when no bar is set", () => {
+ // Turning the feature on must not silently empty an existing pipeline.
+ const weak = score("Struggling with load testing", 24 * 5);
+ expect(qualifies(weak, null)).toBe(true);
+ expect(qualifies(weak, undefined)).toBe(true);
+ });
+
+ it("holds a weak signal below the bar", () => {
+ expect(qualifies(score("Struggling with load testing", 24 * 6), DEFAULT_MIN_INTENT)).toBe(false);
+ });
+
+ it("passes a fresh explicit request", () => {
+ expect(qualifies(score("Anyone recommend a load testing tool?", 2), DEFAULT_MIN_INTENT)).toBe(true);
+ });
+
+ it("never qualifies a disqualified signal, whatever the bar", () => {
+ const blocked = scoreIntent({ text: "load testing, no vendors", postedAt: agoHours(1), keywords: kw, now: NOW });
+ expect(qualifies(blocked, 0)).toBe(false);
+ });
+
+ it("explains itself in one line", () => {
+ expect(describeIntent(score("Willing to pay for load testing", 1))).toMatch(/purchase/);
+ });
+});
+
+describe("reading dates off a search index", () => {
+ it("parses a relative date", () => {
+ expect(parseResultDate("3 hours ago", NOW)?.toISOString()).toBe("2026-07-28T09:00:00.000Z");
+ });
+
+ it("parses an absolute date", () => {
+ expect(parseResultDate("Jul 20, 2026", NOW)?.getUTCDate()).toBe(20);
+ });
+
+ it("rejects a date from the future", () => {
+ // The index misreporting, not a scoop.
+ expect(parseResultDate("Jan 1, 2030", NOW)).toBeNull();
+ });
+
+ it("has nothing to say when the index gives no date", () => {
+ expect(parseResultDate(null, NOW)).toBeNull();
+ expect(parseResultDate("sometime", NOW)).toBeNull();
+ });
+});
+
+describe("searching more than one platform", () => {
+ it("covers more than Reddit", () => {
+ // A pipeline that watches one community inherits its demographics as the
+ // entire market.
+ expect(INTENT_SOURCES.length).toBeGreaterThan(4);
+ expect(INTENT_SOURCES.some((s) => s.id === "reddit")).toBe(true);
+ expect(INTENT_SOURCES.some((s) => s.sites.some((x) => x.includes("linkedin")))).toBe(true);
+ });
+
+ it("restricts a search to its own sites", () => {
+ const q = buildIntentQuery({
+ source: INTENT_SOURCES[0],
+ topic: "load testing",
+ phrasing: '"anyone recommend"',
+ });
+ expect(q).toContain("site:reddit.com");
+ expect(q).toContain("load testing");
+ });
+});