fix(kontakte): LinkedIn-Foto nur aus der Vorschau, klarere Meldung

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>
This commit is contained in:
Sascha Wolf
2026-09-10 00:30:30 +02:00
co-authored by Claude Opus 5
parent 0a2cf52e8a
commit 46983a7e2c
5 changed files with 68 additions and 24 deletions
+17 -9
View File
@@ -68,9 +68,14 @@ export function normalizeProfileUrl(input) {
}
/**
* Bild-URL aus dem HTML der Profilseite ziehen. og:image ist der
* zuverlaessigste Treffer; als Rueckfallebene wird nach einer Foto-URL des
* LinkedIn-CDN im Seitenquelltext gesucht.
* 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;
@@ -80,9 +85,7 @@ function extractImageUrl(html) {
const content = tag.match(/content\s*=\s*["']([^"']+)["']/i);
if (content?.[1]) return decodeEntities(content[1]);
}
const direct = html.match(/https:\/\/media[^"'\s\\]*\.licdn\.com\/dms\/image\/[^"'\s\\]+/i);
return direct ? decodeEntities(direct[0]) : null;
return null;
}
/** Im HTML stehen die URLs mit maskierten Und-Zeichen. */
@@ -151,13 +154,18 @@ export async function fetchLinkedInPhoto(profileInput) {
}
/*
* Profile ohne eigenes Foto bekommen von LinkedIn eine graue Platzhalter-
* Silhouette als Vorschaubild. Die soll nicht als Kontaktfoto landen.
* 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(
'Das Profil hat kein eigenes Foto hinterlegt.',
'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
);
}
+16 -4
View File
@@ -71,12 +71,24 @@ check(
extractImageUrl('<meta name="twitter:image" content="https://media.licdn.com/dms/image/t">') ===
'https://media.licdn.com/dms/image/t'
);
check('Seite ohne Vorschau liefert null', extractImageUrl('<html><body>nichts</body></html>') === null);
/*
* Wichtig: ohne og:image wird NICHT im uebrigen Quelltext gesucht. Eine
* Profilseite ist voller fremder Bilder - Firmenlogos, Beitragsbilder, Fotos
* der "aehnlichen Profile". Eines davon zu nehmen, haenge dem Kontakt das
* Gesicht einer fremden Person an.
*/
check(
'Ohne Vorschau greift die Suche im Quelltext',
extractImageUrl('var x = "https://media.licdn.com/dms/image/v2/zzz/profile-displayphoto";') ===
'https://media.licdn.com/dms/image/v2/zzz/profile-displayphoto'
'Fremde Bilder im Quelltext werden nicht genommen',
extractImageUrl(
'<html><body><img src="https://media.licdn.com/dms/image/v2/xy/profile-displayphoto-shrink_400_400/0/1"></body></html>'
) === null
);
check(
'Ersatzgrafik von static.licdn.com kommt als solche zurueck',
extractImageUrl('<meta property="og:image" content="https://static.licdn.com/aero-v1/sc/h/abc">') ===
'https://static.licdn.com/aero-v1/sc/h/abc'
);
check('Seite ohne Bild liefert null', extractImageUrl('<html><body>nichts</body></html>') === null);
console.log(failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`);
process.exit(failures === 0 ? 0 : 1);