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
|
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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user