Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion app/(marketing)/lead-generation/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,16 @@ export default function LeadGenerationPage() {
/>
<Card
title="Everywhere people ask"
body="Reddit, Hacker News, Stack Exchange, Quora, Indie Hackers, X, LinkedIn and contract boards. One community's regulars are not your whole market."
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."
/>
<Card
title="Somebody else's lead is not yours"
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."
/>
<Card
title="Buyers, not people selling"
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."
/>
<Card
title="It respects a no"
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."
Expand Down
103 changes: 100 additions & 3 deletions lib/outreach/intent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,18 @@ const TIERS: TierSpec[] = [
/\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,
// Engaging a firm is a purchase; taking on a person is employment, and
// the two read alike. Only the first is a buyer.
/\b(?:hire|hiring|engage|retain|onboard)\s+(?:an?\s+)?(?:agency|vendor|supplier|firm|consultancy|consultants?|provider|partner)\b/i,
/\bpaid\s+(?:tool|service|plan|option|solution)\b/i,
/\bhappy\s+to\s+(?:pay|spend)\b/i,
// Mid-purchase by definition. Someone comparing vendors in public has
// already decided to buy something and is choosing between suppliers,
// which is later in the process than saying they are willing to pay.
/\b(?:evaluating|shortlisting|comparing|trialling|trialing|piloting)\s+(?:\w+\s+){0,2}(?:tools?|vendors?|suppliers?|options?|providers?|platforms?|solutions?)\b/i,
/\b(?:we(?:'re| are)?)\s+(?:evaluating|shortlisting|comparing|procuring)\b/i,
/\b(?:vendor|supplier|security|procurement)\s+review\b/i,
/\b(?:proof of concept|poc|bake[- ]off)\b/i,
],
},
{
Expand Down Expand Up @@ -92,6 +101,12 @@ const TIERS: TierSpec[] = [
/\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,
/\bany\s+recommendations?\b/i,
// "We need a X" is the plainest way a team states a requirement, and
// omitting it lost exactly the plural-first-person phrasing that marks
// somebody buying on an organisation's behalf.
/\b(?:we|our team|i)\s+(?:need|want|require)\s+(?:a|an|some|better|new)\b/i,
/\blooking\s+(?:to|at)\s+(?:replace|adopt|introduce|bring in)\b/i,
],
},
{
Expand Down Expand Up @@ -126,8 +141,70 @@ const DISQUALIFIERS: Array<{ pattern: RegExp; reason: string }> = [
{ 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;
Expand All @@ -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;
};

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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");
Expand All @@ -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,
};
}

Expand Down
29 changes: 20 additions & 9 deletions lib/outreach/intentSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,26 +85,37 @@ 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.
*
* 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 = [
// 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"',
];
Expand Down
88 changes: 88 additions & 0 deletions tests/intent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading