From b6efaef80846d6304f6f6b8863488a77e474f0cf Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 28 Jul 2026 16:40:50 +0000 Subject: [PATCH] fix(leads): target buyers and decision makers, not people selling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things I got wrong in the intent sweep, both of which pointed it at the wrong population. Freelance and job boards were on the source list, on the reasoning that a job ad is approved budget. They are dense with the exact inverse of a lead: people advertising their own availability, in the same vocabulary, about the same topic, with the same enthusiasm. Nothing in the scorer told those apart from somebody buying, so "load testing consultant, available for hire" scored like a purchase. Those boards are gone, and self-promotion is now disqualifying wherever it appears — the phrasing turns up on every platform, not only the ones that specialise in it. What genuine demand those boards carry is demand for labour rather than for a supplier, so "hire an agency" and "hire a load testing engineer" have been separated. The first is a purchase; the second is employment, and only one of them is a buyer. Second, the scorer could not tell a buyer from a bystander with the same question. "I wish we had a better X" and "we are evaluating X this quarter" express the same want and only one comes from somebody who can sign. Language that marks purchasing authority — a stated role, plural first person, an evaluation under way, procurement vocabulary — now adds to the score, capped so it cannot outweigh the ask itself. A job title is not intent: someone who can sign but has asked for nothing still scores zero. Writing the tests exposed a real gap behind all of this. "We are evaluating vendors" is among the strongest buying signals there is and it scored nothing, because I had put it in the authority list where it had no tier to attach to. Being mid-evaluation is now a purchase signal on its own, as is a plainly stated team requirement. Co-Authored-By: Claude Opus 5 (1M context) --- app/(marketing)/lead-generation/page.tsx | 6 +- lib/outreach/intent.ts | 103 ++++++++++++++++++++++- lib/outreach/intentSources.ts | 29 +++++-- tests/intent.test.ts | 88 +++++++++++++++++++ 4 files changed, 213 insertions(+), 13 deletions(-) diff --git a/app/(marketing)/lead-generation/page.tsx b/app/(marketing)/lead-generation/page.tsx index 71416dd7..28a45667 100644 --- a/app/(marketing)/lead-generation/page.tsx +++ b/app/(marketing)/lead-generation/page.tsx @@ -105,12 +105,16 @@ export default function LeadGenerationPage() { /> + = [ { 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" }, + // Someone advertising their own services is the exact inverse of a lead, + // and reads almost identically to one: same vocabulary, same topic, same + // enthusiasm. Freelance and job boards are full of these, which is why the + // sweep no longer trawls them. + { pattern: /\b(?:available|open)\s+(?:for|to)\s+(?:hire|work|freelance|contract|new clients?|projects?)\b/i, reason: "selling their own services" }, + { pattern: /\b(?:hire|dm|message|contact)\s+me\b/i, reason: "selling their own services" }, + { pattern: /\b(?:my|our)\s+(?:rates?|portfolio|services|day rate)\b/i, reason: "selling their own services" }, + { pattern: /\b(?:i|we)\s+(?:offer|provide|specialis[ez]e in|do)\s+\w+\s+(?:services|work|consulting)\b/i, reason: "selling their own services" }, + { pattern: /\b(?:taking on|accepting)\s+(?:new\s+)?(?:clients?|work|projects?)\b/i, reason: "selling their own services" }, + { pattern: /\b(?:looking|searching)\s+for\s+(?:a\s+)?(?:job|gig|work|role|position|employment)\b/i, reason: "looking for work, not for a supplier" }, + { pattern: /\b(?:#)?(?:opentowork|forhire|hireme)\b/i, reason: "selling their own services" }, ]; +/** + * Language that means the speaker can actually authorise a purchase. + * + * The distinction the tiers alone miss: "I wish we had a better X" and "we are + * evaluating X this quarter" express the same want, and only one of them comes + * from somebody who can sign. Plural first person is the cheapest reliable + * marker — people speaking for an organisation say "we" — and an explicit + * title is the strongest. + */ +const AUTHORITY: Array<{ pattern: RegExp; weight: number; label: string }> = [ + { + pattern: /\b(?:i(?:'m| am)\s+(?:the\s+)?(?:cto|ceo|coo|cio|ciso|founder|co-?founder|owner|director|vp|head of|manager|lead))\b/i, + weight: 20, + label: "states a decision-making role", + }, + { + pattern: /\b(?:my|our)\s+(?:team|company|org(?:ani[sz]ation)?|firm|agency|startup|department)\b/i, + weight: 12, + label: "speaking for an organisation", + }, + { + pattern: /\b(?:we(?:'re| are)?\s+(?:evaluating|assessing|comparing|shortlisting|reviewing|procuring|rolling out|standardi[sz]ing))\b/i, + weight: 18, + label: "running a buying process", + }, + { + pattern: /\b(?:we|our team)\s+(?:need|want|require|use|are using|have decided|plan to)\b/i, + weight: 10, + label: "buying on behalf of a team", + }, + { + pattern: /\b(?:sign(?:ing)? off|approve[ds]?|procurement|purchase order|vendor review|security review)\b/i, + weight: 15, + label: "purchasing process language", + }, +]; + +/** How much authority language the text shows, capped. */ +export function authorityBonus(text: string): { bonus: number; reasons: string[] } { + let bonus = 0; + const reasons: string[] = []; + for (const a of AUTHORITY) { + if (a.pattern.test(text)) { + bonus += a.weight; + reasons.push(a.label); + } + } + // Capped: stacking every marker should not outweigh the fact of asking. + return { bonus: Math.min(30, bonus), reasons }; +} + export type IntentSignal = { /** 0–100, recency already applied. */ score: number; @@ -138,6 +215,12 @@ export type IntentSignal = { disqualified: string | null; /** Age in hours, or null when the source gave no timestamp. */ ageHours: number | null; + /** + * Whether the speaker showed signs of being able to authorise a purchase. + * False is not disqualifying — plenty of real buyers write tersely — but it + * is the difference between a lead and a bystander with the same question. + */ + decisionMaker: boolean; }; /** @@ -179,7 +262,14 @@ export function scoreIntent(input: { ? Math.max(0, (now.getTime() - input.postedAt.getTime()) / 3_600_000) : null; - const base: IntentSignal = { score: 0, tier: "none", reasons: [], disqualified: null, ageHours }; + const base: IntentSignal = { + score: 0, + tier: "none", + reasons: [], + disqualified: null, + ageHours, + decisionMaker: false, + }; if (!text.trim()) return { ...base, disqualified: "no text to read" }; for (const d of DISQUALIFIERS) { @@ -218,11 +308,17 @@ export function scoreIntent(input: { // 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); + // Authority is added to the ask, not multiplied by it. Someone who can sign + // but has not asked for anything is still not a lead — the bonus should + // promote a buyer above a bystander who asked the same question, not + // manufacture intent out of a job title. + const authority = authorityBonus(text); + const raw = Math.min(100, strongest.weight + extra + authority.bonus); const factor = recencyFactor(ageHours); const reasons = [...base.reasons, strongest.label]; for (const t of matched.slice(1)) reasons.push(t.label); + for (const r of authority.reasons) reasons.push(r); if (ageHours === null) { reasons.push("no date on the source, so treated as stale"); @@ -240,6 +336,7 @@ export function scoreIntent(input: { reasons, disqualified: factor === 0 ? `older than ${Math.round(MAX_AGE_HOURS / 24)} days` : null, ageHours, + decisionMaker: authority.bonus > 0, }; } diff --git a/lib/outreach/intentSources.ts b/lib/outreach/intentSources.ts index fbac300c..9af73e09 100644 --- a/lib/outreach/intentSources.ts +++ b/lib/outreach/intentSources.ts @@ -85,15 +85,23 @@ export const INTENT_SOURCES: IntentSource[] = [ 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, + id: "reviews", + label: "Software review and buyer sites", + // People comparing vendors in public are mid-purchase by definition, and + // the population skews to whoever was handed the evaluation. + sites: ["g2.com", "trustradius.com", "capterra.com", "spiceworks.com"], + defaultOn: true, }, ]; +// Freelance and job boards were on this list and have been removed. They are +// dense with the exact inverse of a lead: people advertising their own +// availability, in the same vocabulary and about the same topic as somebody +// buying. What little genuine demand they carry is demand for labour — a +// person to employ — rather than for a supplier to pay, and the target here is +// decision makers and buyers. + + /** * Search phrasings that find a request rather than an article about one. * @@ -101,10 +109,13 @@ export const INTENT_SOURCES: IntentSource[] = [ * talking. Each is combined with the campaign's own topic words. */ export const INTENT_PHRASINGS = [ + // Plural first person throughout wherever it reads naturally: people + // speaking for an organisation say "we", and that one word separates a + // buyer from a bystander with the same question. + '"we are evaluating" OR "we\'re evaluating"', '"anyone recommend"', - '"looking for a"', - '"any recommendations"', - '"willing to pay"', + '"we need a" OR "our team needs"', + '"willing to pay" OR "we have budget"', '"alternatives to"', '"what do you use"', ]; diff --git a/tests/intent.test.ts b/tests/intent.test.ts index df8e1bf3..01821a7d 100644 --- a/tests/intent.test.ts +++ b/tests/intent.test.ts @@ -181,3 +181,91 @@ describe("searching more than one platform", () => { expect(q).toContain("load testing"); }); }); + +describe("buyers and decision makers, not people selling", () => { + // The target is whoever can authorise a purchase. The inverse — somebody + // advertising their own availability — uses the same vocabulary about the + // same topic with the same enthusiasm, and scored identically until this. + it("refuses a freelancer advertising themselves", () => { + for (const text of [ + "Experienced load testing consultant, available for hire.", + "I do load testing work — DM me for rates.", + "Load testing specialist, open to work. My portfolio is here.", + "Taking on new clients for load testing this quarter.", + "#OpenToWork — load testing and performance engineering.", + ]) { + const s = scoreIntent({ text, postedAt: agoHours(1), keywords: kw, now: NOW }); + expect(s.disqualified).toBeTruthy(); + } + }); + + it("refuses someone looking for a job rather than a supplier", () => { + const s = score("Looking for a job doing load testing."); + expect(s.disqualified).toMatch(/looking for work/); + }); + + it("still accepts a company engaging a firm", () => { + // Buying a service, which is a purchase — unlike taking on an employee. + const s = score("We're looking to hire an agency for our load testing."); + expect(s.disqualified).toBeNull(); + expect(s.tier).toBe("purchase"); + }); + + it("does not treat employing a person as a purchase", () => { + const s = score("We're hiring a load testing engineer."); + expect(s.disqualified).toMatch(/job ad/); + }); +}); + +describe("authority separates a buyer from a bystander", () => { + it("ranks the person who can sign above the one who cannot", () => { + const buyer = score("We're evaluating load testing tools for our team this quarter."); + const bystander = score("Anyone know a good load testing tool?"); + expect(buyer.score).toBeGreaterThan(bystander.score); + expect(buyer.decisionMaker).toBe(true); + expect(bystander.decisionMaker).toBe(false); + }); + + it("recognises a stated role", () => { + const s = score("I'm the CTO and we need load testing. Any recommendations?"); + expect(s.decisionMaker).toBe(true); + expect(s.reasons.join(" ")).toMatch(/decision-making role/); + }); + + it("recognises procurement language", () => { + expect(score("Load testing vendor review — we need a shortlist.").decisionMaker).toBe(true); + }); + + it("does not manufacture intent from a job title alone", () => { + // Authority is added to an ask, never multiplied by it. Someone who can + // sign but has not asked for anything is not a lead. + const s = score("I'm the CTO of a company that does load testing."); + expect(s.tier).toBe("none"); + expect(s.score).toBe(0); + }); + + it("caps the bonus so authority cannot outweigh asking", () => { + const loaded = score( + "I'm the CTO, our team is evaluating, we need this, procurement approved — struggling with load testing.", + ); + const plainAsk = score("Willing to pay for load testing help."); + // A pile of authority markers on a mere complaint must not beat a real + // offer of money. + expect(loaded.score).toBeLessThanOrEqual(plainAsk.score); + }); +}); + +describe("where it looks for buyers", () => { + it("no longer trawls freelance marketplaces", () => { + // Dense with the inverse of a lead: people selling, not buying. + const sites = INTENT_SOURCES.flatMap((s) => s.sites); + for (const board of ["upwork.com", "remoteok.com", "weworkremotely.com"]) { + expect(sites).not.toContain(board); + } + }); + + it("watches sites where buyers compare vendors", () => { + const sites = INTENT_SOURCES.flatMap((s) => s.sites); + expect(sites.some((s) => ["g2.com", "trustradius.com", "capterra.com"].includes(s))).toBe(true); + }); +});