/* * Profilfoto zu einem LinkedIn-Profil besorgen. * * LinkedIn liefert oeffentlichen Profilseiten eine Open-Graph-Vorschau mit, * die das Profilfoto enthaelt (og:image, ausgeliefert ueber media.licdn.com). * Genau diese Vorschau wird hier gelesen - es findet keine Anmeldung und kein * Auslesen geschuetzter Inhalte statt. Ist das Profil nicht oeffentlich oder * blockt LinkedIn die Anfrage, gibt es eine verstaendliche Fehlermeldung * statt eines Bildes; der Benutzer kann das Foto dann wie gehabt hochladen. * * Der Aufruf laeuft bewusst ueber das Backend: aus dem Browser heraus waere er * an der Same-Origin-Policy gescheitert. */ /** Ohne Browser-Kennung antwortet LinkedIn haeufig gar nicht erst. */ const BROWSER_HEADERS = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + 'Chrome/140.0.0.0 Safari/537.36', 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', }; /** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */ const TIMEOUT_MS = 12_000; /** Groessere Bilder sind kein Profilfoto mehr; der Client verkleinert ohnehin. */ const MAX_IMAGE_BYTES = 5_000_000; /** Bilder liefert LinkedIn ausschliesslich ueber dieses CDN aus. */ const IMAGE_HOSTS = ['licdn.com']; class PhotoError extends Error { constructor(message, status = 502) { super(message); this.status = status; } } function hostMatches(hostname, domain) { return hostname === domain || hostname.endsWith(`.${domain}`); } /** * Eingabe des Benutzers zu einer aufrufbaren Profil-URL machen. Akzeptiert * auch "linkedin.com/in/vorname" ohne Schema. */ export function normalizeProfileUrl(input) { const raw = String(input ?? '').trim(); if (!raw) throw new PhotoError('Es ist kein LinkedIn-Profil hinterlegt.', 400); let url; try { url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); } catch { throw new PhotoError('Die LinkedIn-Adresse ist keine gültige URL.', 400); } if (!hostMatches(url.hostname.toLowerCase(), 'linkedin.com')) { throw new PhotoError('Die Adresse zeigt nicht auf ein LinkedIn-Profil.', 400); } // Tracking-Parameter stoeren nur und aendern die Seite nicht. url.protocol = 'https:'; url.search = ''; url.hash = ''; return url.toString(); } /** * Bild-URL aus dem HTML der Profilseite ziehen - ausschliesslich aus der * Open-Graph-Vorschau (`og:image`, ersatzweise `twitter:image`). * * Bewusst wird NICHT im uebrigen Quelltext nach einer Bildadresse gesucht: eine * Profilseite enthaelt Dutzende Adressen des LinkedIn-CDN - Firmenlogos, * Beitragsbilder und die Fotos der "aehnlichen Profile" am Rand. Die erste * davon zu nehmen, haengt irgendwann einem Kontakt das Gesicht einer fremden * Person an. Nur die Vorschau gehoert nachweislich zum aufgerufenen Profil. */ function extractImageUrl(html) { const metaPattern = /]+>/gi; for (const tag of html.match(metaPattern) ?? []) { const isImageTag = /(property|name)\s*=\s*["'](og:image|twitter:image)["']/i.test(tag); if (!isImageTag) continue; const content = tag.match(/content\s*=\s*["']([^"']+)["']/i); if (content?.[1]) return decodeEntities(content[1]); } return null; } /** Im HTML stehen die URLs mit maskierten Und-Zeichen. */ function decodeEntities(value) { return value .replace(/&/g, '&') .replace(///gi, '/') .replace(/&/g, '&') .replace(/"/g, '"'); } async function requestWithTimeout(url, headers) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); try { return await fetch(url, { headers, redirect: 'follow', signal: controller.signal }); } catch (err) { if (err?.name === 'AbortError') { throw new PhotoError('LinkedIn hat nicht rechtzeitig geantwortet.', 504); } throw new PhotoError('LinkedIn ist gerade nicht erreichbar.', 502); } finally { clearTimeout(timer); } } /** * Holt das Profilfoto und gibt es als Data-URL zurueck - so wie ein * hochgeladenes Foto auch gespeichert wird. */ export async function fetchLinkedInPhoto(profileInput) { const profileUrl = normalizeProfileUrl(profileInput); const page = await requestWithTimeout(profileUrl, BROWSER_HEADERS); if (page.status === 404 || page.status === 410) { throw new PhotoError('Unter dieser Adresse gibt es kein LinkedIn-Profil.', 404); } if (!page.ok) { // 999 ist LinkedIns Kennzeichen fuer "Anfrage abgewiesen". throw new PhotoError( 'LinkedIn hat die Anfrage abgewiesen. Bitte das Foto manuell hochladen.', 502 ); } const html = await page.text(); const imageUrl = extractImageUrl(html); if (!imageUrl) { throw new PhotoError( 'Das Profil gibt kein öffentliches Foto preis. Bitte das Foto manuell hochladen.', 404 ); } let parsedImage; try { parsedImage = new URL(imageUrl); } catch { throw new PhotoError('Die gefundene Bildadresse ist unbrauchbar.', 502); } const isTrustedHost = parsedImage.protocol === 'https:' && IMAGE_HOSTS.some((domain) => hostMatches(parsedImage.hostname.toLowerCase(), domain)); if (!isTrustedHost) { throw new PhotoError('Das gefundene Bild stammt nicht von LinkedIn.', 502); } /* * Echte Profilfotos liegen unter media.licdn.com/dms/image/... - alles andere * ist LinkedIns Ersatzbild: die graue Silhouette ('ghost') oder eine feste * Grafik von static.licdn.com (/aero-v1/sc/h/...). Das bekommt zu sehen, wer * nicht angemeldet ist und dessen Profilfoto nur fuer Kontakte sichtbar ist - * von aussen laesst sich das nicht von "gar kein Foto" unterscheiden, deshalb * nennt die Meldung beide Moeglichkeiten. */ const path = parsedImage.pathname.toLowerCase(); if (path.includes('ghost') || !path.includes('/dms/image/')) { throw new PhotoError( 'LinkedIn gibt zu diesem Profil kein Foto heraus: Es ist nur für Kontakte ' + 'sichtbar oder es gibt keines. Bitte das Foto manuell hochladen.', 404 ); } const image = await requestWithTimeout(parsedImage.toString(), { 'User-Agent': BROWSER_HEADERS['User-Agent'], 'Accept': 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8', 'Referer': profileUrl, }); if (!image.ok) throw new PhotoError('Das Profilfoto konnte nicht geladen werden.', 502); const contentType = (image.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); if (!contentType.startsWith('image/')) { throw new PhotoError('LinkedIn hat statt eines Bildes etwas anderes geliefert.', 502); } const buffer = Buffer.from(await image.arrayBuffer()); if (buffer.length === 0) throw new PhotoError('Das Profilfoto ist leer.', 502); if (buffer.length > MAX_IMAGE_BYTES) { throw new PhotoError('Das Profilfoto ist zu groß.', 502); } return { avatar: `data:${contentType};base64,${buffer.toString('base64')}`, bytes: buffer.length, source_url: parsedImage.toString(), profile_url: profileUrl, }; } export { PhotoError, extractImageUrl, decodeEntities };