feat(hinge-auto): auto-like Hinge web (Playwright + Chromium, profil persistant)

Trois binaires dans le profil systeme, patron de services/amazon-mcp :
- hinge-auto-login    chromium visible (telephone + SMS), session persistee dans /var/lib/hermes/hinge-auto/user-data
- hinge-auto-like     boucle de likes headless sur Discover (HINGE_MAX_LIKES defaut 40, detection de fin de flux)
- hinge-auto-calibrate dump des boutons pour affiner les selecteurs

Profil partage hermes <-> marc via ACL tmpfiles. Garde-fous : limite ~8 likes/jour gratuite Hinge, arret sur marqueur de fin.
This commit is contained in:
Hermes Agent
2026-10-11 19:33:17 -04:00
parent 0973d87f41
commit a4cc76163b
4 changed files with 253 additions and 0 deletions
+4
View File
@@ -6,3 +6,7 @@ secrets/*.dec
secrets/*.unencrypted secrets/*.unencrypted
result* result*
*.iso *.iso
# Python bytecode (ex. services/hinge-auto/__pycache__/)
__pycache__/
*.pyc
+1
View File
@@ -49,6 +49,7 @@
./eternal-terminal ./eternal-terminal
./nextcloud-mcp ./nextcloud-mcp
./amazon-mcp ./amazon-mcp
./hinge-auto
./firefox-mcp ./firefox-mcp
# ./stalwart-mcp # FIXME: cargoHash mismatch, needs fix # ./stalwart-mcp # FIXME: cargoHash mismatch, needs fix
./gotify ./gotify
+65
View File
@@ -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"
];
}
+183
View File
@@ -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()