SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
7.8 KB · 158 lines typescript
Raw Blame History
1/**2 * Helpers for the enrichment profile (Wikidata / Wikipedia / homepage / registries): source labels, description3 * attribution, socials, corporate-structure grouping, leadership merge. Server- and client-safe (no React).4 */5import type { CompanyCard, CompanyProfile, CompanySocials, Person, ProfileSource, Relationship } from './types';67/** Human label for a provenance source id (`wikidata`, `sec_edgar`, `page`, …). Unknown ids are humanised, never hidden. */8export const SOURCE_LABELS: Record<string, string> = {9  wikidata: 'Wikidata',10  wikipedia: 'Wikipedia',11  homepage: 'Website',12  website: 'Website',13  page: 'Leadership page',14  leadership_page: 'Leadership page',15  llm: 'LLM',16  sec: 'SEC EDGAR',17  sec_edgar: 'SEC EDGAR',18  edgar: 'SEC EDGAR',19  gleif: 'GLEIF',20  registry: 'Registry',21  opencorporates: 'OpenCorporates',22  crunchbase: 'Crunchbase',23  observed: 'Observed',24};25export function sourceLabel(source: string | null | undefined): string {26  if (!source) return 'Unknown source';27  const k = source.toLowerCase();28  if (SOURCE_LABELS[k]) return SOURCE_LABELS[k];29  const w = k.replace(/[_-]+/g, ' ').trim();30  return w.charAt(0).toUpperCase() + w.slice(1);31}3233/** Description + attribution: profile first, then the registry description (unattributed → no attribution line). */34export function descriptionOf(c: CompanyCard): { text: string; source: CompanyProfile['description_source'] | null; url: string | null; license: string | null } | null {35  const p = c.profile;36  if (p?.description) return { text: p.description, source: p.description_source, url: p.description_url, license: p.description_license };37  if (c.description) return { text: c.description, source: null, url: null, license: null };38  return null;39}4041/** Best available logo candidates, in order (profile logo → profile icon → legacy `logo_url`). */42export function logoCandidates(c: { logo_url?: string | null; profile?: CompanyProfile | null }): string[] {43  const out: string[] = [];44  for (const u of [c.profile?.logo_url, c.profile?.icon_url, c.logo_url]) if (u && !out.includes(u)) out.push(u);45  return out;46}4748/** 1–2 letter monogram for the fallback plate ("Dassault Systèmes" → "DS", "Arm" → "A"). */49export function monogram(name: string): string {50  const words = name51    .replace(/[^\p{L}\p{N} ]+/gu, ' ')52    .split(/\s+/)53    .filter(Boolean);54  if (!words.length) return '?';55  if (words.length === 1) return words[0]!.slice(0, 1).toUpperCase();56  return `${words[0]!.charAt(0)}${words[1]!.charAt(0)}`.toUpperCase();57}5859export const SOCIAL_LABELS: Record<keyof CompanySocials, string> = { linkedin: 'LinkedIn', x: 'X', youtube: 'YouTube', facebook: 'Facebook', instagram: 'Instagram', github: 'GitHub', tiktok: 'TikTok', crunchbase: 'Crunchbase' };60const SOCIAL_ORDER: (keyof CompanySocials)[] = ['linkedin', 'x', 'youtube', 'github', 'instagram', 'facebook', 'tiktok', 'crunchbase'];61export function socialsOf(p: CompanyProfile | null | undefined): { key: keyof CompanySocials; label: string; url: string }[] {62  if (!p?.socials) return [];63  return SOCIAL_ORDER.filter((k) => typeof p.socials[k] === 'string' && /^https?:\/\//i.test(p.socials[k] as string)).map((k) => ({ key: k, label: SOCIAL_LABELS[k], url: p.socials[k] as string }));64}6566/** Per-field provenance lookup (`sources[]` is a flat list keyed by `field`). */67export function sourceFor(p: CompanyProfile | null | undefined, ...fields: string[]): ProfileSource | null {68  if (!p?.sources?.length) return null;69  for (const f of fields) {70    const s = p.sources.find((x) => x.field === f);71    if (s) return s;72  }73  return null;74}7576/** Wikidata-sourced people first by role weight (CEO → chair → founders → other executives), then page-observed, then name. */77const ROLE_RANK: [RegExp, number][] = [78  [/\b(chief executive|ceo|managing director|président-directeur|directeur général)\b/i, 0],79  [/\b(executive chair|chair(man|woman|person)?|chair of the board|président du conseil)\b/i, 1],80  [/\b(co-?founder|founder|fondateur|fondatrice)\b/i, 2],81  [/\b(president|chief \w+ officer|c[a-z]o\b|general counsel)\b/i, 3],82  [/\b(vp|vice president|head of|director)\b/i, 4],83];84export function roleRank(title: string | null | undefined): number {85  if (!title) return 6;86  for (const [re, r] of ROLE_RANK) if (re.test(title)) return r;87  return 5;88}8990export type MergedPerson = Person & { sources: ('wikidata' | 'page')[] };91/** Merge page-observed and Wikidata-sourced people by normalised name; page metadata wins, both source chips are kept. */92export function mergePeople(list: Person[]): MergedPerson[] {93  const byKey = new Map<string, MergedPerson>();94  for (const p of list) {95    const src = p.source === 'wikidata' ? 'wikidata' : 'page';96    const key = p.name97      .normalize('NFD')98      .replace(/[\u0300-\u036f]/g, '')99      .toLowerCase()100      .replace(/[^a-z0-9]+/g, ' ')101      .trim();102    const cur = byKey.get(key);103    if (!cur) {104      byKey.set(key, { ...p, sources: [src] });105      continue;106    }107    if (!cur.sources.includes(src)) cur.sources.push(src);108    // prefer the page row's identity/title (observed first-party), keep the richer title otherwise109    if (src === 'page' && cur.source === 'wikidata') Object.assign(cur, { ...p, sources: cur.sources });110    else if (!cur.title && p.title) cur.title = p.title;111    cur.is_executive = cur.is_executive || p.is_executive;112  }113  return [...byKey.values()].sort((a, b) => {114    const ra = roleRank(a.title);115    const rb = roleRank(b.title);116    if (ra !== rb) return ra - rb;117    if (a.is_executive !== b.is_executive) return a.is_executive ? -1 : 1;118    return a.name.localeCompare(b.name);119  });120}121122/** Corporate-structure groups, in display order. `PARENT_OF X` means X is a subsidiary of this company. */123export const STRUCTURE_GROUPS: { id: string; kinds: string[]; label: string; hint: string }[] = [124  { id: 'parent', kinds: ['SUBSIDIARY_OF', 'PARENT'], label: 'Parent', hint: 'This company is recorded as a subsidiary of' },125  { id: 'owners', kinds: ['OWNED_BY'], label: 'Owners', hint: 'Recorded owners / significant shareholders' },126  { id: 'acquired_by', kinds: ['ACQUIRED_BY'], label: 'Acquired by', hint: 'Recorded acquirer' },127  { id: 'subsidiaries', kinds: ['PARENT_OF', 'SUBSIDIARY'], label: 'Subsidiaries', hint: 'Companies recorded as subsidiaries' },128  { id: 'owns', kinds: ['OWNER_OF'], label: 'Owns', hint: 'Recorded holdings' },129  { id: 'acquisitions', kinds: ['ACQUIRED', 'ACQUIRER_OF'], label: 'Acquisitions', hint: 'Companies recorded as acquired' },130];131export function groupRelationships(rels: Relationship[]): { group: (typeof STRUCTURE_GROUPS)[number] | { id: 'other'; label: string; hint: string; kinds: string[] }; rows: Relationship[] }[] {132  const used = new Set<Relationship>();133  const out: { group: (typeof STRUCTURE_GROUPS)[number] | { id: 'other'; label: string; hint: string; kinds: string[] }; rows: Relationship[] }[] = [];134  for (const g of STRUCTURE_GROUPS) {135    const rows = rels.filter((r) => g.kinds.includes((r.kind ?? '').toUpperCase()));136    if (rows.length) {137      rows.forEach((r) => used.add(r));138      out.push({ group: g, rows: sortRel(rows) });139    }140  }141  const other = rels.filter((r) => !used.has(r));142  if (other.length) out.push({ group: { id: 'other', label: 'Other relationships', hint: 'Competitors, partners and other recorded links', kinds: [] }, rows: sortRel(other) });143  return out;144}145function sortRel(rows: Relationship[]): Relationship[] {146  // current (no valid_to) first, then most recent, then confidence147  return [...rows].sort((a, b) => {148    const ea = a.valid_to ? 1 : 0;149    const eb = b.valid_to ? 1 : 0;150    if (ea !== eb) return ea - eb;151    if ((a.valid_from ?? '') !== (b.valid_from ?? '')) return (b.valid_from ?? '').localeCompare(a.valid_from ?? '');152    return b.confidence - a.confidence;153  });154}155export function relationshipName(r: Relationship): string {156  return r.company?.display_name ?? r.to_name ?? 'Unnamed counterpart';157}158