nulltap 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.
- nulltap/__init__.py +3 -0
- nulltap/__main__.py +5 -0
- nulltap/cli.py +784 -0
- nulltap-0.1.0.dist-info/METADATA +98 -0
- nulltap-0.1.0.dist-info/RECORD +9 -0
- nulltap-0.1.0.dist-info/WHEEL +5 -0
- nulltap-0.1.0.dist-info/entry_points.txt +2 -0
- nulltap-0.1.0.dist-info/licenses/LICENSE +21 -0
- nulltap-0.1.0.dist-info/top_level.txt +1 -0
nulltap/__init__.py
ADDED
nulltap/__main__.py
ADDED
nulltap/cli.py
ADDED
|
@@ -0,0 +1,784 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import pydoc
|
|
7
|
+
import re
|
|
8
|
+
import shutil
|
|
9
|
+
import sys
|
|
10
|
+
import textwrap
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import urllib.request
|
|
14
|
+
import webbrowser
|
|
15
|
+
from collections import Counter
|
|
16
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
17
|
+
from typing import Any, TextIO
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
DEFAULT_FEED_URL = "https://nulltap.sh/feed.json"
|
|
23
|
+
MAX_FEED_BYTES = 5 * 1024 * 1024
|
|
24
|
+
MAX_ARTICLE_BYTES = 2 * 1024 * 1024
|
|
25
|
+
ANSI_ESCAPE = re.compile(r"\x1b(?:[@-Z\-_]|\[[0-?]*[ -/]*[@-~])")
|
|
26
|
+
CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]")
|
|
27
|
+
MARKDOWN_IMAGE = re.compile(r"!\[([^]]*)]\([^)]+\)")
|
|
28
|
+
MARKDOWN_LINK = re.compile(r"(?<!!)\[([^]]+)]\((https?://[^\s)]+)\)")
|
|
29
|
+
HEADING = re.compile(r"^(#{1,6})\s+(.+?)\s*#*$")
|
|
30
|
+
BULLET = re.compile(r"^\s*[-*+]\s+(.+)$")
|
|
31
|
+
NUMBERED = re.compile(r"^\s*(\d+)[.)]\s+(.+)$")
|
|
32
|
+
RULE = re.compile(r"^\s*(?:[-*_]\s*){3,}$")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class FeedError(RuntimeError):
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def safe_text(value: Any) -> str:
|
|
40
|
+
text = ANSI_ESCAPE.sub("", str(value or ""))
|
|
41
|
+
text = CONTROL_CHARS.sub("", text)
|
|
42
|
+
return " ".join(text.split())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def safe_http_url(value: Any) -> str:
|
|
46
|
+
url = str(value or "").strip()
|
|
47
|
+
parsed = urllib.parse.urlparse(url)
|
|
48
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
49
|
+
return ""
|
|
50
|
+
if parsed.username or parsed.password:
|
|
51
|
+
return ""
|
|
52
|
+
return url
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def safe_article_text(value: Any) -> str:
|
|
56
|
+
text = ANSI_ESCAPE.sub("", str(value or ""))
|
|
57
|
+
text = CONTROL_CHARS.sub("", text.replace("\r\n", "\n").replace("\r", "\n"))
|
|
58
|
+
text = MARKDOWN_IMAGE.sub(
|
|
59
|
+
lambda match: f"[Image: {safe_text(match.group(1))}]" if match.group(1) else "[Image]",
|
|
60
|
+
text,
|
|
61
|
+
)
|
|
62
|
+
return re.sub(r"\n{4,}", "\n\n\n", text).strip()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def same_origin(first: str, second: str) -> bool:
|
|
66
|
+
left = urllib.parse.urlparse(first)
|
|
67
|
+
right = urllib.parse.urlparse(second)
|
|
68
|
+
return (left.scheme.lower(), left.hostname, left.port) == (
|
|
69
|
+
right.scheme.lower(),
|
|
70
|
+
right.hostname,
|
|
71
|
+
right.port,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def normalize_item(raw: Any) -> dict[str, Any] | None:
|
|
76
|
+
if not isinstance(raw, dict):
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
title = safe_text(raw.get("title"))
|
|
80
|
+
url = safe_http_url(raw.get("url"))
|
|
81
|
+
if not title or not url:
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
raw_tags = raw.get("tags", [])
|
|
85
|
+
if not isinstance(raw_tags, list):
|
|
86
|
+
raw_tags = []
|
|
87
|
+
tags = list(dict.fromkeys(safe_text(tag).lower() for tag in raw_tags if safe_text(tag)))
|
|
88
|
+
|
|
89
|
+
try:
|
|
90
|
+
read_time = max(0, int(raw.get("read_time_minutes", 0)))
|
|
91
|
+
except (TypeError, ValueError):
|
|
92
|
+
read_time = 0
|
|
93
|
+
|
|
94
|
+
content_url = safe_http_url(raw.get("content_url"))
|
|
95
|
+
if content_url and not same_origin(url, content_url):
|
|
96
|
+
content_url = ""
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"id": safe_text(raw.get("id")) or url,
|
|
100
|
+
"url": url,
|
|
101
|
+
"content_url": content_url,
|
|
102
|
+
"title": title,
|
|
103
|
+
"summary": safe_text(raw.get("summary")),
|
|
104
|
+
"date_published": safe_text(raw.get("date_published")),
|
|
105
|
+
"tags": tags,
|
|
106
|
+
"read_time_minutes": read_time,
|
|
107
|
+
"author": safe_text(raw.get("author")),
|
|
108
|
+
"image": safe_http_url(raw.get("image")),
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _fetch_json(
|
|
113
|
+
url: str,
|
|
114
|
+
*,
|
|
115
|
+
timeout: float,
|
|
116
|
+
maximum_bytes: int,
|
|
117
|
+
label: str,
|
|
118
|
+
accept: str,
|
|
119
|
+
opener: Callable[..., Any],
|
|
120
|
+
) -> Any:
|
|
121
|
+
if not safe_http_url(url):
|
|
122
|
+
raise FeedError(f"{label} URL must be an http or https URL without embedded credentials")
|
|
123
|
+
|
|
124
|
+
request = urllib.request.Request(
|
|
125
|
+
url,
|
|
126
|
+
headers={
|
|
127
|
+
"Accept": accept,
|
|
128
|
+
"User-Agent": f"nulltap/{__version__} (+https://nulltap.sh)",
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
with opener(request, timeout=timeout) as response:
|
|
134
|
+
content_length = response.headers.get("Content-Length")
|
|
135
|
+
if content_length and int(content_length) > maximum_bytes:
|
|
136
|
+
raise FeedError(f"{label} is larger than {maximum_bytes // (1024 * 1024)} MiB")
|
|
137
|
+
body = response.read(maximum_bytes + 1)
|
|
138
|
+
except FeedError:
|
|
139
|
+
raise
|
|
140
|
+
except (OSError, ValueError, urllib.error.URLError) as exc:
|
|
141
|
+
reason = getattr(exc, "reason", exc)
|
|
142
|
+
raise FeedError(f"could not fetch {label}: {safe_text(reason)}") from exc
|
|
143
|
+
|
|
144
|
+
if len(body) > maximum_bytes:
|
|
145
|
+
raise FeedError(f"{label} is larger than {maximum_bytes // (1024 * 1024)} MiB")
|
|
146
|
+
|
|
147
|
+
try:
|
|
148
|
+
return json.loads(body.decode("utf-8"))
|
|
149
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
150
|
+
raise FeedError(f"{label} returned invalid JSON") from exc
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def fetch_feed(
|
|
154
|
+
url: str = DEFAULT_FEED_URL,
|
|
155
|
+
timeout: float = 10.0,
|
|
156
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
157
|
+
) -> list[dict[str, Any]]:
|
|
158
|
+
payload = _fetch_json(
|
|
159
|
+
url,
|
|
160
|
+
timeout=timeout,
|
|
161
|
+
maximum_bytes=MAX_FEED_BYTES,
|
|
162
|
+
label="feed",
|
|
163
|
+
accept="application/feed+json, application/json",
|
|
164
|
+
opener=opener,
|
|
165
|
+
)
|
|
166
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("items"), list):
|
|
167
|
+
raise FeedError("feed does not contain an items array")
|
|
168
|
+
|
|
169
|
+
items = [item for raw in payload["items"] if (item := normalize_item(raw))]
|
|
170
|
+
return sorted(items, key=lambda item: item["date_published"], reverse=True)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def fetch_article(
|
|
174
|
+
item: dict[str, Any],
|
|
175
|
+
timeout: float = 10.0,
|
|
176
|
+
opener: Callable[..., Any] = urllib.request.urlopen,
|
|
177
|
+
) -> dict[str, Any]:
|
|
178
|
+
content_url = item.get("content_url", "")
|
|
179
|
+
if not content_url:
|
|
180
|
+
raise FeedError("this article is not available for terminal reading")
|
|
181
|
+
|
|
182
|
+
payload = _fetch_json(
|
|
183
|
+
content_url,
|
|
184
|
+
timeout=timeout,
|
|
185
|
+
maximum_bytes=MAX_ARTICLE_BYTES,
|
|
186
|
+
label="article",
|
|
187
|
+
accept="application/json",
|
|
188
|
+
opener=opener,
|
|
189
|
+
)
|
|
190
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("content_text"), str):
|
|
191
|
+
raise FeedError("article does not contain terminal-readable text")
|
|
192
|
+
|
|
193
|
+
raw_sources = payload.get("sources", [])
|
|
194
|
+
if not isinstance(raw_sources, list):
|
|
195
|
+
raw_sources = []
|
|
196
|
+
sources = []
|
|
197
|
+
for raw_source in raw_sources:
|
|
198
|
+
if not isinstance(raw_source, dict):
|
|
199
|
+
continue
|
|
200
|
+
label = safe_text(raw_source.get("label"))
|
|
201
|
+
url = safe_http_url(raw_source.get("url"))
|
|
202
|
+
if label and url:
|
|
203
|
+
sources.append({"label": label, "url": url})
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
**item,
|
|
207
|
+
"content_text": safe_article_text(payload["content_text"]),
|
|
208
|
+
"sources": sources,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def positive_int(value: str) -> int:
|
|
213
|
+
try:
|
|
214
|
+
parsed = int(value)
|
|
215
|
+
except ValueError as exc:
|
|
216
|
+
raise argparse.ArgumentTypeError("must be an integer") from exc
|
|
217
|
+
if parsed < 1 or parsed > 500:
|
|
218
|
+
raise argparse.ArgumentTypeError("must be between 1 and 500")
|
|
219
|
+
return parsed
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
223
|
+
common = argparse.ArgumentParser(add_help=False, argument_default=argparse.SUPPRESS)
|
|
224
|
+
common.add_argument(
|
|
225
|
+
"--feed",
|
|
226
|
+
metavar="URL",
|
|
227
|
+
help="JSON feed URL (default: NULLTAP_FEED_URL or nulltap.sh/feed.json)",
|
|
228
|
+
)
|
|
229
|
+
common.add_argument("--timeout", type=float, metavar="SECONDS", help="network timeout (default: 10)")
|
|
230
|
+
common.add_argument("--json", action="store_true", help="emit JSON for scripts")
|
|
231
|
+
common.add_argument("--plain", action="store_true", help="print once without menus or a pager")
|
|
232
|
+
common.add_argument("--page-size", type=positive_int, metavar="N", help="articles per page (default: 5)")
|
|
233
|
+
common.add_argument("--no-color", action="store_true", help="disable terminal color")
|
|
234
|
+
|
|
235
|
+
parser = argparse.ArgumentParser(
|
|
236
|
+
prog="nulltap",
|
|
237
|
+
parents=[common],
|
|
238
|
+
description="Browse and read nulltap from a terminal.",
|
|
239
|
+
)
|
|
240
|
+
parser.add_argument("--version", action="version", version=f"nulltap {__version__}")
|
|
241
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
242
|
+
|
|
243
|
+
browse = subparsers.add_parser("browse", parents=[common], help="browse recent articles")
|
|
244
|
+
browse.add_argument("-n", "--limit", type=positive_int, default=50)
|
|
245
|
+
browse.add_argument("-t", "--topic", help="limit results to one topic")
|
|
246
|
+
|
|
247
|
+
latest = subparsers.add_parser("latest", parents=[common], help="browse recent articles")
|
|
248
|
+
latest.add_argument("-n", "--limit", type=positive_int, default=50)
|
|
249
|
+
latest.add_argument("-t", "--topic", help="limit results to one topic")
|
|
250
|
+
|
|
251
|
+
subparsers.add_parser("topics", parents=[common], help="choose a topic")
|
|
252
|
+
|
|
253
|
+
topic = subparsers.add_parser("topic", parents=[common], help="browse articles for one topic")
|
|
254
|
+
topic.add_argument("name", nargs="?", help="topic name, such as identity or cloud")
|
|
255
|
+
topic.add_argument("-n", "--limit", type=positive_int, default=100)
|
|
256
|
+
|
|
257
|
+
search = subparsers.add_parser("search", parents=[common], help="search and choose an article")
|
|
258
|
+
search.add_argument("query", nargs="*", help="words to search for")
|
|
259
|
+
search.add_argument("-t", "--topic", help="limit results to one topic")
|
|
260
|
+
search.add_argument("-n", "--limit", type=positive_int, default=100)
|
|
261
|
+
|
|
262
|
+
read = subparsers.add_parser("read", parents=[common], help="read an article or browse when omitted")
|
|
263
|
+
read.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
|
|
264
|
+
|
|
265
|
+
show = subparsers.add_parser("show", parents=[common], help="alias for read")
|
|
266
|
+
show.add_argument("target", nargs="?", help="result number, article ID, slug, or URL")
|
|
267
|
+
|
|
268
|
+
open_command = subparsers.add_parser("open", parents=[common], help="open one article in a browser")
|
|
269
|
+
open_command.add_argument("target", help="result number, article ID, slug, or URL")
|
|
270
|
+
|
|
271
|
+
return parser
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def filter_topic(items: Iterable[dict[str, Any]], topic: str | None) -> list[dict[str, Any]]:
|
|
275
|
+
if not topic:
|
|
276
|
+
return list(items)
|
|
277
|
+
wanted = safe_text(topic).lower()
|
|
278
|
+
return [item for item in items if wanted in item["tags"]]
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def search_items(items: Iterable[dict[str, Any]], query: str) -> list[dict[str, Any]]:
|
|
282
|
+
phrase = safe_text(query).lower()
|
|
283
|
+
tokens = [token for token in phrase.split() if token]
|
|
284
|
+
if not tokens:
|
|
285
|
+
return []
|
|
286
|
+
|
|
287
|
+
ranked: list[tuple[int, str, dict[str, Any]]] = []
|
|
288
|
+
for item in items:
|
|
289
|
+
title = item["title"].lower()
|
|
290
|
+
summary = item["summary"].lower()
|
|
291
|
+
tags = " ".join(item["tags"]).lower()
|
|
292
|
+
haystack = f"{title} {summary} {tags}"
|
|
293
|
+
if not all(token in haystack for token in tokens):
|
|
294
|
+
continue
|
|
295
|
+
|
|
296
|
+
score = 100 if phrase in title else 0
|
|
297
|
+
score += sum(12 for token in tokens if token in title)
|
|
298
|
+
score += sum(7 for token in tokens if token in tags)
|
|
299
|
+
score += sum(3 for token in tokens if token in summary)
|
|
300
|
+
ranked.append((score, item["date_published"], item))
|
|
301
|
+
|
|
302
|
+
ranked.sort(key=lambda entry: (entry[0], entry[1]), reverse=True)
|
|
303
|
+
return [item for _, _, item in ranked]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def resolve_item(items: Sequence[dict[str, Any]], target: str) -> dict[str, Any] | None:
|
|
307
|
+
clean_target = safe_text(target)
|
|
308
|
+
if clean_target.isdigit():
|
|
309
|
+
index = int(clean_target) - 1
|
|
310
|
+
return items[index] if 0 <= index < len(items) else None
|
|
311
|
+
|
|
312
|
+
lowered = clean_target.lower().rstrip("/")
|
|
313
|
+
for item in items:
|
|
314
|
+
url = item["url"].lower().rstrip("/")
|
|
315
|
+
slug = urllib.parse.urlparse(url).path.rstrip("/").split("/")[-1]
|
|
316
|
+
if lowered in {item["id"].lower(), url, slug}:
|
|
317
|
+
return item
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def color_enabled(stream: TextIO, disabled: bool) -> bool:
|
|
322
|
+
return not disabled and "NO_COLOR" not in os.environ and bool(getattr(stream, "isatty", lambda: False)())
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def interactive_enabled(stdin: TextIO, stdout: TextIO, plain: bool, json_output: bool) -> bool:
|
|
326
|
+
return (
|
|
327
|
+
not plain
|
|
328
|
+
and not json_output
|
|
329
|
+
and bool(getattr(stdin, "isatty", lambda: False)())
|
|
330
|
+
and bool(getattr(stdout, "isatty", lambda: False)())
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def paint(text: str, code: str, enabled: bool) -> str:
|
|
335
|
+
return f"\033[{code}m{text}\033[0m" if enabled else text
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def display_date(value: str) -> str:
|
|
339
|
+
return value[:10] if len(value) >= 10 else value or "unknown date"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def terminal_width() -> int:
|
|
343
|
+
return max(60, min(110, shutil.get_terminal_size((92, 24)).columns))
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _clean_inline(text: str, links: list[str]) -> str:
|
|
347
|
+
def replace_link(match: re.Match[str]) -> str:
|
|
348
|
+
url = safe_http_url(match.group(2))
|
|
349
|
+
if not url:
|
|
350
|
+
return safe_text(match.group(1))
|
|
351
|
+
if url not in links:
|
|
352
|
+
links.append(url)
|
|
353
|
+
return f"{safe_text(match.group(1))} [{links.index(url) + 1}]"
|
|
354
|
+
|
|
355
|
+
text = MARKDOWN_LINK.sub(replace_link, text)
|
|
356
|
+
text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text)
|
|
357
|
+
text = re.sub(r"(?<!\*)\*([^*]+)\*", r"\1", text)
|
|
358
|
+
text = re.sub(r"__([^_]+)__", r"\1", text)
|
|
359
|
+
text = re.sub(r"(?<!_)_([^_]+)_", r"\1", text)
|
|
360
|
+
text = re.sub(r"`([^`]+)`", r"\1", text)
|
|
361
|
+
return safe_text(text)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def render_markdown(markdown: str, width: int, use_color: bool) -> tuple[str, list[str]]:
|
|
365
|
+
output: list[str] = []
|
|
366
|
+
links: list[str] = []
|
|
367
|
+
paragraph: list[str] = []
|
|
368
|
+
in_code = False
|
|
369
|
+
|
|
370
|
+
def blank() -> None:
|
|
371
|
+
if output and output[-1] != "":
|
|
372
|
+
output.append("")
|
|
373
|
+
|
|
374
|
+
def flush_paragraph() -> None:
|
|
375
|
+
if not paragraph:
|
|
376
|
+
return
|
|
377
|
+
text = _clean_inline(" ".join(paragraph), links)
|
|
378
|
+
output.extend(textwrap.wrap(text, width=width) or [""])
|
|
379
|
+
paragraph.clear()
|
|
380
|
+
|
|
381
|
+
for raw_line in markdown.splitlines():
|
|
382
|
+
line = raw_line.rstrip()
|
|
383
|
+
if line.lstrip().startswith("```"):
|
|
384
|
+
flush_paragraph()
|
|
385
|
+
in_code = not in_code
|
|
386
|
+
blank()
|
|
387
|
+
continue
|
|
388
|
+
if in_code:
|
|
389
|
+
output.append(paint(f" {line}", "2", use_color))
|
|
390
|
+
continue
|
|
391
|
+
if not line.strip():
|
|
392
|
+
flush_paragraph()
|
|
393
|
+
blank()
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
heading = HEADING.match(line)
|
|
397
|
+
bullet = BULLET.match(line)
|
|
398
|
+
numbered = NUMBERED.match(line)
|
|
399
|
+
if heading:
|
|
400
|
+
flush_paragraph()
|
|
401
|
+
blank()
|
|
402
|
+
title = _clean_inline(heading.group(2), links)
|
|
403
|
+
output.extend(paint(part, "1;38;5;214", use_color) for part in textwrap.wrap(title, width=width))
|
|
404
|
+
continue
|
|
405
|
+
if RULE.match(line):
|
|
406
|
+
flush_paragraph()
|
|
407
|
+
blank()
|
|
408
|
+
output.append(paint("─" * min(width, 72), "2", use_color))
|
|
409
|
+
continue
|
|
410
|
+
if bullet:
|
|
411
|
+
flush_paragraph()
|
|
412
|
+
text = _clean_inline(bullet.group(1), links)
|
|
413
|
+
output.append(textwrap.fill(text, width=width, initial_indent="• ", subsequent_indent=" "))
|
|
414
|
+
continue
|
|
415
|
+
if numbered:
|
|
416
|
+
flush_paragraph()
|
|
417
|
+
prefix = f"{numbered.group(1)}. "
|
|
418
|
+
text = _clean_inline(numbered.group(2), links)
|
|
419
|
+
output.append(
|
|
420
|
+
textwrap.fill(text, width=width, initial_indent=prefix, subsequent_indent=" " * len(prefix))
|
|
421
|
+
)
|
|
422
|
+
continue
|
|
423
|
+
if line.lstrip().startswith(">"):
|
|
424
|
+
flush_paragraph()
|
|
425
|
+
text = _clean_inline(line.lstrip()[1:].lstrip(), links)
|
|
426
|
+
output.append(textwrap.fill(text, width=width, initial_indent="│ ", subsequent_indent="│ "))
|
|
427
|
+
continue
|
|
428
|
+
if line.startswith("|") and line.endswith("|"):
|
|
429
|
+
flush_paragraph()
|
|
430
|
+
if all(re.fullmatch(r":?-{3,}:?", cell.strip()) for cell in line.strip("|").split("|")):
|
|
431
|
+
continue
|
|
432
|
+
output.append(_clean_inline(line, links))
|
|
433
|
+
continue
|
|
434
|
+
paragraph.append(line.strip())
|
|
435
|
+
|
|
436
|
+
flush_paragraph()
|
|
437
|
+
while output and output[-1] == "":
|
|
438
|
+
output.pop()
|
|
439
|
+
return "\n".join(output), links
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def render_article(item: dict[str, Any], use_color: bool, width: int | None = None) -> str:
|
|
443
|
+
width = width or terminal_width()
|
|
444
|
+
lines: list[str] = []
|
|
445
|
+
title_lines = textwrap.wrap(item["title"], width=width) or [item["title"]]
|
|
446
|
+
lines.extend(paint(line, "1", use_color) for line in title_lines)
|
|
447
|
+
|
|
448
|
+
metadata = [display_date(item["date_published"]), *item["tags"]]
|
|
449
|
+
if item["read_time_minutes"]:
|
|
450
|
+
metadata.append(f"{item['read_time_minutes']} min")
|
|
451
|
+
if item["author"]:
|
|
452
|
+
metadata.append(item["author"])
|
|
453
|
+
lines.append(paint(" · ".join(metadata), "2", use_color))
|
|
454
|
+
|
|
455
|
+
if item["summary"]:
|
|
456
|
+
lines.append("")
|
|
457
|
+
lines.append(textwrap.fill(item["summary"], width=width))
|
|
458
|
+
|
|
459
|
+
body, links = render_markdown(item.get("content_text", ""), width, use_color)
|
|
460
|
+
if body:
|
|
461
|
+
lines.extend(["", body])
|
|
462
|
+
|
|
463
|
+
if links:
|
|
464
|
+
lines.extend(["", paint("Links", "1;38;5;214", use_color)])
|
|
465
|
+
for index, url in enumerate(links, 1):
|
|
466
|
+
lines.append(textwrap.fill(f"[{index}] {url}", width=width, subsequent_indent=" "))
|
|
467
|
+
|
|
468
|
+
sources = item.get("sources", [])
|
|
469
|
+
if sources:
|
|
470
|
+
lines.extend(["", paint("Primary sources", "1;38;5;214", use_color)])
|
|
471
|
+
for source in sources:
|
|
472
|
+
entry = f"• {source['label']}: {source['url']}"
|
|
473
|
+
lines.append(textwrap.fill(entry, width=width, subsequent_indent=" "))
|
|
474
|
+
|
|
475
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def print_items(
|
|
479
|
+
items: Sequence[dict[str, Any]],
|
|
480
|
+
stream: TextIO,
|
|
481
|
+
use_color: bool,
|
|
482
|
+
*,
|
|
483
|
+
start_index: int = 1,
|
|
484
|
+
width: int | None = None,
|
|
485
|
+
) -> None:
|
|
486
|
+
if not items:
|
|
487
|
+
print("No published articles matched.", file=stream)
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
width = width or terminal_width()
|
|
491
|
+
number_width = len(str(start_index + len(items) - 1)) + 2
|
|
492
|
+
for offset, item in enumerate(items):
|
|
493
|
+
index = start_index + offset
|
|
494
|
+
number = paint(f"[{index}]", "2", use_color)
|
|
495
|
+
title = paint(item["title"], "1", use_color)
|
|
496
|
+
print(f"{number:<{number_width}} {title}", file=stream)
|
|
497
|
+
metadata = [display_date(item["date_published"]), *item["tags"]]
|
|
498
|
+
if item["read_time_minutes"]:
|
|
499
|
+
metadata.append(f"{item['read_time_minutes']} min")
|
|
500
|
+
print(f"{' ' * (number_width + 1)}{' · '.join(metadata)}", file=stream)
|
|
501
|
+
if item["summary"]:
|
|
502
|
+
print(
|
|
503
|
+
textwrap.fill(
|
|
504
|
+
item["summary"],
|
|
505
|
+
width=width,
|
|
506
|
+
initial_indent=" " * (number_width + 1),
|
|
507
|
+
subsequent_indent=" " * (number_width + 1),
|
|
508
|
+
),
|
|
509
|
+
file=stream,
|
|
510
|
+
)
|
|
511
|
+
if offset != len(items) - 1:
|
|
512
|
+
print(file=stream)
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def read_prompt(prompt: str, stdin: TextIO, stdout: TextIO) -> str:
|
|
516
|
+
print(prompt, end="", file=stdout, flush=True)
|
|
517
|
+
value = stdin.readline()
|
|
518
|
+
if value == "":
|
|
519
|
+
return "q"
|
|
520
|
+
return safe_text(value)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def show_article(
|
|
524
|
+
item: dict[str, Any],
|
|
525
|
+
*,
|
|
526
|
+
article_loader: Callable[[dict[str, Any], float], dict[str, Any]],
|
|
527
|
+
timeout: float,
|
|
528
|
+
stdout: TextIO,
|
|
529
|
+
use_color: bool,
|
|
530
|
+
use_pager: bool,
|
|
531
|
+
pager: Callable[[str], None],
|
|
532
|
+
) -> None:
|
|
533
|
+
article = article_loader(item, timeout)
|
|
534
|
+
rendered = render_article(article, use_color)
|
|
535
|
+
if use_pager:
|
|
536
|
+
pager(rendered)
|
|
537
|
+
else:
|
|
538
|
+
print(rendered, end="", file=stdout)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def browse_items(
|
|
542
|
+
items: Sequence[dict[str, Any]],
|
|
543
|
+
*,
|
|
544
|
+
heading: str,
|
|
545
|
+
page_size: int,
|
|
546
|
+
stdin: TextIO,
|
|
547
|
+
stdout: TextIO,
|
|
548
|
+
use_color: bool,
|
|
549
|
+
article_loader: Callable[[dict[str, Any], float], dict[str, Any]],
|
|
550
|
+
timeout: float,
|
|
551
|
+
pager: Callable[[str], None],
|
|
552
|
+
) -> int:
|
|
553
|
+
if not items:
|
|
554
|
+
print("No published articles matched.", file=stdout)
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
page = 0
|
|
558
|
+
page_count = (len(items) + page_size - 1) // page_size
|
|
559
|
+
while True:
|
|
560
|
+
start = page * page_size
|
|
561
|
+
end = min(start + page_size, len(items))
|
|
562
|
+
print(file=stdout)
|
|
563
|
+
print(paint(heading, "1;38;5;214", use_color), file=stdout)
|
|
564
|
+
print(paint(f"Articles {start + 1}–{end} of {len(items)} · page {page + 1}/{page_count}", "2", use_color), file=stdout)
|
|
565
|
+
print(file=stdout)
|
|
566
|
+
print_items(items[start:end], stdout, use_color, start_index=start + 1)
|
|
567
|
+
print(file=stdout)
|
|
568
|
+
|
|
569
|
+
commands = [f"{start + 1}-{end} read"]
|
|
570
|
+
if page < page_count - 1:
|
|
571
|
+
commands.append("n next")
|
|
572
|
+
if page > 0:
|
|
573
|
+
commands.append("p previous")
|
|
574
|
+
commands.append("q quit")
|
|
575
|
+
choice = read_prompt(" ".join(commands) + "\n> ", stdin, stdout).lower()
|
|
576
|
+
|
|
577
|
+
if choice in {"q", "quit", "exit"}:
|
|
578
|
+
return 0
|
|
579
|
+
if choice in {"n", "next", ""}:
|
|
580
|
+
if page < page_count - 1:
|
|
581
|
+
page += 1
|
|
582
|
+
else:
|
|
583
|
+
print("Already on the last page.", file=stdout)
|
|
584
|
+
continue
|
|
585
|
+
if choice in {"p", "prev", "previous"}:
|
|
586
|
+
if page > 0:
|
|
587
|
+
page -= 1
|
|
588
|
+
else:
|
|
589
|
+
print("Already on the first page.", file=stdout)
|
|
590
|
+
continue
|
|
591
|
+
if choice.isdigit():
|
|
592
|
+
index = int(choice) - 1
|
|
593
|
+
if start <= index < end:
|
|
594
|
+
show_article(
|
|
595
|
+
items[index],
|
|
596
|
+
article_loader=article_loader,
|
|
597
|
+
timeout=timeout,
|
|
598
|
+
stdout=stdout,
|
|
599
|
+
use_color=use_color,
|
|
600
|
+
use_pager=True,
|
|
601
|
+
pager=pager,
|
|
602
|
+
)
|
|
603
|
+
continue
|
|
604
|
+
print(f"Choose an article from {start + 1} to {end}, or use n, p, or q.", file=stdout)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def json_dump(value: Any, stream: TextIO) -> None:
|
|
608
|
+
json.dump(value, stream, ensure_ascii=False, indent=2)
|
|
609
|
+
print(file=stream)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def topic_counts(items: Iterable[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
613
|
+
counts = Counter(tag for item in items for tag in item["tags"])
|
|
614
|
+
return [
|
|
615
|
+
{"topic": topic, "articles": count}
|
|
616
|
+
for topic, count in sorted(counts.items(), key=lambda row: (-row[1], row[0]))
|
|
617
|
+
]
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def choose_topic(items: Sequence[dict[str, Any]], stdin: TextIO, stdout: TextIO, use_color: bool) -> str | None:
|
|
621
|
+
topics = topic_counts(items)
|
|
622
|
+
if not topics:
|
|
623
|
+
print("No published topics yet.", file=stdout)
|
|
624
|
+
return None
|
|
625
|
+
print(file=stdout)
|
|
626
|
+
print(paint("Topics", "1;38;5;214", use_color), file=stdout)
|
|
627
|
+
for index, row in enumerate(topics, 1):
|
|
628
|
+
print(f"[{index}] {row['topic']:<12} {row['articles']} articles", file=stdout)
|
|
629
|
+
choice = read_prompt("\nChoose a topic number, or q to quit\n> ", stdin, stdout).lower()
|
|
630
|
+
if choice in {"q", "quit", "exit"}:
|
|
631
|
+
return None
|
|
632
|
+
if choice.isdigit() and 1 <= int(choice) <= len(topics):
|
|
633
|
+
return topics[int(choice) - 1]["topic"]
|
|
634
|
+
print("That topic number is not available.", file=stdout)
|
|
635
|
+
return None
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def run(
|
|
639
|
+
argv: Sequence[str] | None = None,
|
|
640
|
+
*,
|
|
641
|
+
stdin: TextIO = sys.stdin,
|
|
642
|
+
stdout: TextIO = sys.stdout,
|
|
643
|
+
stderr: TextIO = sys.stderr,
|
|
644
|
+
feed_loader: Callable[[str, float], list[dict[str, Any]]] = fetch_feed,
|
|
645
|
+
article_loader: Callable[[dict[str, Any], float], dict[str, Any]] = fetch_article,
|
|
646
|
+
browser_opener: Callable[..., bool] = webbrowser.open,
|
|
647
|
+
pager: Callable[[str], None] = pydoc.pager,
|
|
648
|
+
) -> int:
|
|
649
|
+
parser = build_parser()
|
|
650
|
+
args = parser.parse_args(argv)
|
|
651
|
+
command = args.command or "browse"
|
|
652
|
+
feed_url = getattr(args, "feed", os.environ.get("NULLTAP_FEED_URL", DEFAULT_FEED_URL))
|
|
653
|
+
timeout = getattr(args, "timeout", 10.0)
|
|
654
|
+
json_output = getattr(args, "json", False)
|
|
655
|
+
plain = getattr(args, "plain", False)
|
|
656
|
+
no_color = getattr(args, "no_color", False)
|
|
657
|
+
page_size = min(getattr(args, "page_size", 5), 50)
|
|
658
|
+
|
|
659
|
+
if timeout <= 0 or timeout > 120:
|
|
660
|
+
parser.error("--timeout must be greater than 0 and no more than 120 seconds")
|
|
661
|
+
|
|
662
|
+
items = feed_loader(feed_url, timeout)
|
|
663
|
+
use_color = color_enabled(stdout, no_color)
|
|
664
|
+
interactive = interactive_enabled(stdin, stdout, plain, json_output)
|
|
665
|
+
|
|
666
|
+
if command == "topics":
|
|
667
|
+
topics = topic_counts(items)
|
|
668
|
+
if json_output:
|
|
669
|
+
json_dump(topics, stdout)
|
|
670
|
+
return 0
|
|
671
|
+
if interactive:
|
|
672
|
+
chosen = choose_topic(items, stdin, stdout, use_color)
|
|
673
|
+
if chosen:
|
|
674
|
+
return browse_items(
|
|
675
|
+
filter_topic(items, chosen),
|
|
676
|
+
heading=f"Topic: {chosen}",
|
|
677
|
+
page_size=page_size,
|
|
678
|
+
stdin=stdin,
|
|
679
|
+
stdout=stdout,
|
|
680
|
+
use_color=use_color,
|
|
681
|
+
article_loader=article_loader,
|
|
682
|
+
timeout=timeout,
|
|
683
|
+
pager=pager,
|
|
684
|
+
)
|
|
685
|
+
return 0
|
|
686
|
+
if topics:
|
|
687
|
+
width = max(len(row["topic"]) for row in topics)
|
|
688
|
+
for row in topics:
|
|
689
|
+
print(f"{row['topic']:<{width}} {row['articles']}", file=stdout)
|
|
690
|
+
else:
|
|
691
|
+
print("No published topics yet.", file=stdout)
|
|
692
|
+
return 0
|
|
693
|
+
|
|
694
|
+
if command == "topic":
|
|
695
|
+
topic_name = args.name
|
|
696
|
+
if not topic_name and interactive:
|
|
697
|
+
topic_name = choose_topic(items, stdin, stdout, use_color)
|
|
698
|
+
if not topic_name:
|
|
699
|
+
return 0
|
|
700
|
+
if not topic_name:
|
|
701
|
+
print("nulltap: provide a topic name or run this command in a terminal", file=stderr)
|
|
702
|
+
return 1
|
|
703
|
+
selected = filter_topic(items, topic_name)
|
|
704
|
+
if not selected and items:
|
|
705
|
+
available = ", ".join(row["topic"] for row in topic_counts(items))
|
|
706
|
+
print(f"nulltap: no topic named '{safe_text(topic_name)}'", file=stderr)
|
|
707
|
+
if available:
|
|
708
|
+
print(f"available topics: {available}", file=stderr)
|
|
709
|
+
return 1
|
|
710
|
+
selected = selected[: args.limit]
|
|
711
|
+
heading = f"Topic: {safe_text(topic_name).lower()}"
|
|
712
|
+
elif command == "search":
|
|
713
|
+
query = " ".join(args.query)
|
|
714
|
+
if not query and interactive:
|
|
715
|
+
query = read_prompt("Search nulltap\n> ", stdin, stdout)
|
|
716
|
+
if not query:
|
|
717
|
+
print("nulltap: provide search words", file=stderr)
|
|
718
|
+
return 1
|
|
719
|
+
selected = filter_topic(items, getattr(args, "topic", None))
|
|
720
|
+
selected = search_items(selected, query)[: args.limit]
|
|
721
|
+
heading = f"Search: {safe_text(query)}"
|
|
722
|
+
elif command in {"browse", "latest"}:
|
|
723
|
+
limit = getattr(args, "limit", 50)
|
|
724
|
+
selected = filter_topic(items, getattr(args, "topic", None))[:limit]
|
|
725
|
+
heading = "Recent articles"
|
|
726
|
+
elif command in {"read", "show", "open"}:
|
|
727
|
+
target = getattr(args, "target", None)
|
|
728
|
+
if command in {"read", "show"} and not target:
|
|
729
|
+
selected = items[:50]
|
|
730
|
+
heading = "Recent articles"
|
|
731
|
+
else:
|
|
732
|
+
item = resolve_item(items, target or "")
|
|
733
|
+
if not item:
|
|
734
|
+
print(f"nulltap: article not found: {safe_text(target)}", file=stderr)
|
|
735
|
+
return 1
|
|
736
|
+
if command in {"read", "show"}:
|
|
737
|
+
article = article_loader(item, timeout)
|
|
738
|
+
if json_output:
|
|
739
|
+
json_dump(article, stdout)
|
|
740
|
+
else:
|
|
741
|
+
rendered = render_article(article, use_color)
|
|
742
|
+
if interactive:
|
|
743
|
+
pager(rendered)
|
|
744
|
+
else:
|
|
745
|
+
print(rendered, end="", file=stdout)
|
|
746
|
+
return 0
|
|
747
|
+
|
|
748
|
+
opened = browser_opener(item["url"], new=2)
|
|
749
|
+
if json_output:
|
|
750
|
+
json_dump({"url": item["url"], "opened": bool(opened)}, stdout)
|
|
751
|
+
else:
|
|
752
|
+
print(item["url"], file=stdout)
|
|
753
|
+
return 0 if opened else 1
|
|
754
|
+
else:
|
|
755
|
+
parser.error(f"unknown command: {command}")
|
|
756
|
+
|
|
757
|
+
if json_output:
|
|
758
|
+
json_dump(selected, stdout)
|
|
759
|
+
elif interactive:
|
|
760
|
+
return browse_items(
|
|
761
|
+
selected,
|
|
762
|
+
heading=heading,
|
|
763
|
+
page_size=page_size,
|
|
764
|
+
stdin=stdin,
|
|
765
|
+
stdout=stdout,
|
|
766
|
+
use_color=use_color,
|
|
767
|
+
article_loader=article_loader,
|
|
768
|
+
timeout=timeout,
|
|
769
|
+
pager=pager,
|
|
770
|
+
)
|
|
771
|
+
else:
|
|
772
|
+
print_items(selected, stdout, use_color)
|
|
773
|
+
return 0
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
777
|
+
try:
|
|
778
|
+
return run(argv)
|
|
779
|
+
except FeedError as exc:
|
|
780
|
+
print(f"nulltap: {exc}", file=sys.stderr)
|
|
781
|
+
return 1
|
|
782
|
+
except KeyboardInterrupt:
|
|
783
|
+
print("\nnulltap: interrupted", file=sys.stderr)
|
|
784
|
+
return 130
|
|
@@ -0,0 +1,98 @@
|
|
|
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.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
nulltap/__init__.py,sha256=s82c-MXnXvrIybA4MI_97go-rQ1SG0XEttFy5wJWZSI,58
|
|
2
|
+
nulltap/__main__.py,sha256=PSQ4rpL0dG6f-qH4N7H-gD9igQkdHzH4yVZDcW8lfZo,80
|
|
3
|
+
nulltap/cli.py,sha256=wMJ486CRV7th67QsdSTX3-8zladVvucalNS5IwCpcgc,27986
|
|
4
|
+
nulltap-0.1.0.dist-info/licenses/LICENSE,sha256=hh97_vBa-UamJetW2Mf5DJSkuwvuZh1dE4L6r2je9po,1068
|
|
5
|
+
nulltap-0.1.0.dist-info/METADATA,sha256=ku4nQMXdKSztrJR0BhlAAYwPMQ7Iy-d7Kqs8NM9KxsA,3994
|
|
6
|
+
nulltap-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
7
|
+
nulltap-0.1.0.dist-info/entry_points.txt,sha256=OzRzrMg_7HejnMTejZuPbeL6DlrRHCsv-0MrwF-d0qA,45
|
|
8
|
+
nulltap-0.1.0.dist-info/top_level.txt,sha256=fJdJwgMlobWGv7eHIV-MHbJtGsQ1TOgsJYchAg_nX3E,8
|
|
9
|
+
nulltap-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Justin Howe
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
nulltap
|