cli-web-hackernews 0.1.0__py3-none-any.whl
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.
- cli_web/hackernews/README.md +91 -0
- cli_web/hackernews/__init__.py +0 -0
- cli_web/hackernews/__main__.py +6 -0
- cli_web/hackernews/commands/__init__.py +0 -0
- cli_web/hackernews/commands/actions.py +105 -0
- cli_web/hackernews/commands/auth.py +80 -0
- cli_web/hackernews/commands/search.py +69 -0
- cli_web/hackernews/commands/stories.py +160 -0
- cli_web/hackernews/commands/user.py +112 -0
- cli_web/hackernews/core/__init__.py +0 -0
- cli_web/hackernews/core/auth.py +290 -0
- cli_web/hackernews/core/client.py +517 -0
- cli_web/hackernews/core/exceptions.py +63 -0
- cli_web/hackernews/core/models.py +144 -0
- cli_web/hackernews/hackernews_cli.py +171 -0
- cli_web/hackernews/tests/TEST.md +143 -0
- cli_web/hackernews/tests/__init__.py +0 -0
- cli_web/hackernews/tests/test_core.py +365 -0
- cli_web/hackernews/tests/test_e2e.py +267 -0
- cli_web/hackernews/utils/__init__.py +0 -0
- cli_web/hackernews/utils/doctor.py +188 -0
- cli_web/hackernews/utils/helpers.py +73 -0
- cli_web/hackernews/utils/mcp_server.py +290 -0
- cli_web/hackernews/utils/output.py +136 -0
- cli_web/hackernews/utils/repl_skin.py +486 -0
- cli_web_hackernews-0.1.0.dist-info/METADATA +12 -0
- cli_web_hackernews-0.1.0.dist-info/RECORD +30 -0
- cli_web_hackernews-0.1.0.dist-info/WHEEL +5 -0
- cli_web_hackernews-0.1.0.dist-info/entry_points.txt +2 -0
- cli_web_hackernews-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Auth management for cli-web-hackernews — cookie-based HN authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import stat
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from .exceptions import AuthError
|
|
14
|
+
|
|
15
|
+
HN_BASE = "https://news.ycombinator.com"
|
|
16
|
+
CONFIG_DIR = Path.home() / ".config" / "cli-web-hackernews"
|
|
17
|
+
AUTH_FILE = CONFIG_DIR / "auth.json"
|
|
18
|
+
ENV_VAR = "CLI_WEB_HACKERNEWS_AUTH_JSON"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _get_config_dir() -> Path:
|
|
22
|
+
"""Ensure config directory exists."""
|
|
23
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
return CONFIG_DIR
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def save_auth(user_cookie: str, username: str) -> Path:
|
|
28
|
+
"""Save auth cookie to auth.json with restrictive permissions."""
|
|
29
|
+
_get_config_dir()
|
|
30
|
+
data = {"user_cookie": user_cookie, "username": username}
|
|
31
|
+
AUTH_FILE.write_text(json.dumps(data, indent=2))
|
|
32
|
+
try:
|
|
33
|
+
os.chmod(AUTH_FILE, stat.S_IRUSR | stat.S_IWUSR) # 600
|
|
34
|
+
except OSError:
|
|
35
|
+
pass # Windows may not support chmod
|
|
36
|
+
return AUTH_FILE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_auth() -> dict[str, str]:
|
|
40
|
+
"""Load auth from env var or file. Returns dict with user_cookie and username."""
|
|
41
|
+
# 1. Try env var first (CI/CD)
|
|
42
|
+
env_val = os.environ.get(ENV_VAR)
|
|
43
|
+
if env_val:
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(env_val)
|
|
46
|
+
if isinstance(data, dict) and "user_cookie" in data:
|
|
47
|
+
return data
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# 2. Try auth file
|
|
52
|
+
if AUTH_FILE.exists():
|
|
53
|
+
try:
|
|
54
|
+
data = json.loads(AUTH_FILE.read_text())
|
|
55
|
+
if isinstance(data, dict) and "user_cookie" in data:
|
|
56
|
+
return data
|
|
57
|
+
except (json.JSONDecodeError, OSError):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
raise AuthError("Not logged in. Run: cli-web-hackernews auth login")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_user_cookie() -> str:
|
|
64
|
+
"""Get the HN user cookie value."""
|
|
65
|
+
return load_auth()["user_cookie"]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_username() -> str:
|
|
69
|
+
"""Get the logged-in username."""
|
|
70
|
+
return load_auth()["username"]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def is_logged_in() -> bool:
|
|
74
|
+
"""Check if auth credentials exist."""
|
|
75
|
+
try:
|
|
76
|
+
load_auth()
|
|
77
|
+
return True
|
|
78
|
+
except AuthError:
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def logout() -> None:
|
|
83
|
+
"""Remove auth credentials."""
|
|
84
|
+
if AUTH_FILE.exists():
|
|
85
|
+
AUTH_FILE.unlink()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def login_with_password(username: str, password: str) -> dict[str, str]:
|
|
89
|
+
"""Login to HN with username/password and return auth data.
|
|
90
|
+
|
|
91
|
+
HN login is a POST to /login with acct=username&pw=password.
|
|
92
|
+
On success, it sets a 'user' cookie and redirects.
|
|
93
|
+
"""
|
|
94
|
+
with httpx.Client(
|
|
95
|
+
headers={
|
|
96
|
+
"User-Agent": (
|
|
97
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
98
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
99
|
+
"Chrome/122.0.0.0 Safari/537.36"
|
|
100
|
+
),
|
|
101
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
102
|
+
},
|
|
103
|
+
follow_redirects=False,
|
|
104
|
+
timeout=30.0,
|
|
105
|
+
) as client:
|
|
106
|
+
response = client.post(
|
|
107
|
+
f"{HN_BASE}/login",
|
|
108
|
+
data={"acct": username, "pw": password, "goto": "news"},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# HN returns 302 on success, 200 with error on failure
|
|
112
|
+
user_cookie = None
|
|
113
|
+
for cookie_header in response.headers.get_list("set-cookie"):
|
|
114
|
+
if cookie_header.startswith("user="):
|
|
115
|
+
user_cookie = cookie_header.split("user=")[1].split(";")[0]
|
|
116
|
+
break
|
|
117
|
+
|
|
118
|
+
if not user_cookie:
|
|
119
|
+
# Check if response body has error message
|
|
120
|
+
if response.status_code == 200:
|
|
121
|
+
raise AuthError("Login failed: bad username or password", recoverable=False)
|
|
122
|
+
raise AuthError(f"Login failed: HTTP {response.status_code}", recoverable=False)
|
|
123
|
+
|
|
124
|
+
auth_data = {"user_cookie": user_cookie, "username": username}
|
|
125
|
+
save_auth(user_cookie, username)
|
|
126
|
+
return auth_data
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def login_browser() -> dict[str, str]:
|
|
130
|
+
"""Login to HN via browser (for users who prefer not to enter password in CLI).
|
|
131
|
+
|
|
132
|
+
Uses Python sync_playwright with persistent context.
|
|
133
|
+
"""
|
|
134
|
+
import asyncio
|
|
135
|
+
import sys
|
|
136
|
+
|
|
137
|
+
if sys.platform == "win32":
|
|
138
|
+
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
from playwright.sync_api import sync_playwright
|
|
142
|
+
except ImportError as exc:
|
|
143
|
+
raise AuthError(
|
|
144
|
+
"Browser login requires playwright. Install: pip install playwright && playwright install chromium",
|
|
145
|
+
recoverable=False,
|
|
146
|
+
) from exc
|
|
147
|
+
|
|
148
|
+
user_data_dir = str(_get_config_dir() / "browser-profile")
|
|
149
|
+
|
|
150
|
+
with sync_playwright() as p:
|
|
151
|
+
context = p.chromium.launch_persistent_context(
|
|
152
|
+
user_data_dir=user_data_dir,
|
|
153
|
+
headless=False,
|
|
154
|
+
args=[
|
|
155
|
+
"--disable-blink-features=AutomationControlled",
|
|
156
|
+
"--no-first-run",
|
|
157
|
+
"--no-default-browser-check",
|
|
158
|
+
],
|
|
159
|
+
)
|
|
160
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
161
|
+
page.goto(f"{HN_BASE}/login")
|
|
162
|
+
|
|
163
|
+
print("Please log in to Hacker News in the browser window.")
|
|
164
|
+
print("The window will close automatically after login.")
|
|
165
|
+
|
|
166
|
+
# Wait for the user cookie to appear (max 5 minutes)
|
|
167
|
+
try:
|
|
168
|
+
page.wait_for_url(
|
|
169
|
+
lambda url: "login" not in url,
|
|
170
|
+
timeout=300_000,
|
|
171
|
+
)
|
|
172
|
+
except Exception as exc:
|
|
173
|
+
context.close()
|
|
174
|
+
raise AuthError("Login timed out after 5 minutes", recoverable=False) from exc
|
|
175
|
+
|
|
176
|
+
# Extract cookies
|
|
177
|
+
cookies = context.cookies("https://news.ycombinator.com")
|
|
178
|
+
user_cookie = None
|
|
179
|
+
username = None
|
|
180
|
+
for cookie in cookies:
|
|
181
|
+
if cookie["name"] == "user":
|
|
182
|
+
user_cookie = cookie["value"]
|
|
183
|
+
# Username is the part before &
|
|
184
|
+
username = user_cookie.split("&")[0] if "&" in user_cookie else None
|
|
185
|
+
break
|
|
186
|
+
|
|
187
|
+
context.close()
|
|
188
|
+
|
|
189
|
+
if not user_cookie or not username:
|
|
190
|
+
raise AuthError("Could not extract login cookie from browser", recoverable=False)
|
|
191
|
+
|
|
192
|
+
auth_data = {"user_cookie": user_cookie, "username": username}
|
|
193
|
+
save_auth(user_cookie, username)
|
|
194
|
+
return auth_data
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def refresh_auth() -> dict[str, str]:
|
|
198
|
+
"""Headlessly re-extract the auth cookie via the persistent browser profile.
|
|
199
|
+
|
|
200
|
+
Launches a headless browser with the profile saved by login_browser(),
|
|
201
|
+
navigates to HN (which re-sends the session cookie), and saves it.
|
|
202
|
+
|
|
203
|
+
Raises:
|
|
204
|
+
AuthError: If the profile is missing or the session is gone —
|
|
205
|
+
the user must run `cli-web-hackernews auth login`.
|
|
206
|
+
"""
|
|
207
|
+
import asyncio
|
|
208
|
+
import sys
|
|
209
|
+
|
|
210
|
+
if sys.platform == "win32":
|
|
211
|
+
asyncio.set_event_loop_policy(asyncio.DefaultEventLoopPolicy())
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
from playwright.sync_api import sync_playwright
|
|
215
|
+
except ImportError as exc:
|
|
216
|
+
raise AuthError(
|
|
217
|
+
"Headless refresh requires playwright. Run: cli-web-hackernews auth login",
|
|
218
|
+
recoverable=False,
|
|
219
|
+
) from exc
|
|
220
|
+
|
|
221
|
+
profile_dir = _get_config_dir() / "browser-profile"
|
|
222
|
+
if not profile_dir.exists():
|
|
223
|
+
raise AuthError(
|
|
224
|
+
"Session expired and no browser profile found. Run: cli-web-hackernews auth login",
|
|
225
|
+
recoverable=False,
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
with sync_playwright() as p:
|
|
229
|
+
context = p.chromium.launch_persistent_context(
|
|
230
|
+
user_data_dir=str(profile_dir),
|
|
231
|
+
headless=True,
|
|
232
|
+
args=[
|
|
233
|
+
"--disable-blink-features=AutomationControlled",
|
|
234
|
+
"--no-first-run",
|
|
235
|
+
"--no-default-browser-check",
|
|
236
|
+
],
|
|
237
|
+
)
|
|
238
|
+
try:
|
|
239
|
+
page = context.pages[0] if context.pages else context.new_page()
|
|
240
|
+
page.goto(HN_BASE, wait_until="domcontentloaded")
|
|
241
|
+
page.wait_for_timeout(2000)
|
|
242
|
+
cookies = context.cookies("https://news.ycombinator.com")
|
|
243
|
+
finally:
|
|
244
|
+
context.close()
|
|
245
|
+
|
|
246
|
+
user_cookie = None
|
|
247
|
+
username = None
|
|
248
|
+
for cookie in cookies:
|
|
249
|
+
if cookie["name"] == "user":
|
|
250
|
+
user_cookie = cookie["value"]
|
|
251
|
+
username = user_cookie.split("&")[0] if "&" in user_cookie else None
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
if not user_cookie or not username:
|
|
255
|
+
raise AuthError("Session expired. Run: cli-web-hackernews auth login", recoverable=False)
|
|
256
|
+
|
|
257
|
+
save_auth(user_cookie, username)
|
|
258
|
+
return {"user_cookie": user_cookie, "username": username}
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def validate_auth() -> dict[str, Any]:
|
|
262
|
+
"""Validate that the stored auth is still valid by checking the HN profile page."""
|
|
263
|
+
auth = load_auth()
|
|
264
|
+
cookie = auth["user_cookie"]
|
|
265
|
+
username = auth["username"]
|
|
266
|
+
|
|
267
|
+
with httpx.Client(
|
|
268
|
+
headers={
|
|
269
|
+
"User-Agent": (
|
|
270
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
271
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
272
|
+
"Chrome/122.0.0.0 Safari/537.36"
|
|
273
|
+
),
|
|
274
|
+
},
|
|
275
|
+
cookies={"user": cookie},
|
|
276
|
+
follow_redirects=True,
|
|
277
|
+
timeout=15.0,
|
|
278
|
+
) as client:
|
|
279
|
+
response = client.get(f"{HN_BASE}/user?id={username}")
|
|
280
|
+
|
|
281
|
+
if response.status_code != 200:
|
|
282
|
+
raise AuthError("Auth validation failed — cookie may be expired", recoverable=False)
|
|
283
|
+
|
|
284
|
+
# Check if we're actually logged in (page shows logout link)
|
|
285
|
+
if 'id="logout"' not in response.text and "logout" not in response.text:
|
|
286
|
+
raise AuthError(
|
|
287
|
+
"Auth cookie expired. Run: cli-web-hackernews auth login", recoverable=False
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return {"username": username, "valid": True}
|