diff --git a/README.md b/README.md index a29398c..f8109ef 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index 8f62388..8674396 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index e6dd9fd..9ca2d33 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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 diff --git a/server/src/linkedinPhoto.js b/server/src/linkedinPhoto.js index e43306e..4d439e8 100644 --- a/server/src/linkedinPhoto.js +++ b/server/src/linkedinPhoto.js @@ -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 = /]+>/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 ); } diff --git a/server/test/linkedinPhoto.test.mjs b/server/test/linkedinPhoto.test.mjs index bf10373..46837fb 100644 --- a/server/test/linkedinPhoto.test.mjs +++ b/server/test/linkedinPhoto.test.mjs @@ -71,12 +71,24 @@ check( extractImageUrl('') === 'https://media.licdn.com/dms/image/t' ); +check('Seite ohne Vorschau liefert null', extractImageUrl('
nichts') === 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( + '