Artikel

Vom Match zur Maschine: Wie ich Tinder-Kontakte automatisiert importiere, ohne gleich den Autopiloten Amok fahren zu lassen

BrowserChromeJavascriptKILinuxPHPProgrammierungSymfony

Es gibt Programmierprojekte, die beginnen harmlos.

Man möchte „nur mal eben“ ein paar Daten importieren. Danach möchte man „nur kurz“ die Nachrichten historisieren. Dann wäre es „eigentlich praktisch“, wenn neue Kontakte automatisch erkannt würden. Und irgendwann sitzt man vor einer Architektur aus Symfony, Messenger, Playwright, persistenten Browser-Profilen, VNC, DTOs, Statusmaschinen, Job-Queues und fragt sich: War das noch Dating – oder schon Systemintegration?

In diesem Artikel beschreibe ich den technischen Aufbau eines Tools, das Tinder-Kontakte und sichtbare Nachrichten automatisiert importiert, lokal strukturiert speichert und für spätere KI-gestützte Auswertung vorbereitet. Wichtig: Es geht nicht um massenhaftes Anschreiben, nicht um Spam, nicht um Trickserei, nicht um das Umgehen von Logins oder Captchas. Es geht um kontrollierte, nachvollziehbare Automatisierung einer bestehenden Benutzeroberfläche – mit möglichst wenig Magie und möglichst viel technischer Kontrolle.

Die zentrale Idee lautet:

Die App darf lesen. Schreiben kommt später – und nur, wenn ich es explizit freigebe.

Die Ausgangslage

Dating-Plattformen sind aus Entwicklersicht ein schwieriges Umfeld. Sie bieten normalerweise keine offizielle API für private Nutzer. Gleichzeitig ist die Benutzeroberfläche dynamisch, JavaScript-lastig und nicht dafür gebaut, von Maschinen gelesen zu werden. Was im Browser stabil aussieht, kann im DOM eine wilde Mischung aus verschachtelten divs, generierten CSS-Klassen und rollenspezifischen ARIA-Attributen sein.

Trotzdem gibt es einen legitimen Anwendungsfall: Ich möchte meine eigenen Kontakte und Nachrichten lokal analysieren können. Nicht, um Menschen auszutricksen, sondern um einen besseren Überblick zu bekommen. Welche Kontakte sind aktiv? Welche Gespräche laufen? Wo lohnt eine Antwort? Wo sollte ich die Finger davon lassen? Und welche Kontakte sollen irgendwann überhaupt automatisch bedient werden dürfen?

Daraus entstand eine klare Zielarchitektur:

  • Tinder bleibt die Quelle.
  • Meine Anwendung importiert nur sichtbare Kontakte und Nachrichten.
  • Nichts wird gelöscht.
  • Nichts wird ungefragt gesendet.
  • Jeder Kontakt bekommt einen Modus: manuell oder Autopilot.
  • Der Autopilot ist zunächst nur ein Schalter, keine automatische Waffe.
  • Lange Prozesse laufen asynchron über Jobs.

Kurz: Erst Buchhaltung, dann Intelligenz. Nicht umgekehrt.

Warum keine direkte API?

Eine direkte API wäre natürlich schöner. Saubere Endpunkte, strukturierte Daten, stabile IDs, Authentifizierung, Rate-Limits, Dokumentation. Also all die Dinge, von denen Entwickler nachts träumen, wenn sie nicht gerade von div.msg > div > span > div verfolgt werden.

Da keine offizielle API zur Verfügung steht, bleibt für einen lokalen, privaten Workflow nur die Browser-Automatisierung. Dafür eignet sich Playwright sehr gut. Playwright kann einen echten Chromium starten, eine persistente Session verwenden, DOM-Elemente lesen und mit der Oberfläche interagieren.

Aber Browser-Automatisierung ist kein Freifahrtschein. Deshalb habe ich die Regeln technisch eng gezogen:

  • Kein automatisches Login.
  • Keine Credentials im Code.
  • Keine Captcha-Behandlung.
  • Keine Likes.
  • Keine Swipes.
  • Keine Profilaktionen.
  • Keine Nachrichten senden.
  • Keine Screenshots mit privaten Inhalten.
  • Nur sichtbare Kontakte und sichtbare Nachrichten lesen.

Das ist wichtig, weil UI-Automatisierung schnell kippen kann. Ein falscher Selektor, ein unerwarteter Button, ein verändertes Layout – und schon klickt das Programm nicht mehr auf „Nachrichten“, sondern auf etwas, das man lieber nicht automatisch klicken möchte.

Die technische Grundarchitektur

Das Projekt besteht im Kern aus drei Teilen:

  1. Symfony-Backend
  2. Vue-Frontend
  3. Playwright-Browserautomatisierung

Das Symfony-Backend ist die zentrale Steuerung. Dort liegen Entities, Controller, Services, DTOs, Messenger-Jobs und die API. Das Frontend zeigt die Arbeitsliste, Kontakte, Nachrichten und Job-Status. Playwright übernimmt die kontrollierte Interaktion mit Tinder im Browser.

Der wichtigste Architekturentscheid war: Playwright-Skripte werden nicht wild aus dem Frontend gestartet, sondern ausschließlich vom Backend orchestriert. Das Frontend triggert nur Backend-Endpunkte. Das Backend entscheidet, welches Skript läuft, mit welchem Account, in welchem Modus und mit welchem Ergebnis.

Das reduziert Chaos. Und Chaos ist bei Browserautomatisierung ohnehin schon ausreichend vorhanden.

Persistente Browser-Session

Der erste kritische Punkt war die Session.

Tinder läuft im Browser. Wenn ich mich manuell einlogge, soll Playwright diese Session später verwenden können. Dafür nutzt Playwright einen persistenten Chromium-Kontext:

const context = await chromium.launchPersistentContext(userDataDir, {
  headless: false
});

Der entscheidende Parameter ist userDataDir. Dort speichert Chromium Cookies, lokale Daten und Session-Informationen. Für den Account wird ein eigener Ordner verwendet, zum Beispiel:

var/platform-sessions/tinder/account-1

Der Login selbst bleibt manuell. Die Anwendung startet nur den Browser. Ich logge mich im VNC-Desktop ein. Danach kann das Backend prüfen, ob die Session noch gültig ist.

Dabei gab es eine wichtige Erkenntnis: Wenn das Browserprofil bereits in einem sichtbaren Chromium geöffnet ist, kann Playwright denselben persistenten Kontext nicht parallel öffnen. Das ist technisch korrekt. Das Profil ist gesperrt. Anfangs wurde das als Fehler behandelt. Später wurde daraus ein eigener Status:

profile_in_use

Das bedeutet: Der Browser ist offen, die Session ist wahrscheinlich aktiv, aber ein zweiter Prozess kann das Profil gerade nicht übernehmen.

VNC als kontrollierte Browserumgebung

Da der Server headless läuft, aber Playwright hier mit sichtbarem Chromium arbeiten soll, braucht es einen X-Server. Dafür habe ich einen VNC-Desktop eingerichtet. Der Browser läuft also nicht irgendwo unsichtbar im Nichts, sondern in einer kontrollierbaren grafischen Umgebung.

Das hat zwei Vorteile:

Erstens kann ich jederzeit sehen, was passiert.

Zweitens kann ich mich manuell einloggen, wenn Tinder es verlangt.

Das ist für solche Projekte wesentlich stabiler als der Versuch, Logins vollständig zu automatisieren. Automatische Logins sind eine Einladung zu Ärger. Manuell einloggen, danach Session wiederverwenden: deutlich robuster.

Session-Prüfung: Nicht jede URL ist ein Zustand

Ein unterschätzter Teil der Arbeit war die Session-Prüfung.

Anfangs war die Logik zu simpel: Wenn die URL nicht eindeutig nach App aussah, galt die Session als abgelaufen. Das funktionierte nicht zuverlässig. Tinder kann auf /app/recs stehen, ohne dass sofort alle erwarteten DOM-Indikatoren geladen sind. Umgekehrt kann ein Login-Button sichtbar sein, obwohl die Seite technisch schon unter /app läuft.

Die Session-Prüfung musste daher mehrere Signale kombinieren:

  • finale URL enthält /app
  • keine Login-/Signup-Indikatoren sichtbar
  • harte App-Indikatoren sichtbar, zum Beispiel:
    • „Matches“
    • „Nachrichten“
    • „Gefällt mir“
    • „Nein“
    • „Super-Like“
    • „Boost“
    • „Explore“

Erst diese Kombination lieferte brauchbare Ergebnisse.

Das ist eine allgemeine Lehre aus UI-Automatisierung:

Ein einzelnes Signal ist selten genug. URLs lügen. Buttons auch. Der DOM lügt besonders gern, aber meist nur aus Versehen.

Kontaktimport aus der Nachrichtenübersicht

Der nächste Schritt war der Kontaktimport. Tinder zeigt in der Nachrichtenübersicht sichtbare Kontakte als Links auf Chat-URLs:

/app/messages/<id>

Diese <id> ist wertvoll, weil sie als externe Kontakt-ID verwendet werden kann. Damit wird der Import idempotent: Wenn derselbe Kontakt erneut sichtbar ist, wird er nicht neu angelegt, sondern aktualisiert.

Der Import folgt diesem Schema:

  1. Tinder-App öffnen.
  2. Session prüfen.
  3. Nachrichten-Tab aktivieren.
  4. Sichtbare Kontaktzeilen im aside suchen.
  5. Nur Links mit /app/messages/... akzeptieren.
  6. externalContactId aus der URL extrahieren.
  7. Anzeigename aus aria-label lesen.
  8. Kontakt anlegen oder aktualisieren.

Ein importierter Kontakt enthält dann unter anderem:

externalContactId
displayName
platformAccountId
profileData.sourceHref
metadata.importedVia

Wichtig: Es wird kein Chat geöffnet, keine Nachricht gelesen und nichts angeklickt, was einen Zustand verändert. Der Kontaktimport liest nur die sichtbare Nachrichtenliste.

Warum nicht löschen?

Wenn ein Kontakt in Tinder verschwindet, wird er lokal nicht gelöscht. Stattdessen bekommt er einen Sichtbarkeitsstatus:

visible
missing

Das ist absichtlich so. Externe Systeme sind unzuverlässig. Ein Kontakt kann verschwinden, weil Tinder ihn gerade nicht lädt, weil die UI anders sortiert ist, weil ein Tab nicht vollständig gerendert wurde oder weil der Kontakt tatsächlich gelöscht oder entmatcht wurde.

Löschen wäre zu aggressiv. Markieren ist sicherer.

Der Kontakt-Sync arbeitet deshalb so:

  • Sichtbar in Tinder und lokal nicht vorhanden: importieren.
  • Sichtbar in Tinder und lokal vorhanden: aktualisieren und visible setzen.
  • Lokal vorhanden, aber nicht mehr sichtbar: missing setzen.
  • Niemals löschen.

Das ist ein Standardmuster bei Integrationen mit externen Systemen: Soft State statt harter Wahrheit.

Nachrichtenimport pro Kontakt

Nach dem Kontaktimport kommt der Nachrichtenimport.

Hier wird ein einzelner Chat geöffnet:

https://tinder.com/app/messages/&lt;externalContactId>

Dann wird innerhalb des Chatbereichs nach Nachrichten gesucht. In der aktuellen Tinder-Struktur liegen die Nachrichten in etwa so:

[role="log"]
    [role="article"]
        div.msg

Die Richtung lässt sich über CSS-Klassen ableiten:

chat-bubble-send    => outgoing
chat-bubble-receive => incoming
msg--received       => incoming

Der Zeitstempel liegt auf Artikel-Ebene als ISO-Zeit vor. Pro sichtbarer Nachricht wird genau ein Datensatz erzeugt.

Ein typisches Importobjekt enthält:

direction
body
sentAt
platformMessageId
bodyHash

Die Response gibt bewusst keine vollständigen Nachrichtentexte zurück, sondern nur technische Metadaten wie Textlänge, Richtung und Importstatus. Das schützt vor unnötiger Ausgabe privater Inhalte in Logs oder Debugansichten.

Sprecherpräfixe: Kleine Ursache, hässliche Daten

Ein überraschendes Detail: Der sichtbare Text enthielt zunächst Sprecherpräfixe wie:

Du:Guten Abend
Andrea:Hallo

Das ist für die Anzeige im Tinder-DOM nachvollziehbar, aber für die lokale Speicherung falsch. Die Richtung steht bereits separat in direction. Der Nachrichteninhalt sollte nur der tatsächliche Text sein.

Also musste der Import normalisieren:

  • Bei ausgehenden Nachrichten: Du: entfernen.
  • Bei eingehenden Nachrichten: <displayName>: entfernen.
  • Danach führende Leerzeichen entfernen.
  • Hash und Message-ID erst aus dem normalisierten Text erzeugen.

Das klingt klein, ist aber wichtig. Schlechte Normalisierung erzeugt schlechte IDs. Schlechte IDs erzeugen Duplikate. Und Duplikate erzeugen jenen Zustand, in dem man nachts SQL schreibt und sich fragt, warum man nicht Florist geworden ist.

Idempotenz: Der wichtigste Qualitätsmaßstab

Ein Import ist erst dann gut, wenn man ihn mehrfach ausführen kann.

Das Ziel lautet:

Erster Lauf:
imported > 0

Zweiter Lauf:
imported = 0
skipped > 0
keine Duplikate

Für Kontakte gilt das ebenso wie für Nachrichten. Die Deduplizierung erfolgt bei Kontakten über:

platformAccountId + externalContactId

Bei Nachrichten über:

contactId + platformMessageId

Falls keine stabile Plattform-ID vorhanden ist, wird eine deterministische ID erzeugt aus:

externalContactId + direction + sentAt + sha256(body)

Der Body ist dabei der normalisierte Body. Nicht der rohe DOM-Text.

Idempotenz ist bei Scraping- und UI-Importen nicht optional. Sie ist die einzige Versicherung gegen kleine DOM-Schwankungen, unvollständige Ladevorgänge und wiederholte Sync-Läufe.

Der Weg zum Account-Sync

Zunächst gab es getrennte Aktionen:

  • Kontakte importieren
  • Nachrichten für einzelnen Kontakt importieren
  • Nachrichten für alle Kontakte importieren

Das ist für Entwicklung und Debugging sinnvoll. Für den echten Betrieb ist es zu manuell.

Deshalb wurde daraus ein Account-Sync:

app:tinder:sync-account 1

Der Ablauf:

  1. PlatformAccount laden.
  2. Session prüfen.
  3. Kontakte synchronisieren.
  4. Nur sichtbare Kontakte laden.
  5. Nachrichten pro sichtbarem Kontakt importieren.
  6. Missing-Kontakte überspringen.
  7. Fehler pro Kontakt isolieren.
  8. Ergebnis aggregieren.

Die Ausgabe sieht vereinfacht so aus:

{
  "success": true,
  "platformAccountId": 1,
  "contacts": {
    "imported": 0,
    "updated": 9,
    "markedVisible": 9,
    "markedMissing": 0,
    "skipped": 0
  },
  "messages": {
    "contactsProcessed": 9,
    "contactsSucceeded": 9,
    "contactsFailed": 0,
    "imported": 0,
    "updated": 0,
    "skipped": 408
  }
}

Damit gibt es einen einzigen fachlichen Sync-Vorgang. Genau das braucht man, wenn aus einem Experiment ein Bedienkonzept werden soll.

Warum asynchron?

Ein vollständiger Sync dauerte mehrere Minuten. Das ist für einen HTTP-Request ungünstig. Browserautomatisierung ist langsam, und sie darf langsam sein. Aber sie sollte nicht das Frontend blockieren.

Die Lösung: Symfony Messenger.

Der Button im Frontend startet nicht mehr direkt den Sync. Er legt einen Job an und dispatcht eine Message:

TinderAccountSyncMessage

Der Worker verarbeitet den Job im Hintergrund.

Dazu gibt es eine SyncJob-Entity mit Status:

queued
running
completed
failed

Der Frontend-Button wird deaktiviert, solange für denselben PlatformAccount ein Job mit queued oder running existiert. Damit lässt sich der Sync nicht versehentlich doppelt starten.

Das ist kein Komfortdetail, sondern Produktionshygiene. Lange Jobs brauchen Status. Und Buttons, die lange Jobs starten, brauchen eine Sperre. Sonst entsteht durch einen Doppelklick sehr schnell eine kleine Prozession identischer Browserfenster, die alle glauben, sie seien der Auserwählte.

Job-Übersicht im Frontend

Zur asynchronen Verarbeitung gehört eine Job-Übersicht. Dort werden die letzten Sync-Jobs angezeigt:

  • Typ
  • PlatformAccount
  • Status
  • Startzeit
  • Endzeit
  • Zusammenfassung
  • Fehlertext

Das ist nicht nur nett. Es ist notwendig. Wenn ein Job im Hintergrund läuft, braucht der Nutzer eine Antwort auf die Frage:

Arbeitet das Ding noch, oder denkt es nur sehr gründlich nach?

Die Arbeitsliste pollt zusätzlich den Jobstatus. Sobald kein Job mehr läuft, wird die Kontaktliste neu geladen.

Manuell oder Autopilot?

Ein wichtiger fachlicher Punkt ist der Automationsmodus pro Kontakt.

Jeder Kontakt bekommt:

automationMode:
- manual
- autopilot

Standard ist manual.

Das ist bewusst konservativ. Neue Kontakte werden nicht automatisch bedient. Sie werden importiert, angezeigt und bleiben manuell. Erst wenn ich den Schalter in der Arbeitsliste setze, darf dieser Kontakt später in eine automatische Pipeline laufen.

Der Schalter gehört nur in die Arbeitsliste. Nicht in die Chatansicht, nicht in die Detailansicht, nicht an drei Stellen. Eine Steuerung, eine Quelle, ein Zustand.

Das vermeidet widersprüchliche UI-Zustände. Vor allem vermeidet es, dass man irgendwann nicht mehr weiß, ob der Autopilot jetzt wirklich aktiv ist oder nur irgendwo dekorativ herumsteht.

OpenAPI und API-Aufräumen

Während der Entwicklung entstanden mehrere Endpunkte. Einige waren frühe Scaffolds, andere produktive Domain-Endpunkte, wieder andere Diagnose-Endpunkte.

Das ist normal. Aber es muss aufgeräumt werden.

OpenAPI hilft hier nicht nur bei Dokumentation, sondern auch bei Architekturkontrolle. Wenn die API als JSON ausgegeben werden kann, sieht man schnell:

  • doppelte fachliche Endpunkte
  • Legacy-Routen
  • unklare Benennungen
  • Debug-Endpunkte
  • produktive Endpunkte
  • veraltete Controller

Im Projekt gab es beispielsweise alte /api/dating/*-Routen, während die neuere Architektur domainfähige /api/contacts, /api/platform-accounts und /api/messages nutzte. Die alten Endpunkte wurden entfernt.

Ebenfalls entfernt wurde ein Legacy-Endpunkt:

PATCH /api/contacts/{id}/autopilot

Ersetzt durch:

PATCH /api/contacts/{id}/automation-mode

Das ist fachlich sauberer, weil „Autopilot“ nicht einfach ein Boolean ist, sondern ein Betriebsmodus.

Diagnose-Endpunkte: Behalten, aber einhegen

Für die Entwicklung waren Diagnose-Endpunkte extrem hilfreich:

POST /api/platform-accounts/{id}/diagnose-contacts-import
POST /api/contacts/{id}/diagnose-platform-messages

Sie lesen DOM-Strukturen, geben reduzierte Kandidaten zurück und helfen, Selektoren zu stabilisieren.

Solche Endpunkte sind wertvoll, sollten aber nicht irgendwann achtlos in der öffentlichen API herumstehen. Langfristig gehören sie hinter Admin-Rechte, in den Dev-Modus oder zumindest klar als Diagnose markiert.

Der Unterschied ist wichtig:

  • Import-Endpunkte sind Teil des Produkts.
  • Diagnose-Endpunkte sind Werkstattlampen.

Werkstattlampen sind nützlich. Aber man baut sie nicht ins Armaturenbrett.

Was bewusst noch nicht passiert

Bis hierhin gibt es keinen echten Autopiloten.

Das System importiert Kontakte und Nachrichten. Es synchronisiert. Es zeigt Status. Es kann unterscheiden, ob ein Kontakt manuell oder automatisch bedient werden dürfte.

Aber es sendet nichts.

Das ist kein fehlendes Feature, sondern Absicht.

Bevor automatische Antworten entstehen, müssen weitere Schichten kommen:

  1. Gesprächsanalyse
  2. Risiko- und Dealbreaker-Erkennung
  3. Zieldefinition
  4. Antwortgenerierung
  5. Truth Guard
  6. Sendefreigabe oder strenge Autopilot-Regeln
  7. Logging
  8. Not-Aus

Gerade bei Dating-Kommunikation ist „funktioniert technisch“ nicht genug. Kommunikation ist sozial. Automatisierung ist hier nur dann vertretbar, wenn sie streng begrenzt, nachvollziehbar und jederzeit deaktivierbar ist.

Technische Lehren

1. Browserautomatisierung braucht Diagnose

Man schreibt nicht sofort den Import. Man schreibt zuerst Diagnose. Welche Elemente sind sichtbar? Welche Rollen gibt es? Wo sind stabile IDs? Was ist nur Dekoration? Was ist Nachricht, was ist Zeitstempel, was ist Container?

Ohne Diagnose baut man Selektoren nach Gefühl. Das funktioniert ungefähr so zuverlässig wie Dating nach Horoskop.

2. Idempotenz ist Pflicht

Jeder Import muss mehrfach laufen können. Kein Import darf blind neue Daten erzeugen. Externe IDs, Hashes und Deduplizierung sind zentral.

3. Nichts löschen

Wenn ein externer Kontakt verschwindet, wird er markiert, nicht gelöscht. Externe Sichtbarkeit ist kein Beweis für Nichtexistenz.

4. Lange Prozesse gehören in Jobs

Ein Sync, der mehrere Minuten dauert, gehört nicht in einen normalen HTTP-Request. Messenger, Job-Status und Frontend-Rückmeldung sind hier Pflicht.

5. Ein Zustand braucht eine Quelle

Der Autopilot-Status darf nicht gleichzeitig in metadata, qualification, autopilotEnabled und automationMode leben. Eine Quelle. Eine Anzeige. Eine Änderung.

6. Debug-Ausgaben dürfen keine privaten Inhalte verschütten

Import-Responses brauchen keine vollständigen Nachrichtentexte. Textlänge, Richtung, Zeitstempel und Status reichen fast immer.

Fazit

Am Ende ist dieses Projekt weniger ein Tinder-Projekt als ein Integrationsprojekt.

Tinder ist nur die Oberfläche. Die eigentlichen Themen sind allgemeiner:

  • Wie automatisiert man eine fremde Weboberfläche kontrolliert?
  • Wie hält man lokale Daten synchron, ohne zu löschen?
  • Wie baut man idempotente Imports?
  • Wie trennt man Diagnose, Import und Automatisierung?
  • Wie verhindert man, dass ein Button versehentlich drei Hintergrundprozesse startet?
  • Wie bereitet man KI-gestützte Workflows vor, ohne vorschnell Kontrolle abzugeben?

Die Antwort ist nicht ein einzelnes Skript. Die Antwort ist Architektur.

Ein Browser kann klicken. Ein Skript kann lesen. Eine KI kann Text erzeugen. Aber ein brauchbares System entsteht erst durch Zustände, Grenzen, Protokolle, Job-Status, Deduplizierung und eine gesunde Skepsis gegenüber allem, was „nur mal eben“ automatisiert werden soll.

Oder kürzer:

Erst synchronisieren. Dann verstehen. Dann vielleicht automatisieren.

Und selbst dann nur mit Schalter.

0 Kommentare