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