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.
@@ -0,0 +1,144 @@
1
+ """Data models for Hacker News CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import re
7
+ import time
8
+ from dataclasses import asdict, dataclass, field
9
+ from datetime import datetime, timezone
10
+
11
+
12
+ @dataclass
13
+ class Story:
14
+ """A Hacker News story."""
15
+
16
+ id: int
17
+ title: str
18
+ url: str | None = None
19
+ score: int = 0
20
+ by: str = ""
21
+ time: int = 0
22
+ descendants: int = 0
23
+ type: str = "story"
24
+
25
+ @property
26
+ def age(self) -> str:
27
+ """Human-readable age like '2h ago'."""
28
+ if not self.time:
29
+ return ""
30
+ delta = int(time.time()) - self.time
31
+ if delta < 60:
32
+ return f"{delta}s ago"
33
+ if delta < 3600:
34
+ return f"{delta // 60}m ago"
35
+ if delta < 86400:
36
+ return f"{delta // 3600}h ago"
37
+ return f"{delta // 86400}d ago"
38
+
39
+ @property
40
+ def domain(self) -> str:
41
+ """Extract domain from URL."""
42
+ if not self.url:
43
+ return ""
44
+ match = re.match(r"https?://(?:www\.)?([^/]+)", self.url)
45
+ return match.group(1) if match else ""
46
+
47
+ def to_dict(self) -> dict:
48
+ d = asdict(self)
49
+ d["age"] = self.age
50
+ d["domain"] = self.domain
51
+ return d
52
+
53
+
54
+ @dataclass
55
+ class Comment:
56
+ """A Hacker News comment."""
57
+
58
+ id: int
59
+ by: str = ""
60
+ text: str = ""
61
+ time: int = 0
62
+ parent: int = 0
63
+ kids: list[int] = field(default_factory=list)
64
+ dead: bool = False
65
+ deleted: bool = False
66
+ type: str = "comment"
67
+
68
+ @property
69
+ def text_plain(self) -> str:
70
+ """Strip HTML tags from comment text."""
71
+ if not self.text:
72
+ return ""
73
+ text = re.sub(r"<[^>]+>", "", self.text)
74
+ return html.unescape(text)
75
+
76
+ @property
77
+ def age(self) -> str:
78
+ if not self.time:
79
+ return ""
80
+ delta = int(time.time()) - self.time
81
+ if delta < 60:
82
+ return f"{delta}s ago"
83
+ if delta < 3600:
84
+ return f"{delta // 60}m ago"
85
+ if delta < 86400:
86
+ return f"{delta // 3600}h ago"
87
+ return f"{delta // 86400}d ago"
88
+
89
+ def to_dict(self) -> dict:
90
+ d = asdict(self)
91
+ d["text_plain"] = self.text_plain
92
+ d["age"] = self.age
93
+ return d
94
+
95
+
96
+ @dataclass
97
+ class User:
98
+ """A Hacker News user profile."""
99
+
100
+ id: str
101
+ karma: int = 0
102
+ created: int = 0
103
+ about: str = ""
104
+ submitted: list[int] = field(default_factory=list)
105
+
106
+ @property
107
+ def about_plain(self) -> str:
108
+ if not self.about:
109
+ return ""
110
+ text = re.sub(r"<[^>]+>", "", self.about)
111
+ return html.unescape(text)
112
+
113
+ @property
114
+ def member_since(self) -> str:
115
+ if not self.created:
116
+ return ""
117
+ dt = datetime.fromtimestamp(self.created, tz=timezone.utc)
118
+ return dt.strftime("%Y-%m-%d")
119
+
120
+ def to_dict(self) -> dict:
121
+ d = asdict(self)
122
+ d["about_plain"] = self.about_plain
123
+ d["member_since"] = self.member_since
124
+ # Trim submitted to first 20 for readability
125
+ d["submitted"] = self.submitted[:20]
126
+ d["total_submissions"] = len(self.submitted)
127
+ return d
128
+
129
+
130
+ @dataclass
131
+ class SearchResult:
132
+ """A search result from HN Algolia API."""
133
+
134
+ objectID: str
135
+ title: str
136
+ url: str | None = None
137
+ author: str = ""
138
+ points: int | None = None
139
+ num_comments: int | None = None
140
+ created_at: str = ""
141
+ story_id: int | None = None
142
+
143
+ def to_dict(self) -> dict:
144
+ return asdict(self)
@@ -0,0 +1,171 @@
1
+ """cli-web-hackernews — CLI entry point for Hacker News."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
8
+ try:
9
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
10
+ except AttributeError:
11
+ pass
12
+ if sys.stderr.encoding and sys.stderr.encoding.lower() not in ("utf-8", "utf8"):
13
+ try:
14
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
15
+ except AttributeError:
16
+ pass
17
+
18
+ import shlex
19
+
20
+ import click
21
+ from cli_web.hackernews.commands.actions import (
22
+ comment_cmd,
23
+ favorite_cmd,
24
+ hide_cmd,
25
+ submit_cmd,
26
+ upvote_cmd,
27
+ )
28
+ from cli_web.hackernews.commands.auth import auth_group
29
+ from cli_web.hackernews.commands.search import search_group
30
+ from cli_web.hackernews.commands.stories import stories_group
31
+ from cli_web.hackernews.commands.user import user_group
32
+ from cli_web.hackernews.core.exceptions import AppError
33
+ from cli_web.hackernews.utils.repl_skin import ReplSkin
34
+
35
+ _skin = ReplSkin(app="hackernews", version="0.2.0", display_name="Hacker News")
36
+
37
+
38
+ # ---------------------------------------------------------------------------- main CLI
39
+
40
+
41
+ @click.group(invoke_without_command=True)
42
+ @click.option("--json", "json_mode", is_flag=True, help="Output as JSON (applies to all commands).")
43
+ @click.version_option("0.2.0", prog_name="cli-web-hackernews")
44
+ @click.pass_context
45
+ def cli(ctx, json_mode):
46
+ """cli-web-hackernews — Browse and interact with Hacker News from the command line.
47
+
48
+ Run without arguments to enter interactive REPL mode.
49
+ """
50
+ ctx.ensure_object(dict)
51
+ ctx.obj["json"] = json_mode
52
+
53
+ if ctx.invoked_subcommand is None:
54
+ _run_repl(ctx)
55
+
56
+
57
+ cli.add_command(stories_group)
58
+ cli.add_command(search_group)
59
+ cli.add_command(user_group)
60
+ cli.add_command(auth_group)
61
+ cli.add_command(upvote_cmd)
62
+ cli.add_command(submit_cmd)
63
+ cli.add_command(comment_cmd)
64
+ cli.add_command(favorite_cmd)
65
+ cli.add_command(hide_cmd)
66
+
67
+
68
+ # ---------------------------------------------------------------------------- REPL
69
+
70
+
71
+ def _print_repl_help() -> None:
72
+ _skin.info("Available commands:")
73
+ print()
74
+ print(" stories top [OPTIONS] Top stories (front page)")
75
+ print(" stories new [OPTIONS] Newest stories")
76
+ print(" stories best [OPTIONS] Best stories (all time)")
77
+ print(" stories ask [OPTIONS] Ask HN stories")
78
+ print(" stories show [OPTIONS] Show HN stories")
79
+ print(" stories jobs [OPTIONS] Job listings")
80
+ print(" stories view ID [OPTIONS] View story + comments")
81
+ print(" -n, --limit N Number of items (default 30/10)")
82
+ print(" --json Output as JSON")
83
+ print()
84
+ print(" search stories QUERY Search stories by keyword")
85
+ print(" search comments QUERY Search comments by keyword")
86
+ print(" --sort-date Sort by date instead of relevance")
87
+ print(" -n, --limit N Number of results (default 20)")
88
+ print(" --json Output as JSON")
89
+ print()
90
+ print(" user view USERNAME View user profile")
91
+ print(" user favorites [USERNAME] View favorite stories (auth)")
92
+ print(" user submissions [USERNAME] View submitted stories (auth)")
93
+ print(" user threads [USERNAME] View replies to comments (auth)")
94
+ print(" -n, --limit N Number of items (default 30)")
95
+ print(" --json Output as JSON")
96
+ print()
97
+ print(" upvote ID Upvote a story or comment (auth)")
98
+ print(" submit -t TITLE [-u URL] Submit a new story (auth)")
99
+ print(" comment PARENT_ID TEXT Post a comment or reply (auth)")
100
+ print(" favorite ID Favorite/save a story (auth)")
101
+ print(" hide ID Hide a story from feed (auth)")
102
+ print(" --json Output as JSON")
103
+ print()
104
+ print(" auth login Login with username/password")
105
+ print(" auth login-browser Login via browser window")
106
+ print(" auth status Check login status")
107
+ print(" auth logout Remove credentials")
108
+ print()
109
+ print(" help Show this help")
110
+ print(" exit / quit / Ctrl-D Exit REPL")
111
+ print()
112
+
113
+
114
+ def _run_repl(ctx: click.Context) -> None:
115
+ _skin.print_banner()
116
+ _print_repl_help()
117
+
118
+ pt_session = _skin.create_prompt_session()
119
+
120
+ while True:
121
+ try:
122
+ line = _skin.get_input(pt_session)
123
+ except (EOFError, KeyboardInterrupt):
124
+ _skin.print_goodbye()
125
+ break
126
+
127
+ line = line.strip()
128
+ if not line:
129
+ continue
130
+ if line.lower() in ("exit", "quit", "q"):
131
+ _skin.print_goodbye()
132
+ break
133
+ if line.lower() in ("help", "?", "h"):
134
+ _print_repl_help()
135
+ continue
136
+
137
+ try:
138
+ args = shlex.split(line)
139
+ except ValueError as exc:
140
+ _skin.error(f"Parse error: {exc}")
141
+ continue
142
+
143
+ # Preserve --json flag from context
144
+ if ctx.obj.get("json"):
145
+ args = ["--json"] + args
146
+
147
+ try:
148
+ cli.main(args=args, standalone_mode=False)
149
+ except SystemExit:
150
+ pass
151
+ except AppError as exc:
152
+ _skin.error(exc.message)
153
+ except Exception as exc:
154
+ _skin.error(str(exc))
155
+
156
+
157
+ def main():
158
+ cli()
159
+
160
+
161
+ # MCP server mode — exposes every command as an MCP tool over stdio.
162
+ # Canonical adapter: cli-web-core/cli_web_core/mcp_server.py (vendored copy).
163
+ from cli_web.hackernews.utils.doctor import register_doctor_command # noqa: E402
164
+ from cli_web.hackernews.utils.mcp_server import register_mcp_command # noqa: E402
165
+
166
+ register_mcp_command(cli, app_name="hackernews", version="0.1.0")
167
+ register_doctor_command(cli, app_name="hackernews", pkg="hackernews")
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
@@ -0,0 +1,143 @@
1
+ # TEST.md — cli-web-hackernews Test Plan & Results
2
+
3
+
4
+ ## Part 1: Test Plan
5
+
6
+
7
+ ### Test Inventory
8
+
9
+ | File | Tests | Layer |
10
+ |------|-------|-------|
11
+ | test_core.py | 31 | Unit (mocked) |
12
+ | test_e2e.py | 30 | E2E (live) + Subprocess |
13
+
14
+ **Total: 61 tests**
15
+
16
+
17
+ ### test_core.py
18
+
19
+ **TestStoryModel** (4 tests) — Unit (mocked)
20
+
21
+ - `test_to_dict_includes_computed` — to dict includes computed
22
+ - `test_domain_extraction` — domain extraction
23
+ - `test_domain_empty_when_no_url` — domain empty when no url
24
+ - `test_age_empty_when_no_time` — age empty when no time
25
+
26
+ **TestCommentModel** (3 tests) — Unit (mocked)
27
+
28
+ - `test_text_plain_strips_html` — text plain strips html
29
+ - `test_text_plain_unescapes_entities` — text plain unescapes entities
30
+ - `test_text_plain_empty` — text plain empty
31
+
32
+ **TestUserModel** (2 tests) — Unit (mocked)
33
+
34
+ - `test_to_dict_trims_submitted` — to dict trims submitted
35
+ - `test_about_plain` — about plain
36
+
37
+ **TestSearchResultModel** (1 tests) — Unit (mocked)
38
+
39
+ - `test_to_dict` — to dict
40
+
41
+ **TestClientHTTPErrors** (5 tests) — Unit (mocked)
42
+
43
+ - `test_rate_limit_raises` — rate limit raises
44
+ - `test_server_error_raises` — server error raises
45
+ - `test_404_raises_not_found` — 404 raises not found
46
+ - `test_network_error_raises` — network error raises
47
+ - `test_timeout_raises_network_error` — timeout raises network error
48
+
49
+ **TestClientParsing** (4 tests) — Unit (mocked)
50
+
51
+ - `test_get_story_builds_model` — get story builds model
52
+ - `test_get_user_builds_model` — get user builds model
53
+ - `test_get_user_not_found` — get user not found
54
+ - `test_search_builds_results` — search builds results
55
+
56
+ **TestExceptionsToDicts** (6 tests) — Unit (mocked)
57
+
58
+ - `test_app_error_to_dict` — app error to dict
59
+ - `test_rate_limit_error_to_dict` — rate limit error to dict
60
+ - `test_server_error_to_dict` — server error to dict
61
+ - `test_not_found_to_dict` — not found to dict
62
+ - `test_auth_error_to_dict` — auth error to dict
63
+ - `test_auth_error_recoverable` — auth error recoverable
64
+
65
+ **TestAuthModule** (6 tests) — Unit (mocked)
66
+
67
+ - `test_require_auth_raises_without_cookie` — require auth raises without cookie
68
+ - `test_require_auth_returns_cookie` — require auth returns cookie
69
+ - `test_extract_auth_token_from_html` — extract auth token from html
70
+ - `test_extract_auth_token_missing_raises` — extract auth token missing raises
71
+ - `test_parse_stories_from_html_extracts_ids` — parse stories from html extracts ids
72
+ - `test_authenticated_get_html_403_raises_auth_error` — authenticated get html 403 raises auth error
73
+
74
+ ### test_e2e.py
75
+
76
+ **TestStoriesFeedE2E** (8 tests) — E2E (live)
77
+
78
+ - `test_top_stories_returns_list` — top stories returns list
79
+ - `test_new_stories_returns_list` — new stories returns list
80
+ - `test_best_stories_returns_list` — best stories returns list
81
+ - `test_ask_stories_returns_list` — ask stories returns list
82
+ - `test_show_stories_returns_list` — show stories returns list
83
+ - `test_job_stories_returns_list` — job stories returns list
84
+ - `test_story_has_required_fields` — story has required fields
85
+ - `test_story_ids_returns_ints` — story ids returns ints
86
+
87
+ **TestStoryViewE2E** (2 tests) — E2E (live)
88
+
89
+ - `test_get_story_by_id` — get story by id
90
+ - `test_get_comments_for_story` — get comments for story
91
+
92
+ **TestUserE2E** (2 tests) — E2E (live)
93
+
94
+ - `test_get_user_profile` — get user profile
95
+ - `test_get_nonexistent_user_raises` — get nonexistent user raises
96
+
97
+ **TestSearchE2E** (3 tests) — E2E (live)
98
+
99
+ - `test_search_stories` — search stories
100
+ - `test_search_by_date` — search by date
101
+ - `test_search_comments` — search comments
102
+
103
+ **TestSubprocess** (11 tests) — Subprocess
104
+
105
+ - `test_version` — version
106
+ - `test_help` — help
107
+ - `test_stories_top_json` — stories top json
108
+ - `test_search_stories_json` — search stories json
109
+ - `test_user_view_json` — user view json
110
+ - `test_stories_view_json` — stories view json
111
+ - `test_auth_status_json` — auth status json
112
+ - `test_auth_help` — auth help
113
+ - `test_upvote_help` — upvote help
114
+ - `test_submit_help` — submit help
115
+ - `test_comment_help` — comment help
116
+
117
+ **TestAuthActionsE2E** (4 tests) — E2E (live)
118
+
119
+ - `test_upvote_story` — upvote story
120
+ - `test_get_submissions` — get submissions
121
+ - `test_favorite_and_list` — favorite and list
122
+ - `test_auth_validate` — auth validate
123
+
124
+ ---
125
+
126
+ ## Part 2: Test Results
127
+
128
+ ```
129
+ ============================= 31 passed in 0.32s ==============================
130
+ (test_core.py — all unit tests pass)
131
+
132
+ ============================= 30 passed in 159.47s ============================
133
+ (test_e2e.py — all E2E + subprocess + auth tests pass)
134
+ ```
135
+
136
+ **Pass Rate: 61/61 (100%)**
137
+
138
+ ### Notes
139
+ - E2E tests hit live Firebase API, Algolia search API, and HN web endpoints
140
+ - Subprocess tests use `_resolve_cli()` with `CLI_WEB_FORCE_INSTALLED=1`
141
+ - Auth E2E tests require valid `auth.json` (fail, not skip, without it)
142
+ - Auth actions (upvote, favorite) scrape CSRF tokens from page HTML
143
+ - Parallel HTTP fetching via asyncio in client
File without changes