From eed0c6ed26cfbe77283e2d4e6b36dc4ed0c1e00a Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 7 Oct 2026 05:37:20 +0000 Subject: [PATCH] List sources: parse real headlines, skip past events Tested against live Google News: most funding headlines do not start with the company ('Exclusive: Split Pay raises...', 'Egyptian fintech startup Paymob raises...', 'Latitude, founded by..., raises...'), so the parser now finds the verb and walks back past descriptors and appositives. The leadership query with ORs returned nothing; it is now ' company appoints new chief', and the parser takes Senior/Executive titles, ', Inc.' names, and cuts at 'and'. Board seats are not decision-makers. Event pages that name only past years (PlatformCon 2024 in 2026) are skipped. Co-Authored-By: Claude Opus 5.5 --- packages/domain/src/list-sources.test.ts | 64 +++++++++++ packages/domain/src/list-sources.ts | 136 ++++++++++++++++++++--- packages/pipeline/src/list-sources.ts | 7 +- 3 files changed, 191 insertions(+), 16 deletions(-) diff --git a/packages/domain/src/list-sources.test.ts b/packages/domain/src/list-sources.test.ts index 3c97cd5..59a3e50 100644 --- a/packages/domain/src/list-sources.test.ts +++ b/packages/domain/src/list-sources.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { isEventPeoplePage, + isPastEvent, marketTerms, parseFundingHeadline, parseLeadershipHeadline, @@ -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', @@ -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(); }); @@ -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); + }); +}); diff --git a/packages/domain/src/list-sources.ts b/packages/domain/src/list-sources.ts index 0aa26ff..7d18175 100644 --- a/packages/domain/src/list-sources.ts +++ b/packages/domain/src/list-sources.ts @@ -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 @@ -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() } : {}), }; } @@ -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); +} diff --git a/packages/pipeline/src/list-sources.ts b/packages/pipeline/src/list-sources.ts index f1ed07a..1401c5d 100644 --- a/packages/pipeline/src/list-sources.ts +++ b/packages/pipeline/src/list-sources.ts @@ -18,6 +18,7 @@ import { LIST_SOURCE_KINDS, LIST_SOURCE_MAX_ITEMS, isEventPeoplePage, + isPastEvent, listSourceQuery, marketTerms, newId, @@ -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 }); } }