feat(kontakte): Zustaendigkeit, LinkedIn-Foto, Netzwerkauswahl
Drei Verbesserungen an den Kontakten: * Beim Anlegen ist der angemeldete Benutzer voreingestellt zustaendig. Bisher stand dort der erste Benutzer der Liste - fast immer falsch. * Wird eine LinkedIn-Adresse hinterlegt, holt das CRM das oeffentliche Profilfoto und setzt es als Kontaktfoto: beim Verlassen des Feldes einmal je Adresse, solange kein Foto gesetzt ist, und auf Klick ueber den Knopf daneben. Der Abruf laeuft ueber das Backend (server/src/linkedinPhoto.js, POST /api/leads/linkedin-photo), weil ihn die Same-Origin-Policy im Browser verhindert. Gelesen wird die Open-Graph-Vorschau der Profilseite; Herkunft, Groesse und Antwortzeit des Bildes werden geprueft, Platzhalter-Silhouetten abgewiesen. Schlaegt der Abruf fehl, steht der Grund unter dem Feld. * In der Netzwerkansicht hebt ein Klick auf freie Flaeche die Auswahl auf - das Diagramm sieht danach aus wie beim Wechsel in die Ansicht. Ein Ziehen des Hintergrunds (ab 4 px) laesst die Auswahl stehen. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3c9d83161e
commit
0a2cf52e8a
+1
-1
@@ -12,7 +12,7 @@
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"migrate": "node src/migrate.js",
|
||||
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs"
|
||||
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* Profilfoto zu einem LinkedIn-Profil besorgen.
|
||||
*
|
||||
* LinkedIn liefert oeffentlichen Profilseiten eine Open-Graph-Vorschau mit,
|
||||
* die das Profilfoto enthaelt (og:image, ausgeliefert ueber media.licdn.com).
|
||||
* Genau diese Vorschau wird hier gelesen - es findet keine Anmeldung und kein
|
||||
* Auslesen geschuetzter Inhalte statt. Ist das Profil nicht oeffentlich oder
|
||||
* blockt LinkedIn die Anfrage, gibt es eine verstaendliche Fehlermeldung
|
||||
* statt eines Bildes; der Benutzer kann das Foto dann wie gehabt hochladen.
|
||||
*
|
||||
* Der Aufruf laeuft bewusst ueber das Backend: aus dem Browser heraus waere er
|
||||
* an der Same-Origin-Policy gescheitert.
|
||||
*/
|
||||
|
||||
/** Ohne Browser-Kennung antwortet LinkedIn haeufig gar nicht erst. */
|
||||
const BROWSER_HEADERS = {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
|
||||
'Chrome/140.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
||||
'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8',
|
||||
};
|
||||
|
||||
/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */
|
||||
const TIMEOUT_MS = 12_000;
|
||||
|
||||
/** Groessere Bilder sind kein Profilfoto mehr; der Client verkleinert ohnehin. */
|
||||
const MAX_IMAGE_BYTES = 5_000_000;
|
||||
|
||||
/** Bilder liefert LinkedIn ausschliesslich ueber dieses CDN aus. */
|
||||
const IMAGE_HOSTS = ['licdn.com'];
|
||||
|
||||
class PhotoError extends Error {
|
||||
constructor(message, status = 502) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
function hostMatches(hostname, domain) {
|
||||
return hostname === domain || hostname.endsWith(`.${domain}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eingabe des Benutzers zu einer aufrufbaren Profil-URL machen. Akzeptiert
|
||||
* auch "linkedin.com/in/vorname" ohne Schema.
|
||||
*/
|
||||
export function normalizeProfileUrl(input) {
|
||||
const raw = String(input ?? '').trim();
|
||||
if (!raw) throw new PhotoError('Es ist kein LinkedIn-Profil hinterlegt.', 400);
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`);
|
||||
} catch {
|
||||
throw new PhotoError('Die LinkedIn-Adresse ist keine gültige URL.', 400);
|
||||
}
|
||||
|
||||
if (!hostMatches(url.hostname.toLowerCase(), 'linkedin.com')) {
|
||||
throw new PhotoError('Die Adresse zeigt nicht auf ein LinkedIn-Profil.', 400);
|
||||
}
|
||||
|
||||
// Tracking-Parameter stoeren nur und aendern die Seite nicht.
|
||||
url.protocol = 'https:';
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function extractImageUrl(html) {
|
||||
const metaPattern = /<meta[^>]+>/gi;
|
||||
for (const tag of html.match(metaPattern) ?? []) {
|
||||
const isImageTag = /(property|name)\s*=\s*["'](og:image|twitter:image)["']/i.test(tag);
|
||||
if (!isImageTag) continue;
|
||||
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;
|
||||
}
|
||||
|
||||
/** Im HTML stehen die URLs mit maskierten Und-Zeichen. */
|
||||
function decodeEntities(value) {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(///gi, '/')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function requestWithTimeout(url, headers) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { headers, redirect: 'follow', signal: controller.signal });
|
||||
} catch (err) {
|
||||
if (err?.name === 'AbortError') {
|
||||
throw new PhotoError('LinkedIn hat nicht rechtzeitig geantwortet.', 504);
|
||||
}
|
||||
throw new PhotoError('LinkedIn ist gerade nicht erreichbar.', 502);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holt das Profilfoto und gibt es als Data-URL zurueck - so wie ein
|
||||
* hochgeladenes Foto auch gespeichert wird.
|
||||
*/
|
||||
export async function fetchLinkedInPhoto(profileInput) {
|
||||
const profileUrl = normalizeProfileUrl(profileInput);
|
||||
|
||||
const page = await requestWithTimeout(profileUrl, BROWSER_HEADERS);
|
||||
if (page.status === 404 || page.status === 410) {
|
||||
throw new PhotoError('Unter dieser Adresse gibt es kein LinkedIn-Profil.', 404);
|
||||
}
|
||||
if (!page.ok) {
|
||||
// 999 ist LinkedIns Kennzeichen fuer "Anfrage abgewiesen".
|
||||
throw new PhotoError(
|
||||
'LinkedIn hat die Anfrage abgewiesen. Bitte das Foto manuell hochladen.',
|
||||
502
|
||||
);
|
||||
}
|
||||
|
||||
const html = await page.text();
|
||||
const imageUrl = extractImageUrl(html);
|
||||
if (!imageUrl) {
|
||||
throw new PhotoError(
|
||||
'Das Profil gibt kein öffentliches Foto preis. Bitte das Foto manuell hochladen.',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
let parsedImage;
|
||||
try {
|
||||
parsedImage = new URL(imageUrl);
|
||||
} catch {
|
||||
throw new PhotoError('Die gefundene Bildadresse ist unbrauchbar.', 502);
|
||||
}
|
||||
const isTrustedHost =
|
||||
parsedImage.protocol === 'https:' &&
|
||||
IMAGE_HOSTS.some((domain) => hostMatches(parsedImage.hostname.toLowerCase(), domain));
|
||||
if (!isTrustedHost) {
|
||||
throw new PhotoError('Das gefundene Bild stammt nicht von LinkedIn.', 502);
|
||||
}
|
||||
|
||||
/*
|
||||
* Profile ohne eigenes Foto bekommen von LinkedIn eine graue Platzhalter-
|
||||
* Silhouette als Vorschaubild. Die soll nicht als Kontaktfoto landen.
|
||||
*/
|
||||
const path = parsedImage.pathname.toLowerCase();
|
||||
if (path.includes('ghost') || !path.includes('/dms/image/')) {
|
||||
throw new PhotoError(
|
||||
'Das Profil hat kein eigenes Foto hinterlegt.',
|
||||
404
|
||||
);
|
||||
}
|
||||
|
||||
const image = await requestWithTimeout(parsedImage.toString(), {
|
||||
'User-Agent': BROWSER_HEADERS['User-Agent'],
|
||||
'Accept': 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8',
|
||||
'Referer': profileUrl,
|
||||
});
|
||||
if (!image.ok) throw new PhotoError('Das Profilfoto konnte nicht geladen werden.', 502);
|
||||
|
||||
const contentType = (image.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw new PhotoError('LinkedIn hat statt eines Bildes etwas anderes geliefert.', 502);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await image.arrayBuffer());
|
||||
if (buffer.length === 0) throw new PhotoError('Das Profilfoto ist leer.', 502);
|
||||
if (buffer.length > MAX_IMAGE_BYTES) {
|
||||
throw new PhotoError('Das Profilfoto ist zu groß.', 502);
|
||||
}
|
||||
|
||||
return {
|
||||
avatar: `data:${contentType};base64,${buffer.toString('base64')}`,
|
||||
bytes: buffer.length,
|
||||
source_url: parsedImage.toString(),
|
||||
profile_url: profileUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export { PhotoError, extractImageUrl, decodeEntities };
|
||||
@@ -1,5 +1,6 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { fetchLinkedInPhoto } from '../linkedinPhoto.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -110,6 +111,24 @@ router.get('/', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
* POST /api/leads/linkedin-photo { linkedin: "https://www.linkedin.com/in/..." }
|
||||
*
|
||||
* Liest das oeffentliche Profilfoto und gibt es als Data-URL zurueck. Gespeichert
|
||||
* wird hier nichts - der Client legt das Bild ins Formular, verkleinert es wie
|
||||
* ein hochgeladenes Foto und speichert es erst beim Sichern des Kontakts.
|
||||
* Steht die Route vor '/:id', weil sonst die ID-Routen greifen wuerden.
|
||||
*/
|
||||
router.post('/linkedin-photo', async (req, res, next) => {
|
||||
try {
|
||||
const result = await fetchLinkedInPhoto(req.body?.linkedin);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
if (err?.status) return res.status(err.status).json({ error: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/leads
|
||||
router.post('/', async (req, res, next) => {
|
||||
const firstName = emptyToNull(req.body?.first_name);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Pruefungen des LinkedIn-Profilfotos: laeuft mit `npm test` im Verzeichnis
|
||||
* server/. Geprueft wird alles, was ohne Netz geht - die Adresspruefung und das
|
||||
* Herausloesen der Bildadresse aus dem Seitenquelltext. Der Abruf selbst haengt
|
||||
* an LinkedIn und taugt nicht als Test: er kann jederzeit abgewiesen werden.
|
||||
*/
|
||||
const { normalizeProfileUrl, extractImageUrl } = await import('../src/linkedinPhoto.js');
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function check(name, condition, detail = '') {
|
||||
if (condition) {
|
||||
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
} else {
|
||||
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function throws(fn) {
|
||||
try {
|
||||
fn();
|
||||
return null;
|
||||
} catch (err) {
|
||||
return err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nAdresse pruefen');
|
||||
check(
|
||||
'Vollstaendige Adresse bleibt',
|
||||
normalizeProfileUrl('https://www.linkedin.com/in/max') === 'https://www.linkedin.com/in/max'
|
||||
);
|
||||
check(
|
||||
'Ohne Schema wird https ergaenzt',
|
||||
normalizeProfileUrl('linkedin.com/in/max') === 'https://linkedin.com/in/max'
|
||||
);
|
||||
check(
|
||||
'Landeskennung ist erlaubt',
|
||||
normalizeProfileUrl('https://de.linkedin.com/in/max') === 'https://de.linkedin.com/in/max'
|
||||
);
|
||||
check(
|
||||
'Nachverfolgung faellt weg',
|
||||
normalizeProfileUrl('https://www.linkedin.com/in/max?trk=abc#top') ===
|
||||
'https://www.linkedin.com/in/max'
|
||||
);
|
||||
check(
|
||||
'http wird zu https',
|
||||
normalizeProfileUrl('http://www.linkedin.com/in/max') === 'https://www.linkedin.com/in/max'
|
||||
);
|
||||
|
||||
const fremd = throws(() => normalizeProfileUrl('https://linkedin.com.example.org/in/max'));
|
||||
check('Aehnlich aussehender Host wird abgewiesen', fremd?.status === 400, fremd?.message);
|
||||
check('Fremder Host wird abgewiesen', throws(() => normalizeProfileUrl('https://xing.com/x'))?.status === 400);
|
||||
check('Leere Eingabe wird abgewiesen', throws(() => normalizeProfileUrl(''))?.status === 400);
|
||||
check('Unsinn wird abgewiesen', throws(() => normalizeProfileUrl('kein ://link'))?.status === 400);
|
||||
|
||||
console.log('\nBildadresse aus dem Quelltext');
|
||||
check(
|
||||
'og:image wird gefunden',
|
||||
extractImageUrl('<meta property="og:image" content="https://media.licdn.com/dms/image/v2/abc" />') ===
|
||||
'https://media.licdn.com/dms/image/v2/abc'
|
||||
);
|
||||
check(
|
||||
'Maskierte Und-Zeichen werden zurueckgesetzt',
|
||||
extractImageUrl('<meta property="og:image" content="https://media.licdn.com/dms/image/a?x=1&y=2">') ===
|
||||
'https://media.licdn.com/dms/image/a?x=1&y=2'
|
||||
);
|
||||
check(
|
||||
'twitter:image als Ersatz',
|
||||
extractImageUrl('<meta name="twitter:image" content="https://media.licdn.com/dms/image/t">') ===
|
||||
'https://media.licdn.com/dms/image/t'
|
||||
);
|
||||
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'
|
||||
);
|
||||
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