From 7d927ab2374578537d8eeb1e72d829790f894f63 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 16:11:45 +0000 Subject: [PATCH] feat: retain public profile photos during enrichment --- .env.example | 5 +- README.md | 16 ++ apps/server/src/index.ts | 7 +- apps/web/components/avatar.tsx | 8 +- packages/pipeline/src/crawl.test.ts | 21 ++- packages/pipeline/src/photos.test.ts | 25 ++- packages/pipeline/src/photos.ts | 48 +++++- packages/pipeline/src/pipeline.test.ts | 76 ++++++++- packages/pipeline/src/pipeline.ts | 8 + packages/providers/src/fan-out.test.ts | 46 ++++++ packages/providers/src/fan-out.ts | 28 +++- packages/providers/src/github/client.ts | 1 + packages/providers/src/github/github.test.ts | 5 + packages/providers/src/github/provider.ts | 3 + packages/providers/src/index.ts | 3 + packages/providers/src/photo.ts | 23 +++ packages/providers/src/provider.ts | 7 + packages/providers/src/site/photos.test.ts | 162 +++++++++++++++++++ packages/providers/src/site/photos.ts | 112 +++++++++++++ packages/providers/src/site/provider.ts | 21 +++ 20 files changed, 610 insertions(+), 15 deletions(-) create mode 100644 packages/providers/src/fan-out.test.ts create mode 100644 packages/providers/src/photo.ts create mode 100644 packages/providers/src/site/photos.test.ts create mode 100644 packages/providers/src/site/photos.ts diff --git a/.env.example b/.env.example index b109542..f2ac417 100644 --- a/.env.example +++ b/.env.example @@ -36,8 +36,9 @@ PDL_API_KEY= GITHUB_TOKEN= # Finds a lead's photo (and their LinkedIn profile URL, as research) by -# searching their name, title and company. One image search per person, -# roughly a quarter of a cent each. Unset, only Gravatar supplies pictures. +# searching their name, title and company. One paid image search per person. +# Optional: crawls, GitHub, social profiles and Gravatar also supply pictures +# without image-search credits. LinkedIn image search requires this key. VALUESERP_API_KEY= # Ceiling on photo lookups per workspace per day. Default 300. PHOTO_LOOKUPS_PER_DAY= diff --git a/README.md b/README.md index 78eafe1..ccc030f 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,22 @@ typically yields three linked identities before any paid provider is touched. Each stage persists before the next runs, so a crash resumes rather than restarting, and a half-enriched prospect is still inspectable. +## Public profile photos + +Prospects, approval cards and the daily digest show a photo when a public source +supplies one. Crawls retain JSON-LD `Person.image` portraits and team images whose +alt text names the person, including lazy-loaded images and relative URLs. GitHub +enrichment keeps the public account's avatar. Photos are stored with the source +page in field provenance; existing photos are kept, and a later crawl or enrichment +can fill an empty photo even after image search previously missed. + +These paths use the pages and public API responses already being read. Gravatar +and social profile intake remain available, and `VALUESERP_API_KEY` optionally +enables the bounded image-search fallback for LinkedIn and company team pages. +Nothing signs into LinkedIn or bypasses a blocked page. Missing or unavailable +photos show initials. Previously imported people gain these photos on their next +crawl or enrichment; this change does not run a production backfill. + ## The three ideas worth knowing **The policy engine is arithmetic, not judgement.** Every outbound action diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 13bff0b..e144805 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -247,8 +247,8 @@ if (!encryptionKey) { /** * Pictures of leads, by search. * - * Optional, like every provider: unset, Gravatar is the only source and most - * people stay as initials. Each lookup is a paid request, so the sweep is + * Optional: crawls, public profile enrichment and Gravatar also supply photos. + * Each search lookup is a paid request, so the sweep is * capped per workspace per day as well as per tick. */ const photoFinder = process.env.VALUESERP_API_KEY @@ -257,7 +257,8 @@ const photoFinder = process.env.VALUESERP_API_KEY const photoLookupsPerDay = Number(process.env.PHOTO_LOOKUPS_PER_DAY ?? 300); -if (!photoFinder) console.log('no VALUESERP_API_KEY: lead photos come from Gravatar only'); +if (!photoFinder) + console.log('no VALUESERP_API_KEY: lead photos come from public profiles and crawls'); /** * The feed clients for one campaign's own targets. diff --git a/apps/web/components/avatar.tsx b/apps/web/components/avatar.tsx index 66a9ab3..824bcfc 100644 --- a/apps/web/components/avatar.tsx +++ b/apps/web/components/avatar.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { useState } from 'react'; + /** * A face, or the initials standing in for one. * @@ -25,9 +29,10 @@ export function Avatar({ size?: keyof typeof SIZES; className?: string; }) { + const [failedSrc, setFailedSrc] = useState(); const box = `${SIZES[size]} shrink-0 rounded-full ${className}`; - if (src) { + if (src && src !== failedSrc) { return ( // eslint-disable-next-line @next/next/no-img-element -- remote hosts are not known ahead of time setFailedSrc(src)} className={`${box} bg-surface object-cover`} /> ); diff --git a/packages/pipeline/src/crawl.test.ts b/packages/pipeline/src/crawl.test.ts index 37902f3..1610020 100644 --- a/packages/pipeline/src/crawl.test.ts +++ b/packages/pipeline/src/crawl.test.ts @@ -35,7 +35,7 @@ const COMPANY_HTML = `