nulltap 0.1.0__tar.gz → 0.1.1__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.
- nulltap-0.1.1/PKG-INFO +108 -0
- nulltap-0.1.1/README.md +84 -0
- {nulltap-0.1.0 → nulltap-0.1.1}/pyproject.toml +5 -3
- {nulltap-0.1.0 → nulltap-0.1.1}/src/nulltap/__init__.py +1 -1
- {nulltap-0.1.0 → nulltap-0.1.1}/src/nulltap/cli.py +182 -39
- nulltap-0.1.1/src/nulltap.egg-info/PKG-INFO +108 -0
- {nulltap-0.1.0 → nulltap-0.1.1}/tests/test_cli.py +65 -7
- nulltap-0.1.0/PKG-INFO +0 -98
- nulltap-0.1.0/README.md +0 -76
- nulltap-0.1.0/src/nulltap.egg-info/PKG-INFO +0 -98
- {nulltap-0.1.0 → nulltap-0.1.1}/LICENSE +0 -0
- {nulltap-0.1.0 → nulltap-0.1.1}/setup.cfg +0 -0
- {nulltap-0.1.0 → nulltap-0.1.1}/src/nulltap/__main__.py +0 -0
- {nulltap-0.1.0 → nulltap-0.1.1}/src/nulltap.egg-info/SOURCES.txt +0 -0
- {nulltap-0.1.0 → nulltap-0.1.1}/src/nulltap.egg-info/dependency_links.txt +0 -0
- {nulltap-0.1.0 → nulltap-0.1.1}/src/nulltap.egg-info/entry_points.txt +0 -0
- {nulltap-0.1.0 → nulltap-0.1.1}/src/nulltap.egg-info/top_level.txt +0 -0
nulltap-0.1.1/PKG-INFO
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nulltap
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Read Nulltap cybersecurity and AI articles from a terminal.
|
|
5
|
+
Author: Justin Howe
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nulltap.sh
|
|
8
|
+
Project-URL: Repository, https://github.com/JustinHowe/nulltap
|
|
9
|
+
Project-URL: Issues, https://github.com/JustinHowe/nulltap/issues
|
|
10
|
+
Project-URL: Releases, https://github.com/JustinHowe/nulltap/releases
|
|
11
|
+
Keywords: cybersecurity,ai,news,cli,nulltap
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# nulltap
|
|
26
|
+
|
|
27
|
+
Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and read the full article without opening a browser.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pipx install nulltap
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
To upgrade an existing installation:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pipx upgrade nulltap
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A regular pip installation also works: `python -m pip install nulltap`.
|
|
44
|
+
|
|
45
|
+
## Start here
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
nulltap # browse recent articles
|
|
49
|
+
nulltap latest --days 7 # articles published in the last seven days
|
|
50
|
+
nulltap topics # list every topic and its article count
|
|
51
|
+
nulltap topic identity # browse one topic
|
|
52
|
+
nulltap search "token theft" # search titles, summaries, and topic tags
|
|
53
|
+
nulltap read 2 # read the second result in the terminal
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
|
|
57
|
+
|
|
58
|
+
Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
|
|
59
|
+
|
|
60
|
+
## Topics
|
|
61
|
+
|
|
62
|
+
Nulltap currently publishes under these topics:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
endpoint
|
|
66
|
+
cloud
|
|
67
|
+
network
|
|
68
|
+
identity
|
|
69
|
+
appsec
|
|
70
|
+
AI
|
|
71
|
+
threats
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
|
|
75
|
+
|
|
76
|
+
## Search and recent filters
|
|
77
|
+
|
|
78
|
+
Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
|
|
79
|
+
|
|
80
|
+
Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
|
|
81
|
+
|
|
82
|
+
Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
nulltap search ransomware --days 30
|
|
86
|
+
nulltap topic cloud --days 14
|
|
87
|
+
nulltap topics --days 90
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Automation
|
|
91
|
+
|
|
92
|
+
Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
nulltap latest --days 7 --json
|
|
96
|
+
nulltap search ransomware --json
|
|
97
|
+
nulltap topics --plain
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
|
|
101
|
+
|
|
102
|
+
## Network and privacy
|
|
103
|
+
|
|
104
|
+
Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
|
|
105
|
+
|
|
106
|
+
Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
|
|
107
|
+
|
|
108
|
+
Licensed under the MIT License.
|
nulltap-0.1.1/README.md
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# nulltap
|
|
2
|
+
|
|
3
|
+
Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and read the full article without opening a browser.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
pipx install nulltap
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
To upgrade an existing installation:
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pipx upgrade nulltap
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
A regular pip installation also works: `python -m pip install nulltap`.
|
|
20
|
+
|
|
21
|
+
## Start here
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
nulltap # browse recent articles
|
|
25
|
+
nulltap latest --days 7 # articles published in the last seven days
|
|
26
|
+
nulltap topics # list every topic and its article count
|
|
27
|
+
nulltap topic identity # browse one topic
|
|
28
|
+
nulltap search "token theft" # search titles, summaries, and topic tags
|
|
29
|
+
nulltap read 2 # read the second result in the terminal
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
|
|
33
|
+
|
|
34
|
+
Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
|
|
35
|
+
|
|
36
|
+
## Topics
|
|
37
|
+
|
|
38
|
+
Nulltap currently publishes under these topics:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
endpoint
|
|
42
|
+
cloud
|
|
43
|
+
network
|
|
44
|
+
identity
|
|
45
|
+
appsec
|
|
46
|
+
AI
|
|
47
|
+
threats
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
|
|
51
|
+
|
|
52
|
+
## Search and recent filters
|
|
53
|
+
|
|
54
|
+
Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
|
|
55
|
+
|
|
56
|
+
Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
|
|
57
|
+
|
|
58
|
+
Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
nulltap search ransomware --days 30
|
|
62
|
+
nulltap topic cloud --days 14
|
|
63
|
+
nulltap topics --days 90
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Automation
|
|
67
|
+
|
|
68
|
+
Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
nulltap latest --days 7 --json
|
|
72
|
+
nulltap search ransomware --json
|
|
73
|
+
nulltap topics --plain
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
|
|
77
|
+
|
|
78
|
+
## Network and privacy
|
|
79
|
+
|
|
80
|
+
Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
|
|
81
|
+
|
|
82
|
+
Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
|
|
83
|
+
|
|
84
|
+
Licensed under the MIT License.
|
|
@@ -4,13 +4,13 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "nulltap"
|
|
7
|
-
version = "0.1.
|
|
8
|
-
description = "Read
|
|
7
|
+
version = "0.1.1"
|
|
8
|
+
description = "Read Nulltap cybersecurity and AI articles from a terminal."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = "MIT"
|
|
12
12
|
authors = [{ name = "Justin Howe" }]
|
|
13
|
-
keywords = ["cybersecurity", "news", "cli", "nulltap"]
|
|
13
|
+
keywords = ["cybersecurity", "ai", "news", "cli", "nulltap"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 3 - Alpha",
|
|
16
16
|
"Environment :: Console",
|
|
@@ -29,6 +29,8 @@ nulltap = "nulltap.cli:main"
|
|
|
29
29
|
[project.urls]
|
|
30
30
|
Homepage = "https://nulltap.sh"
|
|
31
31
|
Repository = "https://github.com/JustinHowe/nulltap"
|
|
32
|
+
Issues = "https://github.com/JustinHowe/nulltap/issues"
|
|
33
|
+
Releases = "https://github.com/JustinHowe/nulltap/releases"
|
|
32
34
|
|
|
33
35
|
[tool.setuptools.packages.find]
|
|
34
36
|
where = ["src"]
|
|
@@ -14,6 +14,7 @@ import urllib.request
|
|
|
14
14
|
import webbrowser
|
|
15
15
|
from collections import Counter
|
|
16
16
|
from collections.abc import Callable, Iterable, Sequence
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
17
18
|
from typing import Any, TextIO
|
|
18
19
|
|
|
19
20
|
from . import __version__
|
|
@@ -109,6 +110,35 @@ def normalize_item(raw: Any) -> dict[str, Any] | None:
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
|
|
113
|
+
def normalize_topic(raw: Any) -> dict[str, str] | None:
|
|
114
|
+
if not isinstance(raw, dict):
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
topic_id = safe_text(raw.get("id") or raw.get("topic")).lower()
|
|
118
|
+
if not topic_id:
|
|
119
|
+
return None
|
|
120
|
+
return {
|
|
121
|
+
"topic": topic_id,
|
|
122
|
+
"label": safe_text(raw.get("label")) or topic_id,
|
|
123
|
+
"description": safe_text(raw.get("description")),
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def normalize_feed_payload(payload: Any) -> dict[str, list[dict[str, Any]]]:
|
|
128
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
|
|
129
|
+
raise FeedError("feed does not contain an items array")
|
|
130
|
+
|
|
131
|
+
items = [item for raw in payload["items"] if (item := normalize_item(raw))]
|
|
132
|
+
raw_topics = payload.get("topics", [])
|
|
133
|
+
if not isinstance(raw_topics, list):
|
|
134
|
+
raw_topics = []
|
|
135
|
+
topics = [topic for raw in raw_topics if (topic := normalize_topic(raw))]
|
|
136
|
+
return {
|
|
137
|
+
"items": sorted(items, key=lambda item: item["date_published"], reverse=True),
|
|
138
|
+
"topics": list({topic["topic"]: topic for topic in topics}.values()),
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
112
142
|
def _fetch_json(
|
|
113
143
|
url: str,
|
|
114
144
|
*,
|
|
@@ -150,11 +180,11 @@ def _fetch_json(
|
|
|
150
180
|
raise FeedError(f"{label} returned invalid JSON") from exc
|
|
151
181
|
|
|
152
182
|
|
|
153
|
-
def
|
|
183
|
+
def fetch_feed_document(
|
|
154
184
|
url: str = DEFAULT_FEED_URL,
|
|
155
185
|
timeout: float = 10.0,
|
|
156
186
|
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
157
|
-
) -> list[dict[str, Any]]:
|
|
187
|
+
) -> dict[str, list[dict[str, Any]]]:
|
|
158
188
|
payload = _fetch_json(
|
|
159
189
|
url,
|
|
160
190
|
timeout=timeout,
|
|
@@ -163,11 +193,16 @@ def fetch_feed(
|
|
|
163
193
|
accept="application/feed+json, application/json",
|
|
164
194
|
opener=opener,
|
|
165
195
|
)
|
|
166
|
-
|
|
167
|
-
raise FeedError("feed does not contain an items array")
|
|
196
|
+
return normalize_feed_payload(payload)
|
|
168
197
|
|
|
169
|
-
|
|
170
|
-
|
|
198
|
+
|
|
199
|
+
def fetch_feed(
|
|
200
|
+
url: str = DEFAULT_FEED_URL,
|
|
201
|
+
timeout: float = 10.0,
|
|
202
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
203
|
+
) -> list[dict[str, Any]]:
|
|
204
|
+
"""Return feed items for callers using the 0.1.0 list-based API."""
|
|
205
|
+
return fetch_feed_document(url, timeout, opener)["items"]
|
|
171
206
|
|
|
172
207
|
|
|
173
208
|
def fetch_article(
|
|
@@ -219,6 +254,16 @@ def positive_int(value: str) -> int:
|
|
|
219
254
|
return parsed
|
|
220
255
|
|
|
221
256
|
|
|
257
|
+
def positive_days(value: str) -> int:
|
|
258
|
+
try:
|
|
259
|
+
parsed = int(value)
|
|
260
|
+
except ValueError as exc:
|
|
261
|
+
raise argparse.ArgumentTypeError("must be an integer") from exc
|
|
262
|
+
if parsed < 1 or parsed > 36500:
|
|
263
|
+
raise argparse.ArgumentTypeError("must be between 1 and 36500")
|
|
264
|
+
return parsed
|
|
265
|
+
|
|
266
|
+
|
|
222
267
|
def build_parser() -> argparse.ArgumentParser:
|
|
223
268
|
common = argparse.ArgumentParser(add_help=False, argument_default=argparse.SUPPRESS)
|
|
224
269
|
common.add_argument(
|
|
@@ -230,42 +275,82 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
230
275
|
common.add_argument("--json", action="store_true", help="emit JSON for scripts")
|
|
231
276
|
common.add_argument("--plain", action="store_true", help="print once without menus or a pager")
|
|
232
277
|
common.add_argument("--page-size", type=positive_int, metavar="N", help="articles per page (default: 5)")
|
|
278
|
+
common.add_argument("--days", type=positive_days, metavar="N", help="limit the feed to the last N days")
|
|
233
279
|
common.add_argument("--no-color", action="store_true", help="disable terminal color")
|
|
234
280
|
|
|
235
281
|
parser = argparse.ArgumentParser(
|
|
236
282
|
prog="nulltap",
|
|
237
283
|
parents=[common],
|
|
238
|
-
description="
|
|
284
|
+
description="Read Nulltap articles without leaving the terminal.",
|
|
285
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
286
|
+
epilog=textwrap.dedent(
|
|
287
|
+
"""
|
|
288
|
+
examples:
|
|
289
|
+
nulltap browse recent articles
|
|
290
|
+
nulltap latest --days 7 show the last seven days
|
|
291
|
+
nulltap topics list the current topic catalog
|
|
292
|
+
nulltap topic identity browse one topic
|
|
293
|
+
nulltap search "token theft" search titles, summaries, and tags
|
|
294
|
+
nulltap read 2 read the second result in the terminal
|
|
295
|
+
|
|
296
|
+
Run 'nulltap COMMAND --help' for command-specific options.
|
|
297
|
+
"""
|
|
298
|
+
),
|
|
239
299
|
)
|
|
240
300
|
parser.add_argument("--version", action="version", version=f"nulltap {__version__}")
|
|
241
|
-
subparsers = parser.add_subparsers(dest="command")
|
|
301
|
+
subparsers = parser.add_subparsers(dest="command", title="commands", metavar="COMMAND")
|
|
242
302
|
|
|
243
|
-
browse = subparsers.add_parser(
|
|
244
|
-
|
|
303
|
+
browse = subparsers.add_parser(
|
|
304
|
+
"browse",
|
|
305
|
+
parents=[common],
|
|
306
|
+
help="browse the feed (default)",
|
|
307
|
+
description="Browse recent Nulltap articles, newest first.",
|
|
308
|
+
)
|
|
309
|
+
browse.add_argument("-n", "--limit", type=positive_int, default=50, help="maximum articles shown (default: 50)")
|
|
245
310
|
browse.add_argument("-t", "--topic", help="limit results to one topic")
|
|
246
311
|
|
|
247
|
-
latest = subparsers.add_parser(
|
|
248
|
-
|
|
312
|
+
latest = subparsers.add_parser(
|
|
313
|
+
"latest",
|
|
314
|
+
parents=[common],
|
|
315
|
+
help="browse the newest articles",
|
|
316
|
+
description="Browse the newest Nulltap articles.",
|
|
317
|
+
)
|
|
318
|
+
latest.add_argument("-n", "--limit", type=positive_int, default=50, help="maximum articles shown (default: 50)")
|
|
249
319
|
latest.add_argument("-t", "--topic", help="limit results to one topic")
|
|
250
320
|
|
|
251
|
-
subparsers.add_parser(
|
|
321
|
+
subparsers.add_parser(
|
|
322
|
+
"topics",
|
|
323
|
+
parents=[common],
|
|
324
|
+
help="list topics or choose one interactively",
|
|
325
|
+
description="List every Nulltap topic and its article count.",
|
|
326
|
+
)
|
|
252
327
|
|
|
253
|
-
topic = subparsers.add_parser(
|
|
254
|
-
|
|
255
|
-
|
|
328
|
+
topic = subparsers.add_parser(
|
|
329
|
+
"topic",
|
|
330
|
+
parents=[common],
|
|
331
|
+
help="browse articles for one topic",
|
|
332
|
+
description="Browse articles filed under one topic.",
|
|
333
|
+
)
|
|
334
|
+
topic.add_argument("name", nargs="?", help="topic ID; run 'nulltap topics' to list them")
|
|
335
|
+
topic.add_argument("-n", "--limit", type=positive_int, default=100, help="maximum articles shown (default: 100)")
|
|
256
336
|
|
|
257
|
-
search = subparsers.add_parser(
|
|
337
|
+
search = subparsers.add_parser(
|
|
338
|
+
"search",
|
|
339
|
+
parents=[common],
|
|
340
|
+
help="search titles, summaries, and topic tags",
|
|
341
|
+
description="Search article titles, summaries, and topic tags.",
|
|
342
|
+
)
|
|
258
343
|
search.add_argument("query", nargs="*", help="words to search for")
|
|
259
344
|
search.add_argument("-t", "--topic", help="limit results to one topic")
|
|
260
|
-
search.add_argument("-n", "--limit", type=positive_int, default=100)
|
|
345
|
+
search.add_argument("-n", "--limit", type=positive_int, default=100, help="maximum matches shown (default: 100)")
|
|
261
346
|
|
|
262
|
-
read = subparsers.add_parser("read", parents=[common], help="read
|
|
347
|
+
read = subparsers.add_parser("read", parents=[common], help="read in the terminal or browse when omitted")
|
|
263
348
|
read.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
|
|
264
349
|
|
|
265
350
|
show = subparsers.add_parser("show", parents=[common], help="alias for read")
|
|
266
351
|
show.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
|
|
267
352
|
|
|
268
|
-
open_command = subparsers.add_parser("open", parents=[common], help="
|
|
353
|
+
open_command = subparsers.add_parser("open", parents=[common], help="optional handoff to a web browser")
|
|
269
354
|
open_command.add_argument("target", help="result number, article ID, slug, or URL")
|
|
270
355
|
|
|
271
356
|
return parser
|
|
@@ -278,6 +363,33 @@ def filter_topic(items: Iterable[dict[str, Any]], topic: str | None) -> list[dic
|
|
|
278
363
|
return [item for item in items if wanted in item["tags"]]
|
|
279
364
|
|
|
280
365
|
|
|
366
|
+
def filter_days(
|
|
367
|
+
items: Iterable[dict[str, Any]],
|
|
368
|
+
days: int | None,
|
|
369
|
+
*,
|
|
370
|
+
now: datetime | None = None,
|
|
371
|
+
) -> list[dict[str, Any]]:
|
|
372
|
+
if not days:
|
|
373
|
+
return list(items)
|
|
374
|
+
|
|
375
|
+
current = now or datetime.now(timezone.utc)
|
|
376
|
+
if current.tzinfo is None:
|
|
377
|
+
current = current.replace(tzinfo=timezone.utc)
|
|
378
|
+
cutoff = current.astimezone(timezone.utc) - timedelta(days=days)
|
|
379
|
+
selected = []
|
|
380
|
+
for item in items:
|
|
381
|
+
raw_date = item.get("date_published", "")
|
|
382
|
+
try:
|
|
383
|
+
published = datetime.fromisoformat(raw_date.replace("Z", "+00:00"))
|
|
384
|
+
except (AttributeError, TypeError, ValueError):
|
|
385
|
+
continue
|
|
386
|
+
if published.tzinfo is None:
|
|
387
|
+
published = published.replace(tzinfo=timezone.utc)
|
|
388
|
+
if published.astimezone(timezone.utc) >= cutoff:
|
|
389
|
+
selected.append(item)
|
|
390
|
+
return selected
|
|
391
|
+
|
|
392
|
+
|
|
281
393
|
def search_items(items: Iterable[dict[str, Any]], query: str) -> list[dict[str, Any]]:
|
|
282
394
|
phrase = safe_text(query).lower()
|
|
283
395
|
tokens = [token for token in phrase.split() if token]
|
|
@@ -609,23 +721,41 @@ def json_dump(value: Any, stream: TextIO) -> None:
|
|
|
609
721
|
print(file=stream)
|
|
610
722
|
|
|
611
723
|
|
|
612
|
-
def topic_counts(
|
|
724
|
+
def topic_counts(
|
|
725
|
+
items: Iterable[dict[str, Any]],
|
|
726
|
+
catalog: Iterable[dict[str, str]] = (),
|
|
727
|
+
) -> list[dict[str, Any]]:
|
|
613
728
|
counts = Counter(tag for item in items for tag in item["tags"])
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
729
|
+
rows = []
|
|
730
|
+
seen = set()
|
|
731
|
+
for entry in catalog:
|
|
732
|
+
topic = entry["topic"]
|
|
733
|
+
if topic in seen:
|
|
734
|
+
continue
|
|
735
|
+
seen.add(topic)
|
|
736
|
+
rows.append({**entry, "articles": counts.get(topic, 0)})
|
|
737
|
+
for topic, count in sorted(counts.items(), key=lambda row: (-row[1], row[0])):
|
|
738
|
+
if topic not in seen:
|
|
739
|
+
rows.append({"topic": topic, "label": topic, "description": "", "articles": count})
|
|
740
|
+
return rows
|
|
618
741
|
|
|
619
742
|
|
|
620
|
-
def choose_topic(
|
|
621
|
-
|
|
743
|
+
def choose_topic(
|
|
744
|
+
items: Sequence[dict[str, Any]],
|
|
745
|
+
catalog: Sequence[dict[str, str]],
|
|
746
|
+
stdin: TextIO,
|
|
747
|
+
stdout: TextIO,
|
|
748
|
+
use_color: bool,
|
|
749
|
+
) -> str | None:
|
|
750
|
+
topics = topic_counts(items, catalog)
|
|
622
751
|
if not topics:
|
|
623
752
|
print("No published topics yet.", file=stdout)
|
|
624
753
|
return None
|
|
625
754
|
print(file=stdout)
|
|
626
755
|
print(paint("Topics", "1;38;5;214", use_color), file=stdout)
|
|
627
756
|
for index, row in enumerate(topics, 1):
|
|
628
|
-
|
|
757
|
+
noun = "article" if row["articles"] == 1 else "articles"
|
|
758
|
+
print(f"[{index}] {row['label']:<12} {row['articles']} {noun}", file=stdout)
|
|
629
759
|
choice = read_prompt("\nChoose a topic number, or q to quit\n> ", stdin, stdout).lower()
|
|
630
760
|
if choice in {"q", "quit", "exit"}:
|
|
631
761
|
return None
|
|
@@ -641,7 +771,7 @@ def run(
|
|
|
641
771
|
stdin: TextIO = sys.stdin,
|
|
642
772
|
stdout: TextIO = sys.stdout,
|
|
643
773
|
stderr: TextIO = sys.stderr,
|
|
644
|
-
feed_loader: Callable[[str, float],
|
|
774
|
+
feed_loader: Callable[[str, float], Any] = fetch_feed_document,
|
|
645
775
|
article_loader: Callable[[dict[str, Any], float], dict[str, Any]] = fetch_article,
|
|
646
776
|
browser_opener: Callable[..., bool] = webbrowser.open,
|
|
647
777
|
pager: Callable[[str], None] = pydoc.pager,
|
|
@@ -659,21 +789,31 @@ def run(
|
|
|
659
789
|
if timeout <= 0 or timeout > 120:
|
|
660
790
|
parser.error("--timeout must be greater than 0 and no more than 120 seconds")
|
|
661
791
|
|
|
662
|
-
|
|
792
|
+
loaded_feed = feed_loader(feed_url, timeout)
|
|
793
|
+
if isinstance(loaded_feed, dict):
|
|
794
|
+
feed = normalize_feed_payload(loaded_feed)
|
|
795
|
+
else:
|
|
796
|
+
feed = normalize_feed_payload({"items": list(loaded_feed), "topics": []})
|
|
797
|
+
items = filter_days(feed["items"], getattr(args, "days", None))
|
|
798
|
+
topic_catalog = feed["topics"]
|
|
663
799
|
use_color = color_enabled(stdout, no_color)
|
|
664
800
|
interactive = interactive_enabled(stdin, stdout, plain, json_output)
|
|
665
801
|
|
|
666
802
|
if command == "topics":
|
|
667
|
-
topics = topic_counts(items)
|
|
803
|
+
topics = topic_counts(items, topic_catalog)
|
|
668
804
|
if json_output:
|
|
669
805
|
json_dump(topics, stdout)
|
|
670
806
|
return 0
|
|
671
807
|
if interactive:
|
|
672
|
-
chosen = choose_topic(items, stdin, stdout, use_color)
|
|
808
|
+
chosen = choose_topic(items, topic_catalog, stdin, stdout, use_color)
|
|
673
809
|
if chosen:
|
|
810
|
+
chosen_label = next(
|
|
811
|
+
(row["label"] for row in topics if row["topic"] == chosen),
|
|
812
|
+
chosen,
|
|
813
|
+
)
|
|
674
814
|
return browse_items(
|
|
675
815
|
filter_topic(items, chosen),
|
|
676
|
-
heading=f"Topic: {
|
|
816
|
+
heading=f"Topic: {chosen_label}",
|
|
677
817
|
page_size=page_size,
|
|
678
818
|
stdin=stdin,
|
|
679
819
|
stdout=stdout,
|
|
@@ -684,9 +824,9 @@ def run(
|
|
|
684
824
|
)
|
|
685
825
|
return 0
|
|
686
826
|
if topics:
|
|
687
|
-
width = max(len(row["
|
|
827
|
+
width = max(len(row["label"]) for row in topics)
|
|
688
828
|
for row in topics:
|
|
689
|
-
print(f"{row['
|
|
829
|
+
print(f"{row['label']:<{width}} {row['articles']}", file=stdout)
|
|
690
830
|
else:
|
|
691
831
|
print("No published topics yet.", file=stdout)
|
|
692
832
|
return 0
|
|
@@ -694,21 +834,24 @@ def run(
|
|
|
694
834
|
if command == "topic":
|
|
695
835
|
topic_name = args.name
|
|
696
836
|
if not topic_name and interactive:
|
|
697
|
-
topic_name = choose_topic(items, stdin, stdout, use_color)
|
|
837
|
+
topic_name = choose_topic(items, topic_catalog, stdin, stdout, use_color)
|
|
698
838
|
if not topic_name:
|
|
699
839
|
return 0
|
|
700
840
|
if not topic_name:
|
|
701
841
|
print("nulltap: provide a topic name or run this command in a terminal", file=stderr)
|
|
702
842
|
return 1
|
|
703
843
|
selected = filter_topic(items, topic_name)
|
|
704
|
-
|
|
705
|
-
|
|
844
|
+
available_rows = topic_counts(items, topic_catalog)
|
|
845
|
+
wanted_topic = safe_text(topic_name).lower()
|
|
846
|
+
if wanted_topic not in {row["topic"] for row in available_rows}:
|
|
847
|
+
available = ", ".join(row["topic"] for row in available_rows)
|
|
706
848
|
print(f"nulltap: no topic named '{safe_text(topic_name)}'", file=stderr)
|
|
707
849
|
if available:
|
|
708
850
|
print(f"available topics: {available}", file=stderr)
|
|
709
851
|
return 1
|
|
710
852
|
selected = selected[: args.limit]
|
|
711
|
-
|
|
853
|
+
label = next((row["label"] for row in available_rows if row["topic"] == wanted_topic), wanted_topic)
|
|
854
|
+
heading = f"Topic: {label}"
|
|
712
855
|
elif command == "search":
|
|
713
856
|
query = " ".join(args.query)
|
|
714
857
|
if not query and interactive:
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nulltap
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Read Nulltap cybersecurity and AI articles from a terminal.
|
|
5
|
+
Author: Justin Howe
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://nulltap.sh
|
|
8
|
+
Project-URL: Repository, https://github.com/JustinHowe/nulltap
|
|
9
|
+
Project-URL: Issues, https://github.com/JustinHowe/nulltap/issues
|
|
10
|
+
Project-URL: Releases, https://github.com/JustinHowe/nulltap/releases
|
|
11
|
+
Keywords: cybersecurity,ai,news,cli,nulltap
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Security
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# nulltap
|
|
26
|
+
|
|
27
|
+
Read [Nulltap](https://nulltap.sh) from a terminal. Browse recent cybersecurity and AI articles, search the feed, filter by topic, and read the full article without opening a browser.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
Python 3.10 or newer is required. [pipx](https://pipx.pypa.io/) keeps the command in its own environment:
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pipx install nulltap
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
To upgrade an existing installation:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
pipx upgrade nulltap
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
A regular pip installation also works: `python -m pip install nulltap`.
|
|
44
|
+
|
|
45
|
+
## Start here
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
nulltap # browse recent articles
|
|
49
|
+
nulltap latest --days 7 # articles published in the last seven days
|
|
50
|
+
nulltap topics # list every topic and its article count
|
|
51
|
+
nulltap topic identity # browse one topic
|
|
52
|
+
nulltap search "token theft" # search titles, summaries, and topic tags
|
|
53
|
+
nulltap read 2 # read the second result in the terminal
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Interactive lists show five articles at a time. Enter a result number to read it, `n` or `p` to change pages, and `q` to leave. Use `--page-size N` if you want a different page size.
|
|
57
|
+
|
|
58
|
+
Articles open in the terminal pager with headings, lists, quotations, code blocks, image descriptions, links, and primary sources formatted for the console. Press `q` to return to the article list.
|
|
59
|
+
|
|
60
|
+
## Topics
|
|
61
|
+
|
|
62
|
+
Nulltap currently publishes under these topics:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
endpoint
|
|
66
|
+
cloud
|
|
67
|
+
network
|
|
68
|
+
identity
|
|
69
|
+
appsec
|
|
70
|
+
AI
|
|
71
|
+
threats
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`nulltap topics` reads the catalog from nulltap.sh and shows every topic, including topics that do not have a published article yet. Topic IDs are case-insensitive.
|
|
75
|
+
|
|
76
|
+
## Search and recent filters
|
|
77
|
+
|
|
78
|
+
Search runs locally over the feed already downloaded from nulltap.sh. Search words are not sent to Nulltap or another service.
|
|
79
|
+
|
|
80
|
+
Every search word must match the title, summary, or a topic tag. Title matches rank first, followed by topic and summary matches. Publication date breaks ties.
|
|
81
|
+
|
|
82
|
+
Use `--days N` with browsing, topics, or search to limit the downloaded feed by publication time:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
nulltap search ransomware --days 30
|
|
86
|
+
nulltap topic cloud --days 14
|
|
87
|
+
nulltap topics --days 90
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Automation
|
|
91
|
+
|
|
92
|
+
Use `--json` for structured output or `--plain` for stable text without menus, color, or a pager:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
nulltap latest --days 7 --json
|
|
96
|
+
nulltap search ransomware --json
|
|
97
|
+
nulltap topics --plain
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Redirected output automatically uses non-interactive mode. Article IDs and slugs remain accepted by `read` for scripts, but ordinary browsing does not require them.
|
|
101
|
+
|
|
102
|
+
## Network and privacy
|
|
103
|
+
|
|
104
|
+
Listing and search commands make one read-only request to the public feed. Reading an article makes one additional request for its text. The client has no analytics, account, background process, or local database. Feed and article text are stripped of terminal control sequences before display.
|
|
105
|
+
|
|
106
|
+
Contributor setup, local feed overrides, and release checks are documented in [CONTRIBUTING.md](https://github.com/JustinHowe/nulltap/blob/main/CONTRIBUTING.md).
|
|
107
|
+
|
|
108
|
+
Licensed under the MIT License.
|
|
@@ -2,10 +2,20 @@ import io
|
|
|
2
2
|
import json
|
|
3
3
|
import re
|
|
4
4
|
import unittest
|
|
5
|
+
from datetime import datetime, timezone
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
8
|
from nulltap import __version__
|
|
8
|
-
from nulltap.cli import
|
|
9
|
+
from nulltap.cli import (
|
|
10
|
+
build_parser,
|
|
11
|
+
fetch_feed,
|
|
12
|
+
fetch_feed_document,
|
|
13
|
+
filter_days,
|
|
14
|
+
normalize_item,
|
|
15
|
+
render_article,
|
|
16
|
+
run,
|
|
17
|
+
search_items,
|
|
18
|
+
)
|
|
9
19
|
|
|
10
20
|
|
|
11
21
|
ITEMS = [
|
|
@@ -35,6 +45,16 @@ ITEMS = [
|
|
|
35
45
|
},
|
|
36
46
|
]
|
|
37
47
|
|
|
48
|
+
TOPICS = [
|
|
49
|
+
{"id": "endpoint", "label": "endpoint", "description": "Endpoint security."},
|
|
50
|
+
{"id": "cloud", "label": "cloud", "description": "Cloud security."},
|
|
51
|
+
{"id": "network", "label": "network", "description": "Network security."},
|
|
52
|
+
{"id": "identity", "label": "identity", "description": "Identity security."},
|
|
53
|
+
{"id": "appsec", "label": "appsec", "description": "Application security."},
|
|
54
|
+
{"id": "ai", "label": "AI", "description": "AI security."},
|
|
55
|
+
{"id": "threats", "label": "threats", "description": "Threat activity."},
|
|
56
|
+
]
|
|
57
|
+
|
|
38
58
|
|
|
39
59
|
class FakeResponse:
|
|
40
60
|
def __init__(self, payload):
|
|
@@ -70,7 +90,10 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
70
90
|
args,
|
|
71
91
|
stdout=stdout,
|
|
72
92
|
stderr=stderr,
|
|
73
|
-
feed_loader=lambda _url, _timeout:
|
|
93
|
+
feed_loader=lambda _url, _timeout: {
|
|
94
|
+
"items": list(ITEMS if items is None else items),
|
|
95
|
+
"topics": TOPICS,
|
|
96
|
+
},
|
|
74
97
|
article_loader=lambda item, _timeout: {
|
|
75
98
|
**item,
|
|
76
99
|
"content_text": "## Access path\n\nThe token crossed a trust boundary.\n\n[Image: Token path]",
|
|
@@ -90,7 +113,10 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
90
113
|
stdin=stdin,
|
|
91
114
|
stdout=stdout,
|
|
92
115
|
stderr=stderr,
|
|
93
|
-
feed_loader=lambda _url, _timeout:
|
|
116
|
+
feed_loader=lambda _url, _timeout: {
|
|
117
|
+
"items": list(ITEMS if items is None else items),
|
|
118
|
+
"topics": TOPICS,
|
|
119
|
+
},
|
|
94
120
|
article_loader=lambda item, _timeout: {
|
|
95
121
|
**item,
|
|
96
122
|
"content_text": "## Access path\n\nThe token crossed a trust boundary.",
|
|
@@ -117,8 +143,16 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
117
143
|
code, stdout, _ = self.invoke(["topics", "--json"])
|
|
118
144
|
self.assertEqual(code, 0)
|
|
119
145
|
topics = json.loads(stdout)
|
|
120
|
-
|
|
121
|
-
self.
|
|
146
|
+
counts = {row["topic"]: row["articles"] for row in topics}
|
|
147
|
+
self.assertEqual(counts["identity"], 1)
|
|
148
|
+
self.assertEqual(counts["ai"], 1)
|
|
149
|
+
self.assertEqual(counts["endpoint"], 0)
|
|
150
|
+
|
|
151
|
+
def test_help_surfaces_examples_and_date_filter(self):
|
|
152
|
+
help_text = build_parser().format_help()
|
|
153
|
+
self.assertIn("nulltap latest --days 7", help_text)
|
|
154
|
+
self.assertIn("nulltap topics", help_text)
|
|
155
|
+
self.assertIn("limit the feed to the last N days", help_text)
|
|
122
156
|
|
|
123
157
|
def test_search_uses_title_summary_and_tags(self):
|
|
124
158
|
results = search_items(ITEMS, "production credentials")
|
|
@@ -177,13 +211,27 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
177
211
|
self.assertIn("The AI Gateway Had Root", pages[0])
|
|
178
212
|
|
|
179
213
|
def test_topics_menu_opens_a_topic_and_article(self):
|
|
180
|
-
code, stdout, _, pages = self.invoke_interactive(["topics"], "
|
|
214
|
+
code, stdout, _, pages = self.invoke_interactive(["topics"], "6\n1\nq\n")
|
|
181
215
|
self.assertEqual(code, 0)
|
|
182
216
|
self.assertIn("Topics", stdout)
|
|
183
|
-
self.assertIn("Topic:
|
|
217
|
+
self.assertIn("Topic: AI", stdout)
|
|
184
218
|
self.assertEqual(len(pages), 1)
|
|
185
219
|
self.assertIn("The AI Gateway Had Root", pages[0])
|
|
186
220
|
|
|
221
|
+
def test_known_empty_topic_is_not_reported_as_invalid(self):
|
|
222
|
+
code, stdout, stderr = self.invoke(["topic", "endpoint"])
|
|
223
|
+
self.assertEqual(code, 0)
|
|
224
|
+
self.assertIn("No published articles matched.", stdout)
|
|
225
|
+
self.assertEqual(stderr, "")
|
|
226
|
+
|
|
227
|
+
def test_days_filter_uses_publication_time(self):
|
|
228
|
+
selected = filter_days(
|
|
229
|
+
ITEMS,
|
|
230
|
+
1,
|
|
231
|
+
now=datetime(2026, 7, 26, 18, 0, tzinfo=timezone.utc),
|
|
232
|
+
)
|
|
233
|
+
self.assertEqual([item["id"] for item in selected], ["2026-07-26-cloud-token"])
|
|
234
|
+
|
|
187
235
|
def test_direct_read_uses_pager_in_a_terminal(self):
|
|
188
236
|
code, _, _, pages = self.invoke_interactive(["read", "1"], "")
|
|
189
237
|
self.assertEqual(code, 0)
|
|
@@ -246,6 +294,16 @@ class NulltapCliTests(unittest.TestCase):
|
|
|
246
294
|
self.assertEqual(len(items), 1)
|
|
247
295
|
self.assertEqual(seen, {"url": "https://nulltap.sh/feed.json", "timeout": 3})
|
|
248
296
|
|
|
297
|
+
def test_fetch_feed_document_includes_topic_catalog(self):
|
|
298
|
+
payload = json.dumps({"items": ITEMS, "topics": TOPICS}).encode()
|
|
299
|
+
document = fetch_feed_document(
|
|
300
|
+
"https://nulltap.sh/feed.json",
|
|
301
|
+
3,
|
|
302
|
+
opener=lambda _request, timeout: FakeResponse(payload),
|
|
303
|
+
)
|
|
304
|
+
self.assertEqual(document["topics"][0]["topic"], "endpoint")
|
|
305
|
+
self.assertEqual(document["topics"][5]["label"], "AI")
|
|
306
|
+
|
|
249
307
|
def test_unknown_topic_returns_helpful_error(self):
|
|
250
308
|
code, _, stderr = self.invoke(["topic", "nope"])
|
|
251
309
|
self.assertEqual(code, 1)
|
nulltap-0.1.0/PKG-INFO
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: nulltap
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Read the nulltap.sh cybersecurity feed from a terminal.
|
|
5
|
-
Author: Justin Howe
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://nulltap.sh
|
|
8
|
-
Project-URL: Repository, https://github.com/JustinHowe/nulltap
|
|
9
|
-
Keywords: cybersecurity,news,cli,nulltap
|
|
10
|
-
Classifier: Development Status :: 3 - Alpha
|
|
11
|
-
Classifier: Environment :: Console
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Topic :: Security
|
|
18
|
-
Requires-Python: >=3.10
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
License-File: LICENSE
|
|
21
|
-
Dynamic: license-file
|
|
22
|
-
|
|
23
|
-
# nulltap
|
|
24
|
-
|
|
25
|
-
Nulltap in your terminal. This client reads the public [nulltap.sh](https://nulltap.sh) JSON feed and prints cybersecurity and AI coverage without requiring an account.
|
|
26
|
-
|
|
27
|
-
## Install
|
|
28
|
-
|
|
29
|
-
Python 3.10 or newer is required. After the first PyPI release, install it with [pipx](https://pipx.pypa.io/):
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
pipx install nulltap
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Until then, install the current version from GitHub with `pipx install git+https://github.com/JustinHowe/nulltap.git`.
|
|
36
|
-
|
|
37
|
-
Run `nulltap --help` after installation.
|
|
38
|
-
|
|
39
|
-
## Commands
|
|
40
|
-
|
|
41
|
-
```sh
|
|
42
|
-
nulltap # paginated recent articles; type a number to read
|
|
43
|
-
nulltap browse # explicit form of the default command
|
|
44
|
-
nulltap latest -n 20 # browse the newest 20 articles
|
|
45
|
-
nulltap latest --topic identity
|
|
46
|
-
nulltap topics # choose a topic, then choose an article
|
|
47
|
-
nulltap topic ai # paginated articles tagged AI
|
|
48
|
-
nulltap topic # choose a topic interactively
|
|
49
|
-
nulltap search # enter search words interactively
|
|
50
|
-
nulltap search "token theft"
|
|
51
|
-
nulltap search gateway --topic appsec
|
|
52
|
-
nulltap read 1 # directly read the newest article
|
|
53
|
-
nulltap read # browse first when no number is supplied
|
|
54
|
-
nulltap --plain # print once; no prompts or pager
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Interactive lists show five articles per page so a page fits a normal terminal window. Enter the displayed number to read an article, `n` for the next page, `p` for the previous page, or `q` to leave. Use `--page-size N` to change the page size.
|
|
58
|
-
|
|
59
|
-
Articles open in the terminal pager. Headings, lists, quotations, code blocks, images, inline links, and primary sources are formatted for terminal reading. Press `q` to leave the pager and return to the article list. No browser is required.
|
|
60
|
-
|
|
61
|
-
When output is redirected, nulltap automatically disables menus and the pager. `--plain` does the same thing explicitly. Article IDs, slugs, and URLs remain accepted as direct targets for scripts, but ordinary readers should not need them.
|
|
62
|
-
|
|
63
|
-
## Search
|
|
64
|
-
|
|
65
|
-
Search belongs in the CLI because it is useful when the browser is not. It runs locally over article titles, summaries, and topic tags. Search terms are never sent to nulltap or another service.
|
|
66
|
-
|
|
67
|
-
Every word in the query must match. Title matches rank above tag and summary matches, with publication date breaking ties.
|
|
68
|
-
|
|
69
|
-
## Scripts
|
|
70
|
-
|
|
71
|
-
Add `--json` to `browse`, `latest`, `topics`, `topic`, `search`, `read`, or `show`:
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
nulltap search ransomware --json
|
|
75
|
-
nulltap topics --json
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Use a different compatible feed during development:
|
|
79
|
-
|
|
80
|
-
```sh
|
|
81
|
-
nulltap --feed http://127.0.0.1:4321/feed.json
|
|
82
|
-
# or
|
|
83
|
-
NULLTAP_FEED_URL=http://127.0.0.1:4321/feed.json nulltap
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Listing and search commands make one read-only feed request. Reading an article makes a second request for that article's text. The client has no analytics, login, local database, or background process. Feed and article text are stripped of terminal control sequences before display.
|
|
87
|
-
|
|
88
|
-
## Development
|
|
89
|
-
|
|
90
|
-
```sh
|
|
91
|
-
python -m venv .venv
|
|
92
|
-
# Windows: .venv\Scripts\activate
|
|
93
|
-
# macOS/Linux: source .venv/bin/activate
|
|
94
|
-
python -m pip install -e .
|
|
95
|
-
python -m unittest discover -s tests -v
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Licensed under the MIT License.
|
nulltap-0.1.0/README.md
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
# nulltap
|
|
2
|
-
|
|
3
|
-
Nulltap in your terminal. This client reads the public [nulltap.sh](https://nulltap.sh) JSON feed and prints cybersecurity and AI coverage without requiring an account.
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
Python 3.10 or newer is required. After the first PyPI release, install it with [pipx](https://pipx.pypa.io/):
|
|
8
|
-
|
|
9
|
-
```sh
|
|
10
|
-
pipx install nulltap
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Until then, install the current version from GitHub with `pipx install git+https://github.com/JustinHowe/nulltap.git`.
|
|
14
|
-
|
|
15
|
-
Run `nulltap --help` after installation.
|
|
16
|
-
|
|
17
|
-
## Commands
|
|
18
|
-
|
|
19
|
-
```sh
|
|
20
|
-
nulltap # paginated recent articles; type a number to read
|
|
21
|
-
nulltap browse # explicit form of the default command
|
|
22
|
-
nulltap latest -n 20 # browse the newest 20 articles
|
|
23
|
-
nulltap latest --topic identity
|
|
24
|
-
nulltap topics # choose a topic, then choose an article
|
|
25
|
-
nulltap topic ai # paginated articles tagged AI
|
|
26
|
-
nulltap topic # choose a topic interactively
|
|
27
|
-
nulltap search # enter search words interactively
|
|
28
|
-
nulltap search "token theft"
|
|
29
|
-
nulltap search gateway --topic appsec
|
|
30
|
-
nulltap read 1 # directly read the newest article
|
|
31
|
-
nulltap read # browse first when no number is supplied
|
|
32
|
-
nulltap --plain # print once; no prompts or pager
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Interactive lists show five articles per page so a page fits a normal terminal window. Enter the displayed number to read an article, `n` for the next page, `p` for the previous page, or `q` to leave. Use `--page-size N` to change the page size.
|
|
36
|
-
|
|
37
|
-
Articles open in the terminal pager. Headings, lists, quotations, code blocks, images, inline links, and primary sources are formatted for terminal reading. Press `q` to leave the pager and return to the article list. No browser is required.
|
|
38
|
-
|
|
39
|
-
When output is redirected, nulltap automatically disables menus and the pager. `--plain` does the same thing explicitly. Article IDs, slugs, and URLs remain accepted as direct targets for scripts, but ordinary readers should not need them.
|
|
40
|
-
|
|
41
|
-
## Search
|
|
42
|
-
|
|
43
|
-
Search belongs in the CLI because it is useful when the browser is not. It runs locally over article titles, summaries, and topic tags. Search terms are never sent to nulltap or another service.
|
|
44
|
-
|
|
45
|
-
Every word in the query must match. Title matches rank above tag and summary matches, with publication date breaking ties.
|
|
46
|
-
|
|
47
|
-
## Scripts
|
|
48
|
-
|
|
49
|
-
Add `--json` to `browse`, `latest`, `topics`, `topic`, `search`, `read`, or `show`:
|
|
50
|
-
|
|
51
|
-
```sh
|
|
52
|
-
nulltap search ransomware --json
|
|
53
|
-
nulltap topics --json
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
Use a different compatible feed during development:
|
|
57
|
-
|
|
58
|
-
```sh
|
|
59
|
-
nulltap --feed http://127.0.0.1:4321/feed.json
|
|
60
|
-
# or
|
|
61
|
-
NULLTAP_FEED_URL=http://127.0.0.1:4321/feed.json nulltap
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
Listing and search commands make one read-only feed request. Reading an article makes a second request for that article's text. The client has no analytics, login, local database, or background process. Feed and article text are stripped of terminal control sequences before display.
|
|
65
|
-
|
|
66
|
-
## Development
|
|
67
|
-
|
|
68
|
-
```sh
|
|
69
|
-
python -m venv .venv
|
|
70
|
-
# Windows: .venv\Scripts\activate
|
|
71
|
-
# macOS/Linux: source .venv/bin/activate
|
|
72
|
-
python -m pip install -e .
|
|
73
|
-
python -m unittest discover -s tests -v
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
Licensed under the MIT License.
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: nulltap
|
|
3
|
-
Version: 0.1.0
|
|
4
|
-
Summary: Read the nulltap.sh cybersecurity feed from a terminal.
|
|
5
|
-
Author: Justin Howe
|
|
6
|
-
License-Expression: MIT
|
|
7
|
-
Project-URL: Homepage, https://nulltap.sh
|
|
8
|
-
Project-URL: Repository, https://github.com/JustinHowe/nulltap
|
|
9
|
-
Keywords: cybersecurity,news,cli,nulltap
|
|
10
|
-
Classifier: Development Status :: 3 - Alpha
|
|
11
|
-
Classifier: Environment :: Console
|
|
12
|
-
Classifier: Programming Language :: Python :: 3
|
|
13
|
-
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
-
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
-
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
-
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
-
Classifier: Topic :: Security
|
|
18
|
-
Requires-Python: >=3.10
|
|
19
|
-
Description-Content-Type: text/markdown
|
|
20
|
-
License-File: LICENSE
|
|
21
|
-
Dynamic: license-file
|
|
22
|
-
|
|
23
|
-
# nulltap
|
|
24
|
-
|
|
25
|
-
Nulltap in your terminal. This client reads the public [nulltap.sh](https://nulltap.sh) JSON feed and prints cybersecurity and AI coverage without requiring an account.
|
|
26
|
-
|
|
27
|
-
## Install
|
|
28
|
-
|
|
29
|
-
Python 3.10 or newer is required. After the first PyPI release, install it with [pipx](https://pipx.pypa.io/):
|
|
30
|
-
|
|
31
|
-
```sh
|
|
32
|
-
pipx install nulltap
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
Until then, install the current version from GitHub with `pipx install git+https://github.com/JustinHowe/nulltap.git`.
|
|
36
|
-
|
|
37
|
-
Run `nulltap --help` after installation.
|
|
38
|
-
|
|
39
|
-
## Commands
|
|
40
|
-
|
|
41
|
-
```sh
|
|
42
|
-
nulltap # paginated recent articles; type a number to read
|
|
43
|
-
nulltap browse # explicit form of the default command
|
|
44
|
-
nulltap latest -n 20 # browse the newest 20 articles
|
|
45
|
-
nulltap latest --topic identity
|
|
46
|
-
nulltap topics # choose a topic, then choose an article
|
|
47
|
-
nulltap topic ai # paginated articles tagged AI
|
|
48
|
-
nulltap topic # choose a topic interactively
|
|
49
|
-
nulltap search # enter search words interactively
|
|
50
|
-
nulltap search "token theft"
|
|
51
|
-
nulltap search gateway --topic appsec
|
|
52
|
-
nulltap read 1 # directly read the newest article
|
|
53
|
-
nulltap read # browse first when no number is supplied
|
|
54
|
-
nulltap --plain # print once; no prompts or pager
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
Interactive lists show five articles per page so a page fits a normal terminal window. Enter the displayed number to read an article, `n` for the next page, `p` for the previous page, or `q` to leave. Use `--page-size N` to change the page size.
|
|
58
|
-
|
|
59
|
-
Articles open in the terminal pager. Headings, lists, quotations, code blocks, images, inline links, and primary sources are formatted for terminal reading. Press `q` to leave the pager and return to the article list. No browser is required.
|
|
60
|
-
|
|
61
|
-
When output is redirected, nulltap automatically disables menus and the pager. `--plain` does the same thing explicitly. Article IDs, slugs, and URLs remain accepted as direct targets for scripts, but ordinary readers should not need them.
|
|
62
|
-
|
|
63
|
-
## Search
|
|
64
|
-
|
|
65
|
-
Search belongs in the CLI because it is useful when the browser is not. It runs locally over article titles, summaries, and topic tags. Search terms are never sent to nulltap or another service.
|
|
66
|
-
|
|
67
|
-
Every word in the query must match. Title matches rank above tag and summary matches, with publication date breaking ties.
|
|
68
|
-
|
|
69
|
-
## Scripts
|
|
70
|
-
|
|
71
|
-
Add `--json` to `browse`, `latest`, `topics`, `topic`, `search`, `read`, or `show`:
|
|
72
|
-
|
|
73
|
-
```sh
|
|
74
|
-
nulltap search ransomware --json
|
|
75
|
-
nulltap topics --json
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
Use a different compatible feed during development:
|
|
79
|
-
|
|
80
|
-
```sh
|
|
81
|
-
nulltap --feed http://127.0.0.1:4321/feed.json
|
|
82
|
-
# or
|
|
83
|
-
NULLTAP_FEED_URL=http://127.0.0.1:4321/feed.json nulltap
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
Listing and search commands make one read-only feed request. Reading an article makes a second request for that article's text. The client has no analytics, login, local database, or background process. Feed and article text are stripped of terminal control sequences before display.
|
|
87
|
-
|
|
88
|
-
## Development
|
|
89
|
-
|
|
90
|
-
```sh
|
|
91
|
-
python -m venv .venv
|
|
92
|
-
# Windows: .venv\Scripts\activate
|
|
93
|
-
# macOS/Linux: source .venv/bin/activate
|
|
94
|
-
python -m pip install -e .
|
|
95
|
-
python -m unittest discover -s tests -v
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
Licensed under the MIT License.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|