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 +21 -0
- ghlike-0.1.0/PKG-INFO +195 -0
- ghlike-0.1.0/README.md +173 -0
- ghlike-0.1.0/pyproject.toml +35 -0
- ghlike-0.1.0/setup.cfg +4 -0
- ghlike-0.1.0/src/ghlike/__init__.py +5 -0
- ghlike-0.1.0/src/ghlike/browsers.py +182 -0
- ghlike-0.1.0/src/ghlike/cdp.py +76 -0
- ghlike-0.1.0/src/ghlike/cli.py +99 -0
- ghlike-0.1.0/src/ghlike/core.py +245 -0
- ghlike-0.1.0/src/ghlike/ws.py +90 -0
- ghlike-0.1.0/src/ghlike.egg-info/PKG-INFO +195 -0
- ghlike-0.1.0/src/ghlike.egg-info/SOURCES.txt +14 -0
- ghlike-0.1.0/src/ghlike.egg-info/dependency_links.txt +1 -0
- ghlike-0.1.0/src/ghlike.egg-info/entry_points.txt +2 -0
- ghlike-0.1.0/src/ghlike.egg-info/top_level.txt +1 -0
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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ghlike
|