Anlass: Bei einem Kontakt kam das Foto, beim naechsten nicht. Der Vergleich der beiden Profilseiten zeigt warum - das zweite Profil liefert als og:image nur LinkedIns Ersatzgrafik (static.licdn.com/aero-v1/sc/h/...), weil die Sichtbarkeit des Fotos auf "Meine Kontakte" steht. Angemeldet sieht man es, der Abruf hier ist es nicht. Zwei Konsequenzen: * Die Rueckfallebene "erste media.licdn.com-Adresse im Quelltext" faellt weg. Dieselbe Seite enthaelt 24 solcher Adressen: Firmenlogos, Beitragsbilder und die Fotos der "aehnlichen Profile" am Rand. Nur die Open-Graph-Vorschau gehoert nachweislich zum aufgerufenen Profil - alles andere haette irgendwann ein fremdes Gesicht an einen Kontakt gehaengt. * Die Meldung sagt jetzt, was wirklich los ist: "nur fuer Kontakte sichtbar oder es gibt keines" statt "hat kein eigenes Foto". Von aussen sind die beiden Faelle nicht unterscheidbar. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
7.0 KiB
JavaScript
200 lines
7.0 KiB
JavaScript
/*
|
|
* 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 = /<meta[^>]+>/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 };
|