diff --git a/.gitignore b/.gitignore index 84b15a4..6cbcbf3 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ secrets/*.dec secrets/*.unencrypted result* *.iso + +# Python bytecode (ex. services/hinge-auto/__pycache__/) +__pycache__/ +*.pyc diff --git a/services/default.nix b/services/default.nix index 88cb6a9..c77a761 100644 --- a/services/default.nix +++ b/services/default.nix @@ -49,6 +49,7 @@ ./eternal-terminal ./nextcloud-mcp ./amazon-mcp + ./hinge-auto ./firefox-mcp # ./stalwart-mcp # FIXME: cargoHash mismatch, needs fix ./gotify diff --git a/services/hinge-auto/default.nix b/services/hinge-auto/default.nix new file mode 100644 index 0000000..07e39b5 --- /dev/null +++ b/services/hinge-auto/default.nix @@ -0,0 +1,65 @@ +# services/hinge-auto/default.nix +# Auto-like Hinge web (app.hinge.co) via Playwright + Chromium, sur le patron +# exact de services/amazon-mcp : profil chromium persistant dans +# /var/lib/hermes/hinge-auto/user-data, ACL marc pour le login manuel visible. +# +# Deux binaires installes dans le profil systeme : +# hinge-auto-login chromium VISIBLE, a lancer depuis une session graphique +# de marc (wayvnc/kasm) : telephone + SMS, Entree = sauve. +# Un seul login tenace jusqu'a expiration de session. +# hinge-auto-like boucle de likes headless sur Discover (HINGE_HEADLESS=0 +# pour voir ce que fait le navigateur). Garde-fous : +# HINGE_MAX_LIKES (defaut 40) + detection de fin de flux. +# hinge-auto-calibrate dump des boutons pour affiner les selecteurs. +# +# NB : le profil persistant ne supporte PAS deux instances chromium en meme +# temps (lock) : ne pas lancer login pendant qu'un like tourne. +# +# Limite gratuite Hinge : ~8 likes/jour (Hinge+/X = illimite). Le script s'arrete +# sur le marqueur de fin, pas sur un compte fixe. +{ pkgs, ... }: +let + python = pkgs.python312; + py = python.pkgs; + + script = pkgs.writeText "hinge_auto.py" (builtins.readFile ./hinge_auto.py); + + playwrightPython = python.withPackages (ps: [ ps.playwright ]); +in { + # Helpers dispo pour marc (login visible) et pour un run serveur (like). + environment.systemPackages = [ + (pkgs.writeShellApplication { + name = "hinge-auto-login"; + runtimeInputs = [ playwrightPython ]; + text = '' + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + exec python3 ${script} login + ''; + }) + (pkgs.writeShellApplication { + name = "hinge-auto-like"; + runtimeInputs = [ playwrightPython ]; + text = '' + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + export HINGE_HEADLESS="''${HINGE_HEADLESS:-1}" + exec python3 ${script} like + ''; + }) + (pkgs.writeShellApplication { + name = "hinge-auto-calibrate"; + runtimeInputs = [ playwrightPython ]; + text = '' + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + exec python3 ${script} calibrate + ''; + }) + ]; + + # Profil persistant partage hermes <-> marc (login). Pattern amazon-mcp : + # /var/lib/hermes en 2770 hermes:hermes, marc a besoin du --x de traversee. + systemd.tmpfiles.rules = [ + "a /var/lib/hermes - - - - u:marc:--x,m::rwx" + "d /var/lib/hermes/hinge-auto 0750 hermes hermes - -" + "a /var/lib/hermes/hinge-auto - - - - u:marc:rwx,d:u:marc:rwx,d:u:hermes:rwx,m::rwx,d:m::rwx" + ]; +} \ No newline at end of file diff --git a/services/hinge-auto/hinge_auto.py b/services/hinge-auto/hinge_auto.py new file mode 100644 index 0000000..dcbd131 --- /dev/null +++ b/services/hinge-auto/hinge_auto.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Auto-like Hinge web (app.hinge.co), pilote par Playwright + Chromium. + +Modes : + login Chromium VISIBLE (aucun headless) : tu te connectes (telephone + SMS), + tu arrives sur le flux Discover, puis Entree dans le terminal = session + persistee dans le profil. A lancer depuis une session graphique de marc. + like Profil persistant + boucle de likes dans Discover jusqu'a la limite + journaliere ou l'epuisement du flux. Headless par defaut (serveur). + calibrate Vide la structure des boutons du flux Discover (textes, aria-labels, + data-testid) pour affiner les selecteurs sans lire le DOM a la main. + +Env : + HINGE_USER_DATA_DIR profil persistant (defaut /var/lib/hermes/hinge-auto/user-data) + HINGE_HEADLESS 0|1 pour le mode like (defaut 1 = headless serveur) + HINGE_MAX_LIKES nombre max de likes avant arret (defaut 40, garde-fou) +""" +import os +import random +import sys +import time + +from playwright.sync_api import sync_playwright + +BASE = "https://app.hinge.co" +DISCOVER = BASE + "/discover" +DATA_DIR = os.environ.get("HINGE_USER_DATA_DIR", "/var/lib/hermes/hinge-auto/user-data") +MAX_LIKES = int(os.environ.get("HINGE_MAX_LIKES", "40")) + +# Selecteurs candidats pour le bouton Like d'une fiche (a calibrer sur le DOM reel). +LIKE_SELECTORS = [ + "button[aria-label*='like' i]", + "[data-testid*='like' i]", + "button:has(svg[aria-label*='Like'])", + "button[title*='Like']", +] + +# Boutons de confirmation du dialog apres un like. +CONFIRM_SELECTORS = [ + "button:has-text('Send Like')", + "button:has-text('Send a Like')", + "button:has-text('Like')", + "text=Send Like", + "text=Send a Like", +] + +# Marqueurs de fin (aucun like possible / flux epuise). +END_MARKERS = [ + "all caught up", + "out of likes", + "no more likes", + "seen everyone", + "come back", + "find more", +] + + +def human_delay(a=1.0, b=3.0): + time.sleep(random.uniform(a, b)) + + +def _ctx(p, headless): + return p.chromium.launch_persistent_context( + DATA_DIR, + headless=headless, + args=["--disable-blink-features=AutomationControlled"], + ) + + +def _page_text(page): + try: + return (page.locator("body").inner_text() or "").lower() + except Exception: + return "" + + +def click_first(page, selectors, timeout=6000): + """Clique le premier selecteur qui matche ; renvoie le selecteur ou None.""" + for sel in selectors: + try: + el = page.locator(sel).first + el.wait_for(state="visible", timeout=timeout) + el.click() + return sel + except Exception: + continue + return None + + +def do_login(p): + ctx = _ctx(p, headless=False) + try: + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(DISCOVER, wait_until="domcontentloaded") + input( + "\nConnecte-toi dans la fenetre Chromium (telephone + code SMS), " + "arrive sur le flux Discover, puis reviens ici et appuie sur Entree " + "pour sauver la session... " + ) + # laisser quelques secondes pour que les cookies/localStorage soient flushes + time.sleep(2) + finally: + ctx.close() + print("Session persistee dans " + DATA_DIR) + + +def do_calibrate(p): + ctx = _ctx(p, headless=False) + try: + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(DISCOVER, wait_until="domcontentloaded") + input("Va sur une fiche Discover, puis reviens et appuie sur Entree pour dump... ") + print("=== BOUTONS ===") + for i, b in enumerate(page.locator("button").all()[:60]): + try: + label = b.get_attribute("aria-label") or "" + tip = b.get_attribute("title") or "" + tid = b.get_attribute("data-testid") or "" + txt = (b.inner_text() or "").strip().replace("\n", " ")[:40] + if label or tip or tid or txt: + print(f"[{i}] aria={label!r} title={tip!r} testid={tid!r} text={txt!r}") + except Exception: + continue + print("=== TITLE === ", page.title()) + print("=== URL === ", page.url) + finally: + ctx.close() + + +def do_like(p, headless): + ctx = _ctx(p, headless=headless) + sent = 0 + try: + page = ctx.pages[0] if ctx.pages else ctx.new_page() + page.goto(DISCOVER, wait_until="domcontentloaded") + human_delay(3, 5) + + # Si la navigation n'a pas deja mene au flux, on tente l'URL Discover. + if "discover" not in page.url.lower(): + page.goto(DISCOVER, wait_until="domcontentloaded") + human_delay(3, 5) + + while sent < MAX_LIKES: + txt = _page_text(page) + if any(m in txt for m in END_MARKERS): + print(f"[stop] flux epuise ou limite atteinte apres {sent} likes : marqueur trouve") + break + + liked = click_first(page, LIKE_SELECTORS, timeout=4000) + if not liked: + print(f"[stop] aucun bouton Like trouve apres {sent} likes (page: {page.url})") + break + + sent += 1 + print(f"[like] +1 (selecteur: {liked}) total={sent}") + human_delay(1, 2) + + # Dialog de confirmation eventuel : l'envoyer si present, sinon la + # fiche suivante apparait d'elle-meme. + conf = click_first(page, CONFIRM_SELECTORS, timeout=2500) + if conf: + print(f" confirm: {conf}") + human_delay(1.5, 3.5) + + print(f"[done] {sent} likes envoyes") + finally: + ctx.close() + + +def main(): + mode = sys.argv[1] if len(sys.argv) > 1 else "like" + headless = os.environ.get("HINGE_HEADLESS", "1") != "0" + with sync_playwright() as p: + if mode == "login": + do_login(p) + elif mode == "calibrate": + do_calibrate(p) + else: + do_like(p, headless) + + +if __name__ == "__main__": + main() \ No newline at end of file