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