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
+15 -4
View File
@@ -531,10 +531,21 @@ Den Abruf macht das Backend (`server/src/linkedinPhoto.js`, Route
Same-Origin-Policy davor. Gelesen wird die Open-Graph-Vorschau der öffentlichen Same-Origin-Policy davor. Gelesen wird die Open-Graph-Vorschau der öffentlichen
Profilseite (`og:image`), das Bild kommt anschließend von `media.licdn.com`. Profilseite (`og:image`), das Bild kommt anschließend von `media.licdn.com`.
Geprüft wird die Herkunft (nur `https` von `licdn.com` mit `/dms/image/` im Geprüft wird die Herkunft (nur `https` von `licdn.com` mit `/dms/image/` im
Pfad), die Größe (max. 5 MB) und die Antwortzeit (12 s); die graue Pfad), die Größe (max. 5 MB) und die Antwortzeit (12 s). Verkleinert wird danach
Platzhalter-Silhouette der Profile ohne eigenes Foto wird abgewiesen. Verkleinert im Browser über denselben Weg wie ein Upload (`compressImageSource()`).
wird danach im Browser über denselben Weg wie ein Upload
(`compressImageSource()`). Gelesen wird **ausschließlich** die Vorschau-Adresse. Der übrige Quelltext wird
nicht durchsucht, obwohl dort Dutzende Bildadressen stehen - sie gehören
Firmenlogos, Beiträgen und den „ähnlichen Profilen" am Seitenrand. Eine davon zu
nehmen, hieße, einem Kontakt das Gesicht einer fremden Person anzuhängen.
**Wann kein Foto kommt:** Liefert die Vorschau die graue Silhouette oder eine
Grafik von `static.licdn.com`, gibt LinkedIn zu diesem Profil öffentlich kein
Foto heraus. Zwei Ursachen, von außen nicht unterscheidbar: Das Profil hat kein
Foto, oder seine Sichtbarkeit steht auf *Meine Kontakte* statt *Alle
LinkedIn-Mitglieder*. Im zweiten Fall sieht man das Foto im eigenen,
angemeldeten LinkedIn sehr wohl - dieser Abruf ist aber nicht angemeldet, und
das soll er auch nicht sein.
Der Abruf ist bewusst als **Komfort** gebaut, nicht als Zusage: LinkedIn weist Der Abruf ist bewusst als **Komfort** gebaut, nicht als Zusage: LinkedIn weist
Anfragen aus Rechenzentren häufiger ab als solche aus einem Büronetz. Schlägt er Anfragen aus Rechenzentren häufiger ab als solche aus einem Büronetz. Schlägt er
+7 -3
View File
@@ -502,9 +502,13 @@ Zwei Handgriffe nimmt der Dialog beim **Anlegen** ab:
dem Feld macht dasselbe auf Zuruf - etwa, wenn die Adresse nachgetragen wird. dem Feld macht dasselbe auf Zuruf - etwa, wenn die Adresse nachgetragen wird.
Ein selbst hochgeladenes Foto wird dabei nie überschrieben. Ein selbst hochgeladenes Foto wird dabei nie überschrieben.
Klappt es nicht, steht der Grund unter dem Feld: Das Profil ist nicht Klappt es nicht, steht der Grund unter dem Feld. Der häufigste ist „nur für
öffentlich, es hat gar kein eigenes Foto, oder LinkedIn hat die Anfrage Kontakte sichtbar oder keines": LinkedIn zeigt ein Profilfoto nur dann
abgewiesen. Dann bleibt der gewohnte Weg über *Ändern* am Foto. öffentlich, wenn dessen Sichtbarkeit auf *Alle LinkedIn-Mitglieder* steht.
Steht sie auf *Meine Kontakte*, sieht **man selbst** das Foto im eigenen
LinkedIn - das CRM fragt aber ohne Anmeldung an und bekommt es nicht. Genauso
möglich: LinkedIn weist die Anfrage ab. In allen Fällen bleibt der gewohnte
Weg über *Ändern* am Foto.
Im **Netzwerk** sind Kreise Kontakte (Farbe = Art, Größe = Anzahl Im **Netzwerk** sind Kreise Kontakte (Farbe = Art, Größe = Anzahl
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
+13 -4
View File
@@ -140,13 +140,22 @@ Der Weg in Stichpunkten und die Gründe dafür:
der Seite heraus scheitert an der Same-Origin-Policy. der Seite heraus scheitert an der Same-Origin-Policy.
* **Browser-Kennung im Kopf.** Ohne `User-Agent` antwortet LinkedIn oft gar * **Browser-Kennung im Kopf.** Ohne `User-Agent` antwortet LinkedIn oft gar
nicht erst. nicht erst.
* **Nur `og:image`/`twitter:image`**, ersatzweise eine `media.licdn.com`-Adresse * **Nur `og:image`/`twitter:image`** - und sonst nichts. Im übrigen Quelltext
aus dem Quelltext. Es wird nichts angemeldet und nichts Geschütztes gelesen. wird bewusst *nicht* gesucht: eine Profilseite enthält Dutzende Adressen des
LinkedIn-CDN (Firmenlogos, Beitragsbilder, die Fotos der „ähnlichen Profile"
am Rand). Die erste davon zu nehmen, hängt einem Kontakt irgendwann das
Gesicht einer fremden Person an. Es wird nichts angemeldet und nichts
Geschütztes gelesen.
* **Herkunft wird geprüft.** Das Bild muss über `https` von `licdn.com` kommen * **Herkunft wird geprüft.** Das Bild muss über `https` von `licdn.com` kommen
und `/dms/image/` im Pfad haben - sonst könnte eine manipulierte Seite eine und `/dms/image/` im Pfad haben - sonst könnte eine manipulierte Seite eine
beliebige Adresse unterschieben. beliebige Adresse unterschieben.
* **Platzhalter werden abgewiesen.** Profile ohne eigenes Foto liefern eine * **Platzhalter werden abgewiesen.** Statt eines Fotos liefert LinkedIn dann die
graue Silhouette (`ghost` im Pfad); die soll kein Kontaktfoto werden. graue Silhouette (`ghost` im Pfad) oder eine feste Grafik von
`static.licdn.com` (`/aero-v1/sc/h/...`). Das passiert in zwei Fällen, die von
außen nicht unterscheidbar sind: Das Profil hat kein Foto, **oder** dessen
Sichtbarkeit steht auf „Meine Kontakte" statt „Alle LinkedIn-Mitglieder". Im
zweiten Fall sieht man das Foto angemeldet sehr wohl - der Abruf hier ist aber
nicht angemeldet. Die Meldung nennt deshalb beide Möglichkeiten.
* **Grenzen:** 12 s Zeitlimit je Anfrage, höchstens 5 MB Bild. * **Grenzen:** 12 s Zeitlimit je Anfrage, höchstens 5 MB Bild.
Gibt LinkedIn die Seite nicht heraus (Status 999 oder 403 bei zu vielen Gibt LinkedIn die Seite nicht heraus (Status 999 oder 403 bei zu vielen
+17 -9
View File
@@ -68,9 +68,14 @@ export function normalizeProfileUrl(input) {
} }
/** /**
* Bild-URL aus dem HTML der Profilseite ziehen. og:image ist der * Bild-URL aus dem HTML der Profilseite ziehen - ausschliesslich aus der
* zuverlaessigste Treffer; als Rueckfallebene wird nach einer Foto-URL des * Open-Graph-Vorschau (`og:image`, ersatzweise `twitter:image`).
* LinkedIn-CDN im Seitenquelltext gesucht. *
* 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) { function extractImageUrl(html) {
const metaPattern = /<meta[^>]+>/gi; const metaPattern = /<meta[^>]+>/gi;
@@ -80,9 +85,7 @@ function extractImageUrl(html) {
const content = tag.match(/content\s*=\s*["']([^"']+)["']/i); const content = tag.match(/content\s*=\s*["']([^"']+)["']/i);
if (content?.[1]) return decodeEntities(content[1]); if (content?.[1]) return decodeEntities(content[1]);
} }
return null;
const direct = html.match(/https:\/\/media[^"'\s\\]*\.licdn\.com\/dms\/image\/[^"'\s\\]+/i);
return direct ? decodeEntities(direct[0]) : null;
} }
/** Im HTML stehen die URLs mit maskierten Und-Zeichen. */ /** 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- * Echte Profilfotos liegen unter media.licdn.com/dms/image/... - alles andere
* Silhouette als Vorschaubild. Die soll nicht als Kontaktfoto landen. * 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(); const path = parsedImage.pathname.toLowerCase();
if (path.includes('ghost') || !path.includes('/dms/image/')) { if (path.includes('ghost') || !path.includes('/dms/image/')) {
throw new PhotoError( 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 404
); );
} }
+16 -4
View File
@@ -71,12 +71,24 @@ check(
extractImageUrl('<meta name="twitter:image" content="https://media.licdn.com/dms/image/t">') === extractImageUrl('<meta name="twitter:image" content="https://media.licdn.com/dms/image/t">') ===
'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( check(
'Ohne Vorschau greift die Suche im Quelltext', 'Fremde Bilder im Quelltext werden nicht genommen',
extractImageUrl('var x = "https://media.licdn.com/dms/image/v2/zzz/profile-displayphoto";') === extractImageUrl(
'https://media.licdn.com/dms/image/v2/zzz/profile-displayphoto' '<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.`); console.log(failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`);
process.exit(failures === 0 ? 0 : 1); process.exit(failures === 0 ? 0 : 1);