ghlike 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ghlike-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ghlike contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
ghlike-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghlike
3
+ Version: 0.1.0
4
+ Summary: Like (star) repos on GitHub with your existing browser session — no tokens, no API keys.
5
+ Author: ghlike contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/686f6c61/ghlike-py
8
+ Project-URL: Repository, https://github.com/686f6c61/ghlike-py
9
+ Project-URL: Issues, https://github.com/686f6c61/ghlike-py/issues
10
+ Project-URL: Changelog, https://github.com/686f6c61/ghlike-py/releases
11
+ Keywords: github,star,like,cli,browser-session
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # ghlike
24
+
25
+ **Star GitHub repos with your browser session — no tokens. / Da estrellas
26
+ en GitHub con la sesión de tu navegador — sin tokens.**
27
+
28
+ [English](#english) · [Español](#español)
29
+
30
+ This is the Python CLI of the [ghlike project](https://github.com/686f6c61/ghlike)
31
+ (like-button widget + browser extension + Node CLI live there). Zero
32
+ dependencies: the CDP WebSocket client is implemented on the standard
33
+ library, so the whole package audits in one sitting.
34
+
35
+ ---
36
+
37
+ # English
38
+
39
+ ## What it does
40
+
41
+ `ghlike owner/repo` stars a GitHub repository using the login session
42
+ already active in your browser. No `gh auth login`, no personal access
43
+ tokens, no OAuth: if you're logged into GitHub in Brave, Chrome, Chromium,
44
+ Edge, Vivaldi or Opera (native or snap installs), it just works.
45
+
46
+ How: it finds the browser profile with your github.com session → copies the
47
+ **encrypted** cookie store to a throwaway profile → opens your own browser
48
+ headless with a private debug port → clicks the real Star button (GitHub
49
+ handles its own CSRF) → verifies → kills the browser and deletes the
50
+ profile. Cookies are never decrypted and never leave your machine.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pipx install ghlike # or: pip install --user ghlike
56
+ ```
57
+
58
+ Requires Python ≥ 3.9 and a Chromium-family browser with a github.com
59
+ session. Firefox is not supported (no CDP).
60
+
61
+ ## CLI
62
+
63
+ ```bash
64
+ ghlike owner/repo # star (idempotent — tells you if already starred)
65
+ ghlike https://github.com/o/r # full URLs work
66
+ ghlike owner/repo -c # check current state only
67
+ ghlike owner/repo -u # unstar
68
+ ghlike owner/repo --toggle # flip
69
+ ghlike --list # github.com sessions found in your browsers
70
+ ghlike --browser brave o/r # force one browser (strict)
71
+ ghlike o/r --json # machine-readable output
72
+ ```
73
+
74
+ Exit codes: `0` ok (incl. nothing-to-do) · `1` generic error · `2` no
75
+ github.com session · `3` repo not found.
76
+
77
+ JSON example:
78
+
79
+ ```bash
80
+ $ ghlike cli/cli --check --json
81
+ {"repo":"cli/cli","user":"you","browser":"Brave","profile":"Default",
82
+ "before":false,"action":"check","after":false,"changed":false}
83
+ ```
84
+
85
+ ## Library
86
+
87
+ ```python
88
+ from ghlike import star, unstar, check, toggle
89
+
90
+ star("vercel/next.js") # -> {"repo": ..., "changed": True, ...}
91
+ state = check("vercel/next.js") # -> {"repo": ..., "after": True/False, ...}
92
+ unstar("vercel/next.js", browser="brave")
93
+ toggle("vercel/next.js")
94
+ ```
95
+
96
+ Exceptions: `NoSessionError`, `RepoNotFoundError`, `GhlikeError`.
97
+
98
+ ## Limitations
99
+
100
+ - Chromium-family browsers only; Firefox is not supported (no CDP).
101
+ - Each operation takes ~8–12 s (headless browser + page load). That's the
102
+ price of the no-token approach.
103
+ - Depends on GitHub's Star button; if the markup changes, one selector line
104
+ fixes it (`STATE_JS` in `src/ghlike/core.py`).
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ PYTHONPATH=src python3 -m ghlike.cli --list
110
+ python -m pip wheel . --no-deps --no-build-isolation -w dist
111
+ ```
112
+
113
+ Publishing is automated: push a git tag / create a GitHub Release `vX.Y.Z`
114
+ and `.github/workflows/publish-pypi.yml` publishes to PyPI via
115
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no
116
+ secrets). Versions live in `pyproject.toml` and `src/ghlike/__init__.py`.
117
+
118
+ License: MIT.
119
+
120
+ ---
121
+
122
+ # Español
123
+
124
+ ## Qué hace
125
+
126
+ `ghlike owner/repo` da una estrella a un repositorio de GitHub usando la
127
+ sesión ya iniciada en tu navegador. Sin `gh auth login`, sin personal access
128
+ tokens, sin OAuth: si estás logueado en GitHub en Brave, Chrome, Chromium,
129
+ Edge, Vivaldi u Opera (nativo o snap), funciona directamente.
130
+
131
+ Cómo: busca el perfil del navegador con tu sesión de github.com → copia la
132
+ tienda de cookies **cifrada** a un perfil desechable → abre tu propio
133
+ navegador en modo invisible con un puerto de depuración privado → pulsa el
134
+ botón de Star real (GitHub gestiona su propio CSRF) → verifica → cierra el
135
+ navegador y borra el perfil. Las cookies nunca se descifran ni salen de tu
136
+ máquina.
137
+
138
+ ## Instalación
139
+
140
+ ```bash
141
+ pipx install ghlike # o: pip install --user ghlike
142
+ ```
143
+
144
+ Requiere Python ≥ 3.9 y un navegador de la familia Chromium con sesión de
145
+ github.com. Firefox no está soportado (sin CDP).
146
+
147
+ ## CLI
148
+
149
+ ```bash
150
+ ghlike owner/repo # dar star (idempotente)
151
+ ghlike https://github.com/o/r # URLs completas valen
152
+ ghlike owner/repo -c # solo consultar estado
153
+ ghlike owner/repo -u # quitar star
154
+ ghlike owner/repo --toggle # invertir
155
+ ghlike --list # sesiones de GitHub detectadas
156
+ ghlike --browser brave o/r # forzar un navegador (estricto)
157
+ ghlike o/r --json # salida para scripts
158
+ ```
159
+
160
+ Códigos de salida: `0` ok (también "nada que hacer") · `1` error genérico ·
161
+ `2` sin sesión de github.com · `3` repo no encontrado.
162
+
163
+ ## Librería
164
+
165
+ ```python
166
+ from ghlike import star, unstar, check, toggle
167
+
168
+ star("vercel/next.js") # -> {"repo": ..., "changed": True, ...}
169
+ state = check("vercel/next.js") # -> {"repo": ..., "after": True/False, ...}
170
+ unstar("vercel/next.js", browser="brave")
171
+ ```
172
+
173
+ Excepciones: `NoSessionError`, `RepoNotFoundError`, `GhlikeError`.
174
+
175
+ ## Limitaciones
176
+
177
+ - Solo navegadores Chromium; Firefox no (sin CDP).
178
+ - Cada operación tarda ~8–12 s (navegador invisible + carga). Es el precio
179
+ del enfoque sin tokens.
180
+ - Depende del botón de Star de GitHub; si cambia el markup, se arregla con
181
+ una línea (`STATE_JS` en `src/ghlike/core.py`).
182
+
183
+ ## Desarrollo
184
+
185
+ ```bash
186
+ PYTHONPATH=src python3 -m ghlike.cli --list
187
+ python -m pip wheel . --no-deps --no-build-isolation -w dist
188
+ ```
189
+
190
+ La publicación es automática: crea un Release `vX.Y.Z` en GitHub y
191
+ `.github/workflows/publish-pypi.yml` publica en PyPI con Trusted Publishing
192
+ (sin secrets). Las versiones viven en `pyproject.toml` y
193
+ `src/ghlike/__init__.py`.
194
+
195
+ Licencia: MIT.
ghlike-0.1.0/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # ghlike
2
+
3
+ **Star GitHub repos with your browser session — no tokens. / Da estrellas
4
+ en GitHub con la sesión de tu navegador — sin tokens.**
5
+
6
+ [English](#english) · [Español](#español)
7
+
8
+ This is the Python CLI of the [ghlike project](https://github.com/686f6c61/ghlike)
9
+ (like-button widget + browser extension + Node CLI live there). Zero
10
+ dependencies: the CDP WebSocket client is implemented on the standard
11
+ library, so the whole package audits in one sitting.
12
+
13
+ ---
14
+
15
+ # English
16
+
17
+ ## What it does
18
+
19
+ `ghlike owner/repo` stars a GitHub repository using the login session
20
+ already active in your browser. No `gh auth login`, no personal access
21
+ tokens, no OAuth: if you're logged into GitHub in Brave, Chrome, Chromium,
22
+ Edge, Vivaldi or Opera (native or snap installs), it just works.
23
+
24
+ How: it finds the browser profile with your github.com session → copies the
25
+ **encrypted** cookie store to a throwaway profile → opens your own browser
26
+ headless with a private debug port → clicks the real Star button (GitHub
27
+ handles its own CSRF) → verifies → kills the browser and deletes the
28
+ profile. Cookies are never decrypted and never leave your machine.
29
+
30
+ ## Install
31
+
32
+ ```bash
33
+ pipx install ghlike # or: pip install --user ghlike
34
+ ```
35
+
36
+ Requires Python ≥ 3.9 and a Chromium-family browser with a github.com
37
+ session. Firefox is not supported (no CDP).
38
+
39
+ ## CLI
40
+
41
+ ```bash
42
+ ghlike owner/repo # star (idempotent — tells you if already starred)
43
+ ghlike https://github.com/o/r # full URLs work
44
+ ghlike owner/repo -c # check current state only
45
+ ghlike owner/repo -u # unstar
46
+ ghlike owner/repo --toggle # flip
47
+ ghlike --list # github.com sessions found in your browsers
48
+ ghlike --browser brave o/r # force one browser (strict)
49
+ ghlike o/r --json # machine-readable output
50
+ ```
51
+
52
+ Exit codes: `0` ok (incl. nothing-to-do) · `1` generic error · `2` no
53
+ github.com session · `3` repo not found.
54
+
55
+ JSON example:
56
+
57
+ ```bash
58
+ $ ghlike cli/cli --check --json
59
+ {"repo":"cli/cli","user":"you","browser":"Brave","profile":"Default",
60
+ "before":false,"action":"check","after":false,"changed":false}
61
+ ```
62
+
63
+ ## Library
64
+
65
+ ```python
66
+ from ghlike import star, unstar, check, toggle
67
+
68
+ star("vercel/next.js") # -> {"repo": ..., "changed": True, ...}
69
+ state = check("vercel/next.js") # -> {"repo": ..., "after": True/False, ...}
70
+ unstar("vercel/next.js", browser="brave")
71
+ toggle("vercel/next.js")
72
+ ```
73
+
74
+ Exceptions: `NoSessionError`, `RepoNotFoundError`, `GhlikeError`.
75
+
76
+ ## Limitations
77
+
78
+ - Chromium-family browsers only; Firefox is not supported (no CDP).
79
+ - Each operation takes ~8–12 s (headless browser + page load). That's the
80
+ price of the no-token approach.
81
+ - Depends on GitHub's Star button; if the markup changes, one selector line
82
+ fixes it (`STATE_JS` in `src/ghlike/core.py`).
83
+
84
+ ## Development
85
+
86
+ ```bash
87
+ PYTHONPATH=src python3 -m ghlike.cli --list
88
+ python -m pip wheel . --no-deps --no-build-isolation -w dist
89
+ ```
90
+
91
+ Publishing is automated: push a git tag / create a GitHub Release `vX.Y.Z`
92
+ and `.github/workflows/publish-pypi.yml` publishes to PyPI via
93
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no
94
+ secrets). Versions live in `pyproject.toml` and `src/ghlike/__init__.py`.
95
+
96
+ License: MIT.
97
+
98
+ ---
99
+
100
+ # Español
101
+
102
+ ## Qué hace
103
+
104
+ `ghlike owner/repo` da una estrella a un repositorio de GitHub usando la
105
+ sesión ya iniciada en tu navegador. Sin `gh auth login`, sin personal access
106
+ tokens, sin OAuth: si estás logueado en GitHub en Brave, Chrome, Chromium,
107
+ Edge, Vivaldi u Opera (nativo o snap), funciona directamente.
108
+
109
+ Cómo: busca el perfil del navegador con tu sesión de github.com → copia la
110
+ tienda de cookies **cifrada** a un perfil desechable → abre tu propio
111
+ navegador en modo invisible con un puerto de depuración privado → pulsa el
112
+ botón de Star real (GitHub gestiona su propio CSRF) → verifica → cierra el
113
+ navegador y borra el perfil. Las cookies nunca se descifran ni salen de tu
114
+ máquina.
115
+
116
+ ## Instalación
117
+
118
+ ```bash
119
+ pipx install ghlike # o: pip install --user ghlike
120
+ ```
121
+
122
+ Requiere Python ≥ 3.9 y un navegador de la familia Chromium con sesión de
123
+ github.com. Firefox no está soportado (sin CDP).
124
+
125
+ ## CLI
126
+
127
+ ```bash
128
+ ghlike owner/repo # dar star (idempotente)
129
+ ghlike https://github.com/o/r # URLs completas valen
130
+ ghlike owner/repo -c # solo consultar estado
131
+ ghlike owner/repo -u # quitar star
132
+ ghlike owner/repo --toggle # invertir
133
+ ghlike --list # sesiones de GitHub detectadas
134
+ ghlike --browser brave o/r # forzar un navegador (estricto)
135
+ ghlike o/r --json # salida para scripts
136
+ ```
137
+
138
+ Códigos de salida: `0` ok (también "nada que hacer") · `1` error genérico ·
139
+ `2` sin sesión de github.com · `3` repo no encontrado.
140
+
141
+ ## Librería
142
+
143
+ ```python
144
+ from ghlike import star, unstar, check, toggle
145
+
146
+ star("vercel/next.js") # -> {"repo": ..., "changed": True, ...}
147
+ state = check("vercel/next.js") # -> {"repo": ..., "after": True/False, ...}
148
+ unstar("vercel/next.js", browser="brave")
149
+ ```
150
+
151
+ Excepciones: `NoSessionError`, `RepoNotFoundError`, `GhlikeError`.
152
+
153
+ ## Limitaciones
154
+
155
+ - Solo navegadores Chromium; Firefox no (sin CDP).
156
+ - Cada operación tarda ~8–12 s (navegador invisible + carga). Es el precio
157
+ del enfoque sin tokens.
158
+ - Depende del botón de Star de GitHub; si cambia el markup, se arregla con
159
+ una línea (`STATE_JS` en `src/ghlike/core.py`).
160
+
161
+ ## Desarrollo
162
+
163
+ ```bash
164
+ PYTHONPATH=src python3 -m ghlike.cli --list
165
+ python -m pip wheel . --no-deps --no-build-isolation -w dist
166
+ ```
167
+
168
+ La publicación es automática: crea un Release `vX.Y.Z` en GitHub y
169
+ `.github/workflows/publish-pypi.yml` publica en PyPI con Trusted Publishing
170
+ (sin secrets). Las versiones viven en `pyproject.toml` y
171
+ `src/ghlike/__init__.py`.
172
+
173
+ Licencia: MIT.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ghlike"
7
+ version = "0.1.0"
8
+ description = "Like (star) repos on GitHub with your existing browser session — no tokens, no API keys."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "ghlike contributors" }]
13
+ keywords = ["github", "star", "like", "cli", "browser-session"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development",
21
+ ]
22
+ # Cero dependencias a proposito: el paquete toca sesiones del navegador y
23
+ # debe poder auditarse por completo.
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/686f6c61/ghlike-py"
27
+ Repository = "https://github.com/686f6c61/ghlike-py"
28
+ Issues = "https://github.com/686f6c61/ghlike-py/issues"
29
+ Changelog = "https://github.com/686f6c61/ghlike-py/releases"
30
+
31
+ [project.scripts]
32
+ ghlike = "ghlike.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
ghlike-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """ghlike: da likes (stars) en GitHub con la sesion de tu navegador, sin tokens."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from .core import GhlikeError, NoSessionError, RepoNotFoundError, check, run, star, toggle, unstar # noqa: F401
@@ -0,0 +1,182 @@
1
+ """Detección de navegadores Chromium, sus perfiles y la sesión de GitHub dentro.
2
+
3
+ Funciona con instalaciones nativas y empaquetadas (snap en Linux). No se
4
+ descifra nada: solo se comprueba qué perfil tiene cookies de github.com y se
5
+ copia la tienda tal cual a un directorio temporal que el propio navegador
6
+ sabrá abrir.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import glob
11
+ import os
12
+ import shutil
13
+ import sqlite3
14
+ import tempfile
15
+ from dataclasses import dataclass, field
16
+ from typing import List, Optional
17
+
18
+ SESSION_COOKIES = ("user_session", "__Host-user_session_same_site", "logged_in", "_gh_sess")
19
+
20
+
21
+ @dataclass
22
+ class Browser:
23
+ name: str
24
+ label: str
25
+ exe_candidates: List[str] = field(default_factory=list)
26
+ root_globs: List[str] = field(default_factory=list)
27
+
28
+
29
+ BROWSERS = [
30
+ Browser("brave", "Brave",
31
+ ["/snap/bin/brave", "/usr/bin/brave-browser", "/usr/bin/brave",
32
+ "/usr/local/bin/brave-browser", "/opt/brave.com/brave/brave-browser",
33
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
34
+ r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
35
+ r"C:\Program Files (x86)\BraveSoftware\Brave-Browser\Application\brave.exe"],
36
+ ["~/.config/BraveSoftware/Brave-Browser",
37
+ "~/snap/brave/*/.config/BraveSoftware/Brave-Browser"]),
38
+ Browser("chrome", "Google Chrome",
39
+ ["/usr/bin/google-chrome", "/usr/bin/google-chrome-stable",
40
+ "/usr/bin/google-chrome-beta", "/opt/google/chrome/chrome",
41
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
42
+ r"C:\Program Files\Google\Chrome\Application\chrome.exe",
43
+ r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe"],
44
+ ["~/.config/google-chrome", "~/snap/google-chrome/*/.config/google-chrome"]),
45
+ Browser("chromium", "Chromium",
46
+ ["/snap/bin/chromium", "/usr/bin/chromium", "/usr/bin/chromium-browser",
47
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
48
+ r"C:\Program Files\Chromium\Application\chrome.exe"],
49
+ ["~/.config/chromium", "~/snap/chromium/*/.config/chromium"]),
50
+ Browser("edge", "Microsoft Edge",
51
+ ["/usr/bin/microsoft-edge", "/usr/bin/microsoft-edge-stable",
52
+ r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"],
53
+ ["~/.config/microsoft-edge"]),
54
+ Browser("vivaldi", "Vivaldi",
55
+ ["/usr/bin/vivaldi", "/usr/bin/vivaldi-stable",
56
+ "/Applications/Vivaldi.app/Contents/MacOS/Vivaldi"],
57
+ ["~/.config/vivaldi"]),
58
+ Browser("opera", "Opera",
59
+ ["/usr/bin/opera", "/usr/lib/x86_64-linux-gnu/opera/opera"],
60
+ ["~/.config/opera"]),
61
+ ]
62
+
63
+
64
+ @dataclass
65
+ class SessionInfo:
66
+ browser: Browser
67
+ exe: str
68
+ profile_dir: str # ruta absoluta del perfil (".../Default", ".../Profile 1")
69
+ profile_name: str # "Default", "Profile 1", ...
70
+ root_dir: str # ruta del navegador (contiene "Local State")
71
+ cookie_count: int
72
+
73
+
74
+ def resolve_exe(browser: Browser) -> Optional[str]:
75
+ for cand in browser.exe_candidates:
76
+ p = os.path.expanduser(cand)
77
+ if os.path.isfile(p) and os.access(p, os.X_OK):
78
+ return p
79
+ which = shutil.which(os.path.basename(cand)) if not os.path.isabs(cand) or os.sep not in cand else None
80
+ if which:
81
+ return which
82
+ which = shutil.which(browser.name)
83
+ return which
84
+
85
+
86
+ def _profile_dirs(root: str) -> List[str]:
87
+ dirs = [d for d in glob.glob(os.path.join(root, "Default")) +
88
+ glob.glob(os.path.join(root, "Profile *")) if os.path.isdir(d)]
89
+ dirs.sort(key=lambda d: os.path.getmtime(os.path.join(d, "Cookies"))
90
+ if os.path.exists(os.path.join(d, "Cookies")) else 0, reverse=True)
91
+ return dirs
92
+
93
+
94
+ def github_cookie_count(profile_dir: str) -> int:
95
+ db = os.path.join(profile_dir, "Cookies")
96
+ if not os.path.exists(db):
97
+ return 0
98
+ try:
99
+ con = sqlite3.connect(f"file:{db}?immutable=1", uri=True)
100
+ try:
101
+ marks = ",".join("?" * len(SESSION_COOKIES))
102
+ return con.execute(
103
+ f"SELECT COUNT(*) FROM cookies WHERE host_key LIKE '%github.com' "
104
+ f"AND name IN ({marks})", SESSION_COOKIES).fetchone()[0]
105
+ finally:
106
+ con.close()
107
+ except sqlite3.Error:
108
+ return 0
109
+
110
+
111
+ def _expand_roots(browser: Browser) -> List[str]:
112
+ roots: List[str] = []
113
+ seen = set()
114
+ for pattern in browser.root_globs:
115
+ for r in glob.glob(os.path.expanduser(pattern)):
116
+ real = os.path.realpath(r)
117
+ if real in seen:
118
+ continue
119
+ seen.add(real)
120
+ if os.path.isdir(r):
121
+ roots.append(r)
122
+ roots.sort(key=lambda r: _newest_cookie_mtime(r), reverse=True)
123
+ return roots
124
+
125
+
126
+ def _newest_cookie_mtime(root: str) -> float:
127
+ best = 0.0
128
+ for d in _profile_dirs(root):
129
+ cookies = os.path.join(d, "Cookies")
130
+ if os.path.exists(cookies):
131
+ best = max(best, os.path.getmtime(cookies))
132
+ return best
133
+
134
+
135
+ def find_sessions(preferred: Optional[str] = None) -> List[SessionInfo]:
136
+ """Perfiles con sesión de GitHub.
137
+
138
+ Sin `preferred` escanea todos los navegadores soportados. Con `preferred`,
139
+ SOLO ese navegador: forzar uno y caer a otro en silencio seria engañoso.
140
+ """
141
+ if preferred:
142
+ order = [b for b in BROWSERS if b.name == preferred.lower()]
143
+ if not order:
144
+ return []
145
+ else:
146
+ order = list(BROWSERS)
147
+ found: List[SessionInfo] = []
148
+ for browser in order:
149
+ exe = resolve_exe(browser)
150
+ if not exe:
151
+ continue
152
+ for root in _expand_roots(browser):
153
+ for profile in _profile_dirs(root):
154
+ count = github_cookie_count(profile)
155
+ if count:
156
+ found.append(SessionInfo(browser, exe, profile,
157
+ os.path.basename(profile), root, count))
158
+ return found
159
+
160
+
161
+ # Ficheros mínimos que hacen viva una sesión en un user-data-dir nuevo.
162
+ _PROFILE_FILES = ("Cookies", "Cookies-wal", "Cookies-shm", "Preferences")
163
+ _ROOT_FILES = ("Local State",)
164
+
165
+
166
+ def copy_session(session: SessionInfo) -> str:
167
+ """Copia la sesión a un user-data-dir temporal visible en $HOME.
168
+
169
+ Debe ser visible (sin punto inicial) porque los navegadores snap solo
170
+ tienen permiso de escritura sobre rutas no ocultas del home.
171
+ """
172
+ tmp = tempfile.mkdtemp(prefix="ghlike-", dir=os.path.expanduser("~"))
173
+ os.makedirs(os.path.join(tmp, session.profile_name), exist_ok=True)
174
+ for f in _PROFILE_FILES:
175
+ src = os.path.join(session.profile_dir, f)
176
+ if os.path.exists(src):
177
+ shutil.copy2(src, os.path.join(tmp, session.profile_name, f))
178
+ for f in _ROOT_FILES:
179
+ src = os.path.join(session.root_dir, f)
180
+ if os.path.exists(src):
181
+ shutil.copy2(src, os.path.join(tmp, f))
182
+ return tmp
@@ -0,0 +1,76 @@
1
+ """Cliente CDP (Chrome DevTools Protocol) mínimo sobre el WebSocket propio."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import time
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any, Dict, Optional
9
+ from urllib.parse import urlparse
10
+
11
+ from .ws import WebSocket
12
+
13
+
14
+ class CdpError(RuntimeError):
15
+ pass
16
+
17
+
18
+ def http_json(port: int, path: str, method: str = "GET") -> Dict[str, Any]:
19
+ req = urllib.request.Request(f"http://127.0.0.1:{port}{path}", method=method)
20
+ with urllib.request.urlopen(req, timeout=10) as resp:
21
+ return json.loads(resp.read().decode())
22
+
23
+
24
+ def wait_for_cdp(port: int, timeout: float = 20.0) -> bool:
25
+ deadline = time.monotonic() + timeout
26
+ while time.monotonic() < deadline:
27
+ try:
28
+ http_json(port, "/json/version")
29
+ return True
30
+ except (urllib.error.URLError, OSError):
31
+ time.sleep(0.3)
32
+ return False
33
+
34
+
35
+ def open_tab(port: int, url: str) -> Dict[str, Any]:
36
+ """Abre una pestaña en la URL y devuelve su target info."""
37
+ target = http_json(port, f"/json/new?{url}", method="PUT")
38
+ if "webSocketDebuggerUrl" not in target:
39
+ raise CdpError("el navegador no devolvio websocket para la pestaña")
40
+ return target
41
+
42
+
43
+ class Tab:
44
+ """Una pestaña conectada por WebSocket, con eval sincrono."""
45
+
46
+ def __init__(self, target: Dict[str, Any]):
47
+ parsed = urlparse(target["webSocketDebuggerUrl"])
48
+ self.ws = WebSocket(parsed.hostname, parsed.port or 80, parsed.path)
49
+ self._id = 0
50
+ self._counter_events: int = 0
51
+
52
+ def send(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
53
+ self._id += 1
54
+ mid = self._id
55
+ self.ws.send_text(json.dumps({"id": mid, "method": method, "params": params or {}}))
56
+ while True:
57
+ msg = json.loads(self.ws.recv_text())
58
+ if msg.get("id") == mid:
59
+ if "error" in msg:
60
+ raise CdpError(f"{method}: {msg['error'].get('message')}")
61
+ return msg.get("result", {})
62
+ # eventos: se descartan (Page.enable etc. no son necesarios aqui)
63
+
64
+ def eval(self, expression: str) -> Any:
65
+ res = self.send("Runtime.evaluate", {
66
+ "expression": expression,
67
+ "returnByValue": True,
68
+ "awaitPromise": True,
69
+ })
70
+ if res.get("exceptionDetails"):
71
+ detail = res["exceptionDetails"].get("exception", {}).get("description", "")
72
+ raise CdpError(f"JS fallo: {detail[:200]}")
73
+ return res.get("result", {}).get("value")
74
+
75
+ def close(self) -> None:
76
+ self.ws.close()
@@ -0,0 +1,99 @@
1
+ """CLI de ghlike: da (o quita) el like de un repo de GitHub con tu sesion del navegador.
2
+
3
+ ghlike owner/repo # dar star (si ya lo tienes, lo dice y no hace nada)
4
+ ghlike owner/repo -u # quitar el star
5
+ ghlike owner/repo -c # solo consultar
6
+ ghlike owner/repo --toggle # invertir
7
+ ghlike --list # sesiones de GitHub detectadas en los navegadores
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+
15
+ from . import __version__
16
+ from .browsers import find_sessions
17
+ from .core import GhlikeError, NoSessionError, RepoNotFoundError, run
18
+
19
+ EXIT_OK, EXIT_ERROR, EXIT_NO_SESSION, EXIT_NOT_FOUND = 0, 1, 2, 3
20
+
21
+
22
+ def _build_parser() -> argparse.ArgumentParser:
23
+ p = argparse.ArgumentParser(
24
+ prog="ghlike",
25
+ description="Like (star) en repos de GitHub usando la sesion ya iniciada en tu navegador. "
26
+ "Sin tokens: se abre tu navegador en modo invisible y pulsa el boton por ti.")
27
+ p.add_argument("repo", nargs="?", help="repo en formato 'owner/repo' o URL de GitHub")
28
+ p.add_argument("-u", "--unstar", action="store_true", help="quitar el star en vez de darlo")
29
+ p.add_argument("-c", "--check", action="store_true", help="solo consultar el estado actual")
30
+ p.add_argument("--toggle", action="store_true", help="invertir el estado actual")
31
+ p.add_argument("--browser", metavar="NOMBRE", help="forzar un navegador (brave, chrome, chromium, edge, vivaldi, opera)")
32
+ p.add_argument("--json", action="store_true", dest="as_json", help="salida en JSON")
33
+ p.add_argument("--list", action="store_true", help="listar sesiones de GitHub detectadas y salir")
34
+ p.add_argument("--version", action="version", version=f"ghlike {__version__}")
35
+ return p
36
+
37
+
38
+ def _print_sessions(as_json: bool) -> int:
39
+ sessions = find_sessions()
40
+ if as_json:
41
+ print(json.dumps([{"browser": s.browser.label, "exe": s.exe,
42
+ "profile": s.profile_name, "github_cookies": s.cookie_count}
43
+ for s in sessions], indent=2))
44
+ return EXIT_OK
45
+ if not sessions:
46
+ print("No se ha encontrado ninguna sesion de github.com en los navegadores Chromium.")
47
+ return EXIT_NO_SESSION
48
+ print("Sesiones de github.com detectadas:")
49
+ for s in sessions:
50
+ print(f" - {s.browser.label}: perfil {s.profile_name} ({s.cookie_count} cookies de sesion)")
51
+ print(f" {s.exe}")
52
+ return EXIT_OK
53
+
54
+
55
+ def main(argv=None) -> int:
56
+ args = _build_parser().parse_args(argv)
57
+
58
+ if args.list:
59
+ return _print_sessions(args.as_json)
60
+ if not args.repo:
61
+ _build_parser().print_usage(sys.stderr)
62
+ print("ghlike: falta el repo (p. ej. 'ghlike cli/cli')", file=sys.stderr)
63
+ return EXIT_ERROR
64
+ if sum([args.unstar, args.check, args.toggle]) > 1:
65
+ print("ghlike: --unstar, --check y --toggle son mutuamente excluyentes", file=sys.stderr)
66
+ return EXIT_ERROR
67
+
68
+ action = "unstar" if args.unstar else "check" if args.check else "toggle" if args.toggle else "star"
69
+ try:
70
+ r = run(args.repo, action=action, browser=args.browser)
71
+ except NoSessionError as e:
72
+ print(f"ghlike: {e}", file=sys.stderr)
73
+ return EXIT_NO_SESSION
74
+ except RepoNotFoundError as e:
75
+ print(f"ghlike: {e}", file=sys.stderr)
76
+ return EXIT_NOT_FOUND
77
+ except GhlikeError as e:
78
+ print(f"ghlike: {e}", file=sys.stderr)
79
+ return EXIT_ERROR
80
+
81
+ if args.as_json:
82
+ print(json.dumps(r))
83
+ return EXIT_OK
84
+
85
+ who = f"[{r['user']} via {r['browser']}]"
86
+ if r["action"] == "check":
87
+ state = "con like" if r["after"] else "sin like"
88
+ print(f"{r['repo']}: {state} {who}")
89
+ elif not r["changed"]:
90
+ state = "ya tenia like" if r["after"] else "ya estaba sin like"
91
+ print(f"{r['repo']}: {state}, sin cambios {who}")
92
+ else:
93
+ verb = "like dado :star:" if r["after"] else "like quitado"
94
+ print(f"{r['repo']}: {verb} {who}")
95
+ return EXIT_OK
96
+
97
+
98
+ if __name__ == "__main__":
99
+ sys.exit(main())
@@ -0,0 +1,245 @@
1
+ """Orquestación: sesion del navegador -> Brave/Chrome headless con CDP -> click en Star.
2
+
3
+ El "like" se hace clicando el boton real de github.com con la propia sesion
4
+ del usuario, igual que lo haria a mano. Sin tokens, sin API, sin credenciales
5
+ en claro: las cookies cifradas se copian a un perfil temporal local que solo
6
+ el navegador puede abrir, y todo se borra al terminar.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import shutil
13
+ import signal
14
+ import socket
15
+ import subprocess
16
+ import time
17
+ from typing import Any, Dict, Optional
18
+
19
+ from .browsers import BROWSERS, copy_session, find_sessions
20
+ from .cdp import Tab, open_tab, wait_for_cdp
21
+
22
+ REPO_RE = re.compile(r"^([\w.\-]+)/([\w.\-]+)$")
23
+ URL_RE = re.compile(r"github\.com/([\w.\-]+)/([\w.\-]+)")
24
+
25
+ # Lectura del estado del boton star. GitHub renderiza el repo con React: no
26
+ # hay formularios clasicos ni authenticity_token en el HTML; el click dispara
27
+ # internamente el POST correcto con su propio CSRF.
28
+ STATE_JS = """(() => {
29
+ const btn = document.querySelector(
30
+ 'button[aria-label^="Star"], button[aria-label^="Unstar"], button[aria-label^="Starred"]');
31
+ const meta = document.querySelector('meta[name="user-login"]');
32
+ return {
33
+ login: meta ? meta.content : null,
34
+ found: !!btn,
35
+ starred: btn ? ((btn.getAttribute('aria-label') || '').startsWith('Unstar')
36
+ || /Starred/i.test(btn.innerText || '')) : null,
37
+ aria: btn ? btn.getAttribute('aria-label') : null,
38
+ notFound: /Page not found|404 "not found"/i.test(document.title)
39
+ };
40
+ })()"""
41
+
42
+ CLICK_JS = """(() => {
43
+ const btn = document.querySelector(
44
+ 'button[aria-label^="Star"], button[aria-label^="Unstar"], button[aria-label^="Starred"]');
45
+ if (!btn) return false;
46
+ btn.click();
47
+ return true;
48
+ })()"""
49
+
50
+
51
+ class GhlikeError(RuntimeError):
52
+ pass
53
+
54
+
55
+ class NoSessionError(GhlikeError):
56
+ pass
57
+
58
+
59
+ class RepoNotFoundError(GhlikeError):
60
+ pass
61
+
62
+
63
+ def star(repo: str, browser: Optional[str] = None) -> Dict[str, Any]:
64
+ return run(repo, action="star", browser=browser)
65
+
66
+
67
+ def unstar(repo: str, browser: Optional[str] = None) -> Dict[str, Any]:
68
+ return run(repo, action="unstar", browser=browser)
69
+
70
+
71
+ def toggle(repo: str, browser: Optional[str] = None) -> Dict[str, Any]:
72
+ return run(repo, action="toggle", browser=browser)
73
+
74
+
75
+ def check(repo: str, browser: Optional[str] = None) -> Dict[str, Any]:
76
+ return run(repo, action="check", browser=browser)
77
+
78
+
79
+ def parse_repo(text: str) -> str:
80
+ text = text.strip().rstrip("/")
81
+ m = URL_RE.search(text)
82
+ if m:
83
+ return f"{m.group(1)}/{m.group(2)}"
84
+ if text.startswith("https://") or text.startswith("http://"):
85
+ raise GhlikeError(f"URL no reconocida como repo de GitHub: {text}")
86
+ if REPO_RE.match(text):
87
+ return text
88
+ raise GhlikeError(f"formato esperado 'owner/repo' o URL de GitHub: {text}")
89
+
90
+
91
+ def _free_port() -> int:
92
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
93
+ s.bind(("127.0.0.1", 0))
94
+ return s.getsockname()[1]
95
+
96
+
97
+ def _kill_tree(proc: subprocess.Popen) -> None:
98
+ """Mata el grupo de procesos completo del navegador (snap incluido).
99
+
100
+ Matar solo el Popen no basta: /snap/bin/brave es un wrapper y el binario
101
+ real queda huérfano (y sigue escribiendo en el perfil temporal).
102
+ """
103
+ try:
104
+ pgid = os.getpgid(proc.pid)
105
+ except ProcessLookupError:
106
+ proc.terminate()
107
+ return
108
+ for sig in (signal.SIGTERM, signal.SIGKILL):
109
+ try:
110
+ os.killpg(pgid, sig)
111
+ except ProcessLookupError:
112
+ return
113
+ for _ in range(50): # hasta 5s por señal
114
+ try:
115
+ os.killpg(pgid, 0)
116
+ except ProcessLookupError:
117
+ return
118
+ time.sleep(0.1)
119
+
120
+
121
+ def _rmtree_retry(path: str, attempts: int = 4) -> None:
122
+ for _ in range(attempts):
123
+ shutil.rmtree(path, ignore_errors=True)
124
+ if not os.path.exists(path):
125
+ return
126
+ time.sleep(0.5)
127
+
128
+
129
+ def _wait_ready(tab: Tab, timeout: float = 25.0) -> None:
130
+ deadline = time.monotonic() + timeout
131
+ while time.monotonic() < deadline:
132
+ try:
133
+ if tab.eval("document.readyState") == "complete":
134
+ return
135
+ except Exception:
136
+ pass
137
+ time.sleep(0.4)
138
+ raise GhlikeError("la pagina no termino de cargar")
139
+
140
+
141
+ def _read_state(tab: Tab) -> Dict[str, Any]:
142
+ deadline = time.monotonic() + 10.0
143
+ while time.monotonic() < deadline:
144
+ try:
145
+ st = tab.eval(STATE_JS)
146
+ if st and st.get("login") is not None:
147
+ return st
148
+ except Exception:
149
+ pass
150
+ time.sleep(0.5)
151
+ return tab.eval(STATE_JS) or {}
152
+
153
+
154
+ def run(repo: str, action: str = "star", browser: Optional[str] = None,
155
+ timeout: float = 60.0) -> Dict[str, Any]:
156
+ """Ejecuta la accion ('star' | 'unstar' | 'toggle' | 'check') contra un repo.
157
+
158
+ Devuelve un dict con repo, before/after, navegador usado y usuario.
159
+ """
160
+ if action not in ("star", "unstar", "toggle", "check"):
161
+ raise GhlikeError(f"accion desconocida: {action}")
162
+ repo = parse_repo(repo)
163
+
164
+ sessions = find_sessions(browser)
165
+ if not sessions:
166
+ if browser and browser.lower() not in {b.name for b in BROWSERS}:
167
+ known = ", ".join(b.name for b in BROWSERS)
168
+ raise NoSessionError(
169
+ f"navegador '{browser}' no soportado (soportados: {known}). "
170
+ "Firefox no funciona: no implementa CDP.")
171
+ raise NoSessionError(
172
+ "no se encontro ninguna sesion de github.com en los perfiles de "
173
+ "Brave/Chrome/Chromium/Edge/Vivaldi/Opera. Logueate en el navegador primero.")
174
+
175
+ last_session_failure = None
176
+ for session in sessions:
177
+ tmp = copy_session(session)
178
+ port = _free_port()
179
+ cmd = [
180
+ session.exe,
181
+ "--headless=new", "--disable-gpu", "--no-first-run",
182
+ "--no-default-browser-check", "--disable-component-update",
183
+ f"--user-data-dir={tmp}",
184
+ f"--profile-directory={session.profile_name}",
185
+ f"--remote-debugging-port={port}",
186
+ "about:blank",
187
+ ]
188
+ proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
189
+ start_new_session=True)
190
+ try:
191
+ if proc.poll() is not None or not wait_for_cdp(port):
192
+ raise GhlikeError(f"{session.browser.label} no arranco con el puerto de depuracion")
193
+ target = open_tab(port, f"https://github.com/{repo}")
194
+ tab = Tab(target)
195
+ try:
196
+ _wait_ready(tab)
197
+ time.sleep(2.0) # margen para la hidratacion de React
198
+ st = _read_state(tab)
199
+
200
+ if st.get("notFound"):
201
+ raise RepoNotFoundError(f"el repo {repo} no existe (o no es visible)")
202
+ if not st.get("login"):
203
+ last_session_failure = (
204
+ f"la sesion de {session.browser.label} ({session.profile_name}) "
205
+ "no se pudo usar: ¿sigues logueado en github.com?")
206
+ continue # probar el siguiente perfil/navegador
207
+ if not st.get("found"):
208
+ raise GhlikeError(
209
+ "no se encontro el boton de star: el markup de GitHub cambio, "
210
+ "abre un issue en el repo del proyecto")
211
+
212
+ before = bool(st["starred"])
213
+ result: Dict[str, Any] = {
214
+ "repo": repo, "user": st["login"],
215
+ "browser": session.browser.label, "profile": session.profile_name,
216
+ "before": before,
217
+ }
218
+ if action == "check":
219
+ result.update(action="check", after=before, changed=False)
220
+ return result
221
+
222
+ desired = not before if action == "toggle" else (action == "star")
223
+ result["action"] = action
224
+ if before != desired:
225
+ if not tab.eval(CLICK_JS):
226
+ raise GhlikeError("el boton de star desaparecio antes del click")
227
+ flipped = False
228
+ deadline = time.monotonic() + 15.0
229
+ while time.monotonic() < deadline:
230
+ time.sleep(0.6)
231
+ st2 = _read_state(tab)
232
+ if bool(st2.get("starred")) == desired:
233
+ flipped = True
234
+ break
235
+ if not flipped:
236
+ raise GhlikeError("el click no cambio el estado del star (rate limit o cambio de UI)")
237
+ result["after"] = desired if before != desired else before
238
+ result["changed"] = result["after"] != result["before"]
239
+ return result
240
+ finally:
241
+ tab.close()
242
+ finally:
243
+ _kill_tree(proc)
244
+ _rmtree_retry(tmp)
245
+ raise NoSessionError(last_session_failure or "ninguna sesion de navegador pudo usarse")
@@ -0,0 +1,90 @@
1
+ """Cliente WebSocket mínimo (RFC 6455) sobre stdlib, para hablar CDP en localhost.
2
+
3
+ Alcance deliberadamente reducido: handshake cliente, frames de texto en ambas
4
+ direcciones y enmascaramiento de salida. Es todo lo que el DevTools protocol
5
+ de un navegador local necesita, y evita cualquier dependencia externa.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ import os
11
+ import socket
12
+ import struct
13
+
14
+
15
+ class WebSocket:
16
+ def __init__(self, host: str, port: int, path: str, timeout: float = 30.0):
17
+ self.sock = socket.create_connection((host, int(port)), timeout=timeout)
18
+ key = base64.b64encode(os.urandom(16)).decode()
19
+ request = (
20
+ f"GET {path} HTTP/1.1\r\n"
21
+ f"Host: {host}:{port}\r\n"
22
+ "Upgrade: websocket\r\n"
23
+ "Connection: Upgrade\r\n"
24
+ f"Sec-WebSocket-Key: {key}\r\n"
25
+ "Sec-WebSocket-Version: 13\r\n"
26
+ "User-Agent: ghlike\r\n"
27
+ "\r\n"
28
+ )
29
+ self.sock.sendall(request.encode())
30
+ buf = b""
31
+ while b"\r\n\r\n" not in buf:
32
+ chunk = self.sock.recv(4096)
33
+ if not chunk:
34
+ raise IOError("conexion cerrada durante el handshake")
35
+ buf += chunk
36
+ head, _, rest = buf.partition(b"\r\n\r\n")
37
+ status = head.split(b"\r\n", 1)[0]
38
+ if b"101" not in status:
39
+ raise IOError(f"handshake rechazado: {status!r}")
40
+ self._buf = rest
41
+
42
+ def _read(self, n: int) -> bytes:
43
+ while len(self._buf) < n:
44
+ chunk = self.sock.recv(4096)
45
+ if not chunk:
46
+ raise IOError("conexion cerrada")
47
+ self._buf += chunk
48
+ out, self._buf = self._buf[:n], self._buf[n:]
49
+ return out
50
+
51
+ def send_text(self, text: str) -> None:
52
+ payload = text.encode()
53
+ mask = os.urandom(4)
54
+ header = bytearray([0x81]) # FIN + text frame
55
+ n = len(payload)
56
+ if n < 126:
57
+ header.append(0x80 | n)
58
+ elif n < 65536:
59
+ header.append(0x80 | 126)
60
+ header += struct.pack(">H", n)
61
+ else:
62
+ header.append(0x80 | 127)
63
+ header += struct.pack(">Q", n)
64
+ header += mask
65
+ masked = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
66
+ self.sock.sendall(bytes(header) + masked)
67
+
68
+ def recv_text(self) -> str:
69
+ while True:
70
+ b1, b2 = self._read(2)
71
+ opcode = b1 & 0x0F
72
+ n = b2 & 0x7F
73
+ if n == 126:
74
+ n = struct.unpack(">H", self._read(2))[0]
75
+ elif n == 127:
76
+ n = struct.unpack(">Q", self._read(8))[0]
77
+ if opcode & 0x80: # MASK nunca llega del servidor CDP; defiende igual
78
+ self._read(4)
79
+ payload = self._read(n)
80
+ if opcode == 0x1:
81
+ return payload.decode("utf-8", "replace")
82
+ if opcode == 0x8:
83
+ raise IOError("conexion cerrada por el peer")
84
+ # ping (0x9) / pong (0xA) / continuacion: ignorar
85
+
86
+ def close(self) -> None:
87
+ try:
88
+ self.sock.close()
89
+ except OSError:
90
+ pass
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: ghlike
3
+ Version: 0.1.0
4
+ Summary: Like (star) repos on GitHub with your existing browser session — no tokens, no API keys.
5
+ Author: ghlike contributors
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/686f6c61/ghlike-py
8
+ Project-URL: Repository, https://github.com/686f6c61/ghlike-py
9
+ Project-URL: Issues, https://github.com/686f6c61/ghlike-py/issues
10
+ Project-URL: Changelog, https://github.com/686f6c61/ghlike-py/releases
11
+ Keywords: github,star,like,cli,browser-session
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: Software Development
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # ghlike
24
+
25
+ **Star GitHub repos with your browser session — no tokens. / Da estrellas
26
+ en GitHub con la sesión de tu navegador — sin tokens.**
27
+
28
+ [English](#english) · [Español](#español)
29
+
30
+ This is the Python CLI of the [ghlike project](https://github.com/686f6c61/ghlike)
31
+ (like-button widget + browser extension + Node CLI live there). Zero
32
+ dependencies: the CDP WebSocket client is implemented on the standard
33
+ library, so the whole package audits in one sitting.
34
+
35
+ ---
36
+
37
+ # English
38
+
39
+ ## What it does
40
+
41
+ `ghlike owner/repo` stars a GitHub repository using the login session
42
+ already active in your browser. No `gh auth login`, no personal access
43
+ tokens, no OAuth: if you're logged into GitHub in Brave, Chrome, Chromium,
44
+ Edge, Vivaldi or Opera (native or snap installs), it just works.
45
+
46
+ How: it finds the browser profile with your github.com session → copies the
47
+ **encrypted** cookie store to a throwaway profile → opens your own browser
48
+ headless with a private debug port → clicks the real Star button (GitHub
49
+ handles its own CSRF) → verifies → kills the browser and deletes the
50
+ profile. Cookies are never decrypted and never leave your machine.
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ pipx install ghlike # or: pip install --user ghlike
56
+ ```
57
+
58
+ Requires Python ≥ 3.9 and a Chromium-family browser with a github.com
59
+ session. Firefox is not supported (no CDP).
60
+
61
+ ## CLI
62
+
63
+ ```bash
64
+ ghlike owner/repo # star (idempotent — tells you if already starred)
65
+ ghlike https://github.com/o/r # full URLs work
66
+ ghlike owner/repo -c # check current state only
67
+ ghlike owner/repo -u # unstar
68
+ ghlike owner/repo --toggle # flip
69
+ ghlike --list # github.com sessions found in your browsers
70
+ ghlike --browser brave o/r # force one browser (strict)
71
+ ghlike o/r --json # machine-readable output
72
+ ```
73
+
74
+ Exit codes: `0` ok (incl. nothing-to-do) · `1` generic error · `2` no
75
+ github.com session · `3` repo not found.
76
+
77
+ JSON example:
78
+
79
+ ```bash
80
+ $ ghlike cli/cli --check --json
81
+ {"repo":"cli/cli","user":"you","browser":"Brave","profile":"Default",
82
+ "before":false,"action":"check","after":false,"changed":false}
83
+ ```
84
+
85
+ ## Library
86
+
87
+ ```python
88
+ from ghlike import star, unstar, check, toggle
89
+
90
+ star("vercel/next.js") # -> {"repo": ..., "changed": True, ...}
91
+ state = check("vercel/next.js") # -> {"repo": ..., "after": True/False, ...}
92
+ unstar("vercel/next.js", browser="brave")
93
+ toggle("vercel/next.js")
94
+ ```
95
+
96
+ Exceptions: `NoSessionError`, `RepoNotFoundError`, `GhlikeError`.
97
+
98
+ ## Limitations
99
+
100
+ - Chromium-family browsers only; Firefox is not supported (no CDP).
101
+ - Each operation takes ~8–12 s (headless browser + page load). That's the
102
+ price of the no-token approach.
103
+ - Depends on GitHub's Star button; if the markup changes, one selector line
104
+ fixes it (`STATE_JS` in `src/ghlike/core.py`).
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ PYTHONPATH=src python3 -m ghlike.cli --list
110
+ python -m pip wheel . --no-deps --no-build-isolation -w dist
111
+ ```
112
+
113
+ Publishing is automated: push a git tag / create a GitHub Release `vX.Y.Z`
114
+ and `.github/workflows/publish-pypi.yml` publishes to PyPI via
115
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no
116
+ secrets). Versions live in `pyproject.toml` and `src/ghlike/__init__.py`.
117
+
118
+ License: MIT.
119
+
120
+ ---
121
+
122
+ # Español
123
+
124
+ ## Qué hace
125
+
126
+ `ghlike owner/repo` da una estrella a un repositorio de GitHub usando la
127
+ sesión ya iniciada en tu navegador. Sin `gh auth login`, sin personal access
128
+ tokens, sin OAuth: si estás logueado en GitHub en Brave, Chrome, Chromium,
129
+ Edge, Vivaldi u Opera (nativo o snap), funciona directamente.
130
+
131
+ Cómo: busca el perfil del navegador con tu sesión de github.com → copia la
132
+ tienda de cookies **cifrada** a un perfil desechable → abre tu propio
133
+ navegador en modo invisible con un puerto de depuración privado → pulsa el
134
+ botón de Star real (GitHub gestiona su propio CSRF) → verifica → cierra el
135
+ navegador y borra el perfil. Las cookies nunca se descifran ni salen de tu
136
+ máquina.
137
+
138
+ ## Instalación
139
+
140
+ ```bash
141
+ pipx install ghlike # o: pip install --user ghlike
142
+ ```
143
+
144
+ Requiere Python ≥ 3.9 y un navegador de la familia Chromium con sesión de
145
+ github.com. Firefox no está soportado (sin CDP).
146
+
147
+ ## CLI
148
+
149
+ ```bash
150
+ ghlike owner/repo # dar star (idempotente)
151
+ ghlike https://github.com/o/r # URLs completas valen
152
+ ghlike owner/repo -c # solo consultar estado
153
+ ghlike owner/repo -u # quitar star
154
+ ghlike owner/repo --toggle # invertir
155
+ ghlike --list # sesiones de GitHub detectadas
156
+ ghlike --browser brave o/r # forzar un navegador (estricto)
157
+ ghlike o/r --json # salida para scripts
158
+ ```
159
+
160
+ Códigos de salida: `0` ok (también "nada que hacer") · `1` error genérico ·
161
+ `2` sin sesión de github.com · `3` repo no encontrado.
162
+
163
+ ## Librería
164
+
165
+ ```python
166
+ from ghlike import star, unstar, check, toggle
167
+
168
+ star("vercel/next.js") # -> {"repo": ..., "changed": True, ...}
169
+ state = check("vercel/next.js") # -> {"repo": ..., "after": True/False, ...}
170
+ unstar("vercel/next.js", browser="brave")
171
+ ```
172
+
173
+ Excepciones: `NoSessionError`, `RepoNotFoundError`, `GhlikeError`.
174
+
175
+ ## Limitaciones
176
+
177
+ - Solo navegadores Chromium; Firefox no (sin CDP).
178
+ - Cada operación tarda ~8–12 s (navegador invisible + carga). Es el precio
179
+ del enfoque sin tokens.
180
+ - Depende del botón de Star de GitHub; si cambia el markup, se arregla con
181
+ una línea (`STATE_JS` en `src/ghlike/core.py`).
182
+
183
+ ## Desarrollo
184
+
185
+ ```bash
186
+ PYTHONPATH=src python3 -m ghlike.cli --list
187
+ python -m pip wheel . --no-deps --no-build-isolation -w dist
188
+ ```
189
+
190
+ La publicación es automática: crea un Release `vX.Y.Z` en GitHub y
191
+ `.github/workflows/publish-pypi.yml` publica en PyPI con Trusted Publishing
192
+ (sin secrets). Las versiones viven en `pyproject.toml` y
193
+ `src/ghlike/__init__.py`.
194
+
195
+ Licencia: MIT.
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/ghlike/__init__.py
5
+ src/ghlike/browsers.py
6
+ src/ghlike/cdp.py
7
+ src/ghlike/cli.py
8
+ src/ghlike/core.py
9
+ src/ghlike/ws.py
10
+ src/ghlike.egg-info/PKG-INFO
11
+ src/ghlike.egg-info/SOURCES.txt
12
+ src/ghlike.egg-info/dependency_links.txt
13
+ src/ghlike.egg-info/entry_points.txt
14
+ src/ghlike.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ghlike = ghlike.cli:main
@@ -0,0 +1 @@
1
+ ghlike