Skip to content

Commit 17f6e21

Browse files
ralyodioclaude
andauthored
fix(leads): target buyers and decision makers, not people selling (#158)
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) <noreply@anthropic.com>
1 parent e03ab79 commit 17f6e21

4 files changed

Lines changed: 213 additions & 13 deletions

File tree

‎app/(marketing)/lead-generation/page.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,16 @@ export default function LeadGenerationPage() {
105105
/>
106106
<Card
107107
title="Everywhere people ask"
108-
body="Reddit, Hacker News, Stack Exchange, Quora, Indie Hackers, X, LinkedIn and contract boards. One community's regulars are not your whole market."
108+
body="Reddit, Hacker News, Stack Exchange, Quora, Indie Hackers, X, LinkedIn and the software review sites where people compare vendors in public. One community's regulars are not your whole market."
109109
/>
110110
<Card
111111
title="Somebody else's lead is not yours"
112112
body="A flawless “anyone recommend a good accountant, happy to pay” is worth nothing if you sell load testing, so intent is only counted when it lands on your topic."
113113
/>
114+
<Card
115+
title="Buyers, not people selling"
116+
body="Someone advertising their own availability uses the same words about the same topic as someone buying. Freelance boards and “open to work” posts are dropped, and language that means the writer can actually authorise a purchase ranks them up."
117+
/>
114118
<Card
115119
title="It respects a no"
116120
body="“No vendors please”, “don't DM me”, free-only and job ads are dropped outright, whatever else they score. The fastest way to lose a channel is to answer the person who said not to."

‎lib/outreach/intent.ts‎

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,18 @@ const TIERS: TierSpec[] = [
5858
/\b(?:request for proposal|rfp|rfq)\b/i,
5959
/\b(?:get|need|send|want)\s+(?:a\s+)?(?:quote|quotes|pricing)\b/i,
6060
/\bwho\s+(?:do|should)\s+(?:i|we)\s+pay\b/i,
61-
/\b(?:hire|hiring|contract|engage)\s+(?:someone|a\s+\w+|an\s+agency|a\s+freelancer)\b/i,
61+
// Engaging a firm is a purchase; taking on a person is employment, and
62+
// the two read alike. Only the first is a buyer.
63+
/\b(?:hire|hiring|engage|retain|onboard)\s+(?:an?\s+)?(?:agency|vendor|supplier|firm|consultancy|consultants?|provider|partner)\b/i,
6264
/\bpaid\s+(?:tool|service|plan|option|solution)\b/i,
6365
/\bhappy\s+to\s+(?:pay|spend)\b/i,
66+
// Mid-purchase by definition. Someone comparing vendors in public has
67+
// already decided to buy something and is choosing between suppliers,
68+
// which is later in the process than saying they are willing to pay.
69+
/\b(?:evaluating|shortlisting|comparing|trialling|trialing|piloting)\s+(?:\w+\s+){0,2}(?:tools?|vendors?|suppliers?|options?|providers?|platforms?|solutions?)\b/i,
70+
/\b(?:we(?:'re| are)?)\s+(?:evaluating|shortlisting|comparing|procuring)\b/i,
71+
/\b(?:vendor|supplier|security|procurement)\s+review\b/i,
72+
/\b(?:proof of concept|poc|bake[- ]off)\b/i,
6473
],
6574
},
6675
{
@@ -92,6 +101,12 @@ const TIERS: TierSpec[] = [
92101
/\bwhich\s+(?:tool|service|vendor|provider|agency|platform)\b/i,
93102
/\bwhat\s+(?:do|are)\s+you\s+(?:all\s+)?(?:use|using)\b/i,
94103
/\bany\s+(?:tools?|services?|vendors?|agencies)\s+that\b/i,
104+
/\bany\s+recommendations?\b/i,
105+
// "We need a X" is the plainest way a team states a requirement, and
106+
// omitting it lost exactly the plural-first-person phrasing that marks
107+
// somebody buying on an organisation's behalf.
108+
/\b(?:we|our team|i)\s+(?:need|want|require)\s+(?:a|an|some|better|new)\b/i,
109+
/\blooking\s+(?:to|at)\s+(?:replace|adopt|introduce|bring in)\b/i,
95110
],
96111
},
97112
{
@@ -126,8 +141,70 @@ const DISQUALIFIERS: Array<{ pattern: RegExp; reason: string }> = [
126141
{ pattern: /\bno\s+paid\b/i, reason: "free/open-source only" },
127142
{ pattern: /\bthis\s+is\s+(?:an?\s+)?(?:ad|advert|sponsored)\b/i, reason: "the post is itself an advert" },
128143
{ pattern: /\bwe(?:'re| are)\s+hiring\b/i, reason: "a job ad, not a buyer" },
144+
// Someone advertising their own services is the exact inverse of a lead,
145+
// and reads almost identically to one: same vocabulary, same topic, same
146+
// enthusiasm. Freelance and job boards are full of these, which is why the
147+
// sweep no longer trawls them.
148+
{ pattern: /\b(?:available|open)\s+(?:for|to)\s+(?:hire|work|freelance|contract|new clients?|projects?)\b/i, reason: "selling their own services" },
149+
{ pattern: /\b(?:hire|dm|message|contact)\s+me\b/i, reason: "selling their own services" },
150+
{ pattern: /\b(?:my|our)\s+(?:rates?|portfolio|services|day rate)\b/i, reason: "selling their own services" },
151+
{ 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" },
152+
{ pattern: /\b(?:taking on|accepting)\s+(?:new\s+)?(?:clients?|work|projects?)\b/i, reason: "selling their own services" },
153+
{ 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" },
154+
{ pattern: /\b(?:#)?(?:opentowork|forhire|hireme)\b/i, reason: "selling their own services" },
129155
];
130156

157+
/**
158+
* Language that means the speaker can actually authorise a purchase.
159+
*
160+
* The distinction the tiers alone miss: "I wish we had a better X" and "we are
161+
* evaluating X this quarter" express the same want, and only one of them comes
162+
* from somebody who can sign. Plural first person is the cheapest reliable
163+
* marker — people speaking for an organisation say "we" — and an explicit
164+
* title is the strongest.
165+
*/
166+
const AUTHORITY: Array<{ pattern: RegExp; weight: number; label: string }> = [
167+
{
168+
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,
169+
weight: 20,
170+
label: "states a decision-making role",
171+
},
172+
{
173+
pattern: /\b(?:my|our)\s+(?:team|company|org(?:ani[sz]ation)?|firm|agency|startup|department)\b/i,
174+
weight: 12,
175+
label: "speaking for an organisation",
176+
},
177+
{
178+
pattern: /\b(?:we(?:'re| are)?\s+(?:evaluating|assessing|comparing|shortlisting|reviewing|procuring|rolling out|standardi[sz]ing))\b/i,
179+
weight: 18,
180+
label: "running a buying process",
181+
},
182+
{
183+
pattern: /\b(?:we|our team)\s+(?:need|want|require|use|are using|have decided|plan to)\b/i,
184+
weight: 10,
185+
label: "buying on behalf of a team",
186+
},
187+
{
188+
pattern: /\b(?:sign(?:ing)? off|approve[ds]?|procurement|purchase order|vendor review|security review)\b/i,
189+
weight: 15,
190+
label: "purchasing process language",
191+
},
192+
];
193+
194+
/** How much authority language the text shows, capped. */
195+
export function authorityBonus(text: string): { bonus: number; reasons: string[] } {
196+
let bonus = 0;
197+
const reasons: string[] = [];
198+
for (const a of AUTHORITY) {
199+
if (a.pattern.test(text)) {
200+
bonus += a.weight;
201+
reasons.push(a.label);
202+
}
203+
}
204+
// Capped: stacking every marker should not outweigh the fact of asking.
205+
return { bonus: Math.min(30, bonus), reasons };
206+
}
207+
131208
export type IntentSignal = {
132209
/** 0–100, recency already applied. */
133210
score: number;
@@ -138,6 +215,12 @@ export type IntentSignal = {
138215
disqualified: string | null;
139216
/** Age in hours, or null when the source gave no timestamp. */
140217
ageHours: number | null;
218+
/**
219+
* Whether the speaker showed signs of being able to authorise a purchase.
220+
* False is not disqualifying — plenty of real buyers write tersely — but it
221+
* is the difference between a lead and a bystander with the same question.
222+
*/
223+
decisionMaker: boolean;
141224
};
142225

143226
/**
@@ -179,7 +262,14 @@ export function scoreIntent(input: {
179262
? Math.max(0, (now.getTime() - input.postedAt.getTime()) / 3_600_000)
180263
: null;
181264

182-
const base: IntentSignal = { score: 0, tier: "none", reasons: [], disqualified: null, ageHours };
265+
const base: IntentSignal = {
266+
score: 0,
267+
tier: "none",
268+
reasons: [],
269+
disqualified: null,
270+
ageHours,
271+
decisionMaker: false,
272+
};
183273
if (!text.trim()) return { ...base, disqualified: "no text to read" };
184274

185275
for (const d of DISQUALIFIERS) {
@@ -218,11 +308,17 @@ export function scoreIntent(input: {
218308
// a better prospect than someone doing either alone — but not twice as good.
219309
const strongest = matched[0];
220310
const extra = matched.slice(1).reduce((n, t) => n + t.weight * 0.08, 0);
221-
const raw = Math.min(100, strongest.weight + extra);
311+
// Authority is added to the ask, not multiplied by it. Someone who can sign
312+
// but has not asked for anything is still not a lead — the bonus should
313+
// promote a buyer above a bystander who asked the same question, not
314+
// manufacture intent out of a job title.
315+
const authority = authorityBonus(text);
316+
const raw = Math.min(100, strongest.weight + extra + authority.bonus);
222317

223318
const factor = recencyFactor(ageHours);
224319
const reasons = [...base.reasons, strongest.label];
225320
for (const t of matched.slice(1)) reasons.push(t.label);
321+
for (const r of authority.reasons) reasons.push(r);
226322

227323
if (ageHours === null) {
228324
reasons.push("no date on the source, so treated as stale");
@@ -240,6 +336,7 @@ export function scoreIntent(input: {
240336
reasons,
241337
disqualified: factor === 0 ? `older than ${Math.round(MAX_AGE_HOURS / 24)} days` : null,
242338
ageHours,
339+
decisionMaker: authority.bonus > 0,
243340
};
244341
}
245342

‎lib/outreach/intentSources.ts‎

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,26 +85,37 @@ export const INTENT_SOURCES: IntentSource[] = [
8585
defaultOn: false,
8686
},
8787
{
88-
id: "jobs",
89-
label: "Job and contract boards",
90-
// A job ad for the thing you sell is a budget that has already been
91-
// approved — it is intent wearing different clothes.
92-
sites: ["upwork.com", "weworkremotely.com", "remoteok.com"],
93-
defaultOn: false,
88+
id: "reviews",
89+
label: "Software review and buyer sites",
90+
// People comparing vendors in public are mid-purchase by definition, and
91+
// the population skews to whoever was handed the evaluation.
92+
sites: ["g2.com", "trustradius.com", "capterra.com", "spiceworks.com"],
93+
defaultOn: true,
9494
},
9595
];
9696

97+
// Freelance and job boards were on this list and have been removed. They are
98+
// dense with the exact inverse of a lead: people advertising their own
99+
// availability, in the same vocabulary and about the same topic as somebody
100+
// buying. What little genuine demand they carry is demand for labour — a
101+
// person to employ — rather than for a supplier to pay, and the target here is
102+
// decision makers and buyers.
103+
104+
97105
/**
98106
* Search phrasings that find a request rather than an article about one.
99107
*
100108
* A bare topic search returns listicles and vendor pages; these return people
101109
* talking. Each is combined with the campaign's own topic words.
102110
*/
103111
export const INTENT_PHRASINGS = [
112+
// Plural first person throughout wherever it reads naturally: people
113+
// speaking for an organisation say "we", and that one word separates a
114+
// buyer from a bystander with the same question.
115+
'"we are evaluating" OR "we\'re evaluating"',
104116
'"anyone recommend"',
105-
'"looking for a"',
106-
'"any recommendations"',
107-
'"willing to pay"',
117+
'"we need a" OR "our team needs"',
118+
'"willing to pay" OR "we have budget"',
108119
'"alternatives to"',
109120
'"what do you use"',
110121
];

‎tests/intent.test.ts‎

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,91 @@ describe("searching more than one platform", () => {
181181
expect(q).toContain("load testing");
182182
});
183183
});
184+
185+
describe("buyers and decision makers, not people selling", () => {
186+
// The target is whoever can authorise a purchase. The inverse — somebody
187+
// advertising their own availability — uses the same vocabulary about the
188+
// same topic with the same enthusiasm, and scored identically until this.
189+
it("refuses a freelancer advertising themselves", () => {
190+
for (const text of [
191+
"Experienced load testing consultant, available for hire.",
192+
"I do load testing work — DM me for rates.",
193+
"Load testing specialist, open to work. My portfolio is here.",
194+
"Taking on new clients for load testing this quarter.",
195+
"#OpenToWork — load testing and performance engineering.",
196+
]) {
197+
const s = scoreIntent({ text, postedAt: agoHours(1), keywords: kw, now: NOW });
198+
expect(s.disqualified).toBeTruthy();
199+
}
200+
});
201+
202+
it("refuses someone looking for a job rather than a supplier", () => {
203+
const s = score("Looking for a job doing load testing.");
204+
expect(s.disqualified).toMatch(/looking for work/);
205+
});
206+
207+
it("still accepts a company engaging a firm", () => {
208+
// Buying a service, which is a purchase — unlike taking on an employee.
209+
const s = score("We're looking to hire an agency for our load testing.");
210+
expect(s.disqualified).toBeNull();
211+
expect(s.tier).toBe("purchase");
212+
});
213+
214+
it("does not treat employing a person as a purchase", () => {
215+
const s = score("We're hiring a load testing engineer.");
216+
expect(s.disqualified).toMatch(/job ad/);
217+
});
218+
});
219+
220+
describe("authority separates a buyer from a bystander", () => {
221+
it("ranks the person who can sign above the one who cannot", () => {
222+
const buyer = score("We're evaluating load testing tools for our team this quarter.");
223+
const bystander = score("Anyone know a good load testing tool?");
224+
expect(buyer.score).toBeGreaterThan(bystander.score);
225+
expect(buyer.decisionMaker).toBe(true);
226+
expect(bystander.decisionMaker).toBe(false);
227+
});
228+
229+
it("recognises a stated role", () => {
230+
const s = score("I'm the CTO and we need load testing. Any recommendations?");
231+
expect(s.decisionMaker).toBe(true);
232+
expect(s.reasons.join(" ")).toMatch(/decision-making role/);
233+
});
234+
235+
it("recognises procurement language", () => {
236+
expect(score("Load testing vendor review — we need a shortlist.").decisionMaker).toBe(true);
237+
});
238+
239+
it("does not manufacture intent from a job title alone", () => {
240+
// Authority is added to an ask, never multiplied by it. Someone who can
241+
// sign but has not asked for anything is not a lead.
242+
const s = score("I'm the CTO of a company that does load testing.");
243+
expect(s.tier).toBe("none");
244+
expect(s.score).toBe(0);
245+
});
246+
247+
it("caps the bonus so authority cannot outweigh asking", () => {
248+
const loaded = score(
249+
"I'm the CTO, our team is evaluating, we need this, procurement approved — struggling with load testing.",
250+
);
251+
const plainAsk = score("Willing to pay for load testing help.");
252+
// A pile of authority markers on a mere complaint must not beat a real
253+
// offer of money.
254+
expect(loaded.score).toBeLessThanOrEqual(plainAsk.score);
255+
});
256+
});
257+
258+
describe("where it looks for buyers", () => {
259+
it("no longer trawls freelance marketplaces", () => {
260+
// Dense with the inverse of a lead: people selling, not buying.
261+
const sites = INTENT_SOURCES.flatMap((s) => s.sites);
262+
for (const board of ["upwork.com", "remoteok.com", "weworkremotely.com"]) {
263+
expect(sites).not.toContain(board);
264+
}
265+
});
266+
267+
it("watches sites where buyers compare vendors", () => {
268+
const sites = INTENT_SOURCES.flatMap((s) => s.sites);
269+
expect(sites.some((s) => ["g2.com", "trustradius.com", "capterra.com"].includes(s))).toBe(true);
270+
});
271+
});

0 commit comments

Comments
 (0)