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:
co-authored by
Claude Opus 5
parent
0a2cf52e8a
commit
46983a7e2c
@@ -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
|
||||
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
|
||||
Pfad), die Größe (max. 5 MB) und die Antwortzeit (12 s); die graue
|
||||
Platzhalter-Silhouette der Profile ohne eigenes Foto wird abgewiesen. Verkleinert
|
||||
wird danach im Browser über denselben Weg wie ein Upload
|
||||
(`compressImageSource()`).
|
||||
Pfad), die Größe (max. 5 MB) und die Antwortzeit (12 s). Verkleinert 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
|
||||
Anfragen aus Rechenzentren häufiger ab als solche aus einem Büronetz. Schlägt er
|
||||
|
||||
@@ -502,9 +502,13 @@ Zwei Handgriffe nimmt der Dialog beim **Anlegen** ab:
|
||||
dem Feld macht dasselbe auf Zuruf - etwa, wenn die Adresse nachgetragen wird.
|
||||
Ein selbst hochgeladenes Foto wird dabei nie überschrieben.
|
||||
|
||||
Klappt es nicht, steht der Grund unter dem Feld: Das Profil ist nicht
|
||||
öffentlich, es hat gar kein eigenes Foto, oder LinkedIn hat die Anfrage
|
||||
abgewiesen. Dann bleibt der gewohnte Weg über *Ändern* am Foto.
|
||||
Klappt es nicht, steht der Grund unter dem Feld. Der häufigste ist „nur für
|
||||
Kontakte sichtbar oder keines": LinkedIn zeigt ein Profilfoto nur dann
|
||||
ö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
|
||||
Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien
|
||||
|
||||
+13
-4
@@ -140,13 +140,22 @@ Der Weg in Stichpunkten und die Gründe dafür:
|
||||
der Seite heraus scheitert an der Same-Origin-Policy.
|
||||
* **Browser-Kennung im Kopf.** Ohne `User-Agent` antwortet LinkedIn oft gar
|
||||
nicht erst.
|
||||
* **Nur `og:image`/`twitter:image`**, ersatzweise eine `media.licdn.com`-Adresse
|
||||
aus dem Quelltext. Es wird nichts angemeldet und nichts Geschütztes gelesen.
|
||||
* **Nur `og:image`/`twitter:image`** - und sonst nichts. Im übrigen Quelltext
|
||||
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
|
||||
und `/dms/image/` im Pfad haben - sonst könnte eine manipulierte Seite eine
|
||||
beliebige Adresse unterschieben.
|
||||
* **Platzhalter werden abgewiesen.** Profile ohne eigenes Foto liefern eine
|
||||
graue Silhouette (`ghost` im Pfad); die soll kein Kontaktfoto werden.
|
||||
* **Platzhalter werden abgewiesen.** Statt eines Fotos liefert LinkedIn dann die
|
||||
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.
|
||||
|
||||
Gibt LinkedIn die Seite nicht heraus (Status 999 oder 403 bei zu vielen
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user