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
64 changes: 64 additions & 0 deletions packages/domain/src/list-sources.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
isEventPeoplePage,
isPastEvent,
marketTerms,
parseFundingHeadline,
parseLeadershipHeadline,
Expand All @@ -21,6 +22,27 @@ describe('parseFundingHeadline', () => {
expect(parseFundingHeadline(headline)).toEqual(expected);
});

test.each([
['Exclusive: Split Pay raises $125 million for rent and mortgage lending', 'Split Pay'],
['Egyptian fintech startup Paymob raises $35m pre-Series C funding round', 'Paymob'],
[
'Exclusive: Latitude, founded by Stripe and Uber alums, raises $35 million to turn stablecoins into local payments',
'Latitude',
],
['Piston raises $15M to expand cardless fuel payments network', 'Piston'],
[
'Pay-i rebrands as Ascerta and raises $18M to help enterprises track AI business value',
'Ascerta',
],
[
'Stablecoin payments firm dtcpay closes $25 million Series A with SBI Group investment',
'dtcpay',
],
['Payment startup Infini raises $6M seed round', 'Infini'],
])('real headline: %s', (headline, company) => {
expect(parseFundingHeadline(headline)?.company).toBe(company);
});

test.each([
'Why fintech startups raise less in 2026',
'The startup that raises $10M every year',
Expand Down Expand Up @@ -57,6 +79,33 @@ describe('parseLeadershipHeadline', () => {
});
});

test.each([
[
'Solana Foundation Appoints Rachel Conlan as Chief Strategy Officer and Jamal Raees as General Manager of Payments',
{ company: 'Solana Foundation', person: 'Rachel Conlan', title: 'Chief Strategy Officer' },
],
[
'BNB Chain Appoints Thomas Chen as Chief Business Officer',
{ company: 'BNB Chain', person: 'Thomas Chen', title: 'Chief Business Officer' },
],
[
'Usio, Inc. Appoints Linda Loof as Senior Vice President of Strategic Partnerships',
{
company: 'Usio, Inc.',
person: 'Linda Loof',
title: 'Senior Vice President of Strategic Partnerships',
},
],
])('real headline: %s', (headline, expected) => {
expect(parseLeadershipHeadline(headline)).toEqual(expected);
});

test('a board seat is not a new decision-maker', () => {
expect(
parseLeadershipHeadline('Flywire Appoints Sabrina Farmer to its Board of Directors'),
).toBeUndefined();
});

test('not an appointment', () => {
expect(parseLeadershipHeadline('Acme launches a new product line')).toBeUndefined();
});
Expand All @@ -76,3 +125,18 @@ describe('helpers', () => {
expect(isEventPeoplePage('https://www.eventbrite.com/e/speakers')).toBe(false);
});
});

describe('isPastEvent', () => {
test('a page naming only past years is a past edition', () => {
expect(
isPastEvent(
'https://2024.platformcon.com/speakers',
'Meet Our PlatformCon 2024 Speakers',
2026,
),
).toBe(true);
expect(isPastEvent('https://x.example/speakers', 'Future of AM 2027', 2026)).toBe(false);
expect(isPastEvent('https://x.example/2025-2026/speakers', 'Speakers', 2026)).toBe(false);
expect(isPastEvent('https://x.example/speakers', 'Speakers', 2026)).toBe(false);
});
});
136 changes: 121 additions & 15 deletions packages/domain/src/list-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,22 +81,21 @@ export interface LeadershipHeadline {
readonly title: string;
}

const COMPANY = String.raw`([A-Z][\w.&'’+-]*(?:\s+[A-Z0-9][\w.&'’+-]*){0,4})`;
const COMPANY = String.raw`([A-Z][\w.&'’+-]*(?:\s+[A-Z0-9][\w.&'’+-]*){0,4}(?:,\s+(?:Inc|LLC|Ltd|Corp)\.?)?)`;
/** A decision-maker's title, as a headline writes it. */
const ROLE = String.raw`((?:Senior\s+|Executive\s+|Global\s+|Group\s+)?(?:Chief|VP|Vice President|SVP|EVP|Head|Director|Executive Director|Managing Director|General Manager|President|CEO|CFO|CTO|CRO|CMO|COO|CPO)[\w\s,&-]{0,60})`;
const AMOUNT = String.raw`(\$|€|£)\s?\d[\d.,]*\s?(?:million|billion|[MBK])?`;

const FUNDING = new RegExp(
String.raw`^${COMPANY}\s+(?:raises|secures|closes|lands|nabs|bags|announces)\s+(?:an?\s+)?(${AMOUNT})?[^.]*?(pre-seed|seed|series\s+[a-h]\b|growth|funding|round)`,
'i',
);
const APPOINTS = new RegExp(
String.raw`^${COMPANY}\s+(?:appoints|names|hires|welcomes|taps|promotes)\s+(?:former\s+[\w\s-]+?\s+)?([A-Z][a-zA-Z'’.-]+(?:\s+[A-Z][a-zA-Z'’.-]+){1,3})\s+(?:as\s+(?:its\s+|new\s+)*|to\s+)?((?:Chief|VP|Vice President|SVP|EVP|Head|Director|General Manager|President|CEO|CFO|CTO|CRO|CMO|COO|CPO)[\w\s,&-]{0,60})`,
String.raw`^${COMPANY}\s+(?:appoints|names|hires|welcomes|taps|promotes)\s+(?:former\s+[\w\s-]+?\s+)?([A-Z][a-zA-Z'’.-]+(?:\s+[A-Z][a-zA-Z'’.-]+){1,3})\s+(?:as\s+(?:its\s+|new\s+)*|to\s+)?${ROLE}`,
);
const JOINS = new RegExp(
String.raw`^([A-Z][a-zA-Z'’.-]+(?:\s+[A-Z][a-zA-Z'’.-]+){1,3})\s+(?:joins|named|appointed)\s+${COMPANY}\s+as\s+((?:Chief|VP|Vice President|SVP|EVP|Head|Director|General Manager|President|CEO|CFO|CTO|CRO|CMO|COO|CPO)[\w\s,&-]{0,60})`,
String.raw`^([A-Z][a-zA-Z'’.-]+(?:\s+[A-Z][a-zA-Z'’.-]+){1,3})\s+(?:joins|named|appointed)\s+${COMPANY}\s+as\s+${ROLE}`,
);

/** Generic words that open a headline but are not a company. */
const NOT_A_COMPANY = /^(the|a|an|this|startup|report|exclusive|why|how|ai|fintech|new|top|meet)$/i;
const NOT_A_COMPANY =
/^(the|a|an|this|that|who|which|it|they|startup|report|exclusive|why|how|ai|fintech|new|top|meet|company|firm)$/i;

function cleanCompany(raw: string): string | undefined {
const name = raw
Expand All @@ -109,20 +108,117 @@ function cleanCompany(raw: string): string | undefined {

function cleanTitle(raw: string): string {
return raw
.split(/\s+(?:to|in|amid|after|as|following|for)\s+/i)[0]!
.split(/\s+(?:to|in|amid|after|as|following|for|and|ahead)\s+/i)[0]!
.replace(/[,\s-]+$/, '')
.trim();
}

/** Words that describe a company in a headline rather than name it. */
const DESCRIPTORS = new Set([
'startup',
'start-up',
'firm',
'company',
'platform',
'provider',
'maker',
'fintech',
'insurtech',
'proptech',
'healthtech',
'edtech',
'payments',
'payment',
'stablecoin',
'ai',
'saas',
'software',
'unicorn',
'scaleup',
'developer',
'egyptian',
'indian',
'european',
'british',
'german',
'french',
'estonian',
'nigerian',
'israeli',
'canadian',
'us',
'u.s.',
'based',
'backed',
'and',
'the',
'a',
'an',
'its',
'of',
'for',
'to',
'in',
'as',
'by',
'with',
'rebrands',
'exclusive',
'breaking',
'report',
'mena',
'cybersecurity',
'security',
'data',
'cloud',
'crypto',
'web3',
'b2b',
'logistics',
'climate',
'robotics',
'biotech',
'legal',
]);

const FUNDING_VERB =
/\s(raises|raised|secures|secured|closes|closed|lands|nabs|bags|gets|announces)\s/i;
const ROUND = /(pre-seed|seed|series\s+[a-h]\b|growth round|funding|round|\$|€|£)/i;

export function parseFundingHeadline(title: string): FundingHeadline | undefined {
const match = FUNDING.exec(title.trim());
if (!match) return undefined;
const company = cleanCompany(match[1] ?? '');
if (!company) return undefined;
// "Exclusive: Split Pay raises..." / "Startup wrap — ..." prefixes go.
const text = title.trim().replace(/^[\w\s]{1,20}:\s+/, '');
const verb = FUNDING_VERB.exec(text);
if (!verb || verb.index === undefined) return undefined;
const after = text.slice(verb.index + verb[0].length);
if (!ROUND.test(after.slice(0, 80))) return undefined;

// The company is the words just before the verb, read backwards until a
// descriptor ("fintech startup"), and with an appositive dropped: in
// "Latitude, founded by Stripe alums, raises" the subject is before the
// first comma.
let before = text.slice(0, verb.index).trim();
if (before.includes(',')) before = before.split(',')[0]!.trim();
const words = before.split(/\s+/);
// "Pay-i rebrands as Ascerta and raises": the conjunction is not the name.
while (words.length > 0 && /^(and|which|who|that|now)$/i.test(words[words.length - 1]!)) {
words.pop();
}
const name: string[] = [];
for (let i = words.length - 1; i >= 0 && name.length < 4; i -= 1) {
const word = words[i]!.replace(/[’']s$/i, '');
if (DESCRIPTORS.has(word.toLowerCase())) break;
name.unshift(word);
}
const company = cleanCompany(name.join(' '));
if (!company || company.length < 2) return undefined;

const amount = new RegExp(`^(?:an?\\s+)?(${AMOUNT})`, 'i').exec(after)?.[1];
const round = /(pre-seed|seed|series\s+[a-h]\b)/i.exec(after)?.[1];
return {
company,
...(match[2] ? { amount: match[2].replace(/\s+/g, ' ').trim() } : {}),
...(match[4] ? { round: match[4].replace(/\s+/g, ' ').trim() } : {}),
...(amount ? { amount: amount.replace(/\s+/g, ' ').trim() } : {}),
...(round ? { round: round.replace(/\s+/g, ' ').trim() } : {}),
};
}

Expand Down Expand Up @@ -163,3 +259,13 @@ export function isEventPeoplePage(url: string): boolean {
return false;
}
}

/**
* True when a page is about a past edition: every year it names (in the URL
* or title) is before `year`. "PlatformCon 2024 speakers" is not a 2026
* contact list; a page naming no year at all is taken.
*/
export function isPastEvent(url: string, title: string, year: number): boolean {
const years = `${url} ${title}`.match(/\b20\d{2}\b/g)?.map(Number) ?? [];
return years.length > 0 && years.every((found) => found < year);
}
7 changes: 6 additions & 1 deletion packages/pipeline/src/list-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
LIST_SOURCE_KINDS,
LIST_SOURCE_MAX_ITEMS,
isEventPeoplePage,
isPastEvent,
listSourceQuery,
marketTerms,
newId,
Expand Down Expand Up @@ -174,7 +175,11 @@ async function scanOne(

if (kind === 'event') {
for (const result of await searcher.search(query, { num: 20 })) {
if (result.link && isEventPeoplePage(result.link)) {
if (
result.link &&
isEventPeoplePage(result.link) &&
!isPastEvent(result.link, result.title ?? '', at.getUTCFullYear())
) {
found.push({ url: result.link, title: result.title ?? result.link });
}
}
Expand Down
Loading