librario 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: librario
3
+ Version: 0.1.0
4
+ Summary: A small keyboard-first text web browser for the terminal
5
+ Requires-Python: >=3.10
@@ -0,0 +1,37 @@
1
+ # Librario
2
+
3
+ Librario is a deliberately small, text-first web browser for your terminal. Search results are navigated with the arrow keys (or `j`/`k`); press Enter to open a stripped-down readable page.
4
+
5
+ ## Install
6
+
7
+ The quick, dependency-free way is to put the included launcher on your path:
8
+
9
+ ```sh
10
+ mkdir -p ~/.local/bin
11
+ ln -sf "$(pwd)/bin/librario" ~/.local/bin/librario
12
+ ```
13
+
14
+ Ensure `~/.local/bin` is in your `PATH`, then run:
15
+
16
+ ```sh
17
+ librario search "query here"
18
+ ```
19
+
20
+ It can also be packaged with a normal Python environment that has `setuptools` installed:
21
+
22
+ ```sh
23
+ python3 -m pip install --user .
24
+ ```
25
+
26
+ ## Use
27
+
28
+ ```sh
29
+ librario search "weather on mars"
30
+ ```
31
+
32
+ - `↑` / `↓` or `j` / `k`: move or scroll
33
+ - `Enter`: open selected result
34
+ - `b` or `Esc`: return to results
35
+ - `q`: quit
36
+
37
+ Librario retrieves search results from Bing’s RSS endpoint and makes normal HTTPS requests for pages. Some JavaScript-only, login-protected, or anti-bot-heavy sites will not provide usable text—alas, the modern web occasionally wears its pages like a hat.
@@ -0,0 +1 @@
1
+ """Librario: a tiny text-first terminal browser."""
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import curses
5
+ import html
6
+ import re
7
+ import sys
8
+ import textwrap
9
+ import urllib.parse
10
+ import urllib.request
11
+ import xml.etree.ElementTree as ET
12
+ from dataclasses import dataclass
13
+ from html.parser import HTMLParser
14
+
15
+ USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124 Safari/537.36"
16
+ BLOCK_TAGS = {"p", "div", "section", "article", "main", "header", "footer", "aside", "li", "br", "h1", "h2", "h3", "h4", "h5", "h6", "tr"}
17
+ SKIP_TAGS = {"script", "style", "noscript", "svg", "canvas", "iframe", "nav"}
18
+
19
+
20
+ @dataclass
21
+ class Result:
22
+ title: str
23
+ url: str
24
+ snippet: str = ""
25
+
26
+
27
+ def request(url: str) -> str:
28
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html,application/xhtml+xml"})
29
+ with urllib.request.urlopen(req, timeout=15) as response:
30
+ charset = response.headers.get_content_charset() or "utf-8"
31
+ return response.read().decode(charset, errors="replace")
32
+
33
+
34
+ class SearchParser(HTMLParser):
35
+ def __init__(self):
36
+ super().__init__()
37
+ self.results: list[Result] = []
38
+ self._href: str | None = None
39
+ self._title: list[str] = []
40
+ self._snippet: list[str] = []
41
+ self._in_result = False
42
+ self._in_snippet = False
43
+ self._snippet_target: Result | None = None
44
+
45
+ def handle_starttag(self, tag, attrs):
46
+ attrs = dict(attrs)
47
+ classes = attrs.get("class", "")
48
+ if tag == "a" and "result__a" in classes:
49
+ self._href, self._title, self._snippet, self._in_result = attrs.get("href"), [], [], True
50
+ elif tag in {"a", "div"} and "result__snippet" in classes and self.results:
51
+ self._snippet_target = self.results[-1]
52
+ self._in_snippet = True
53
+
54
+ def handle_endtag(self, tag):
55
+ if tag == "a" and self._href and self._title:
56
+ href = html.unescape(self._href)
57
+ parsed = urllib.parse.urlparse(href)
58
+ if "duckduckgo.com" in parsed.netloc:
59
+ href = urllib.parse.parse_qs(parsed.query).get("uddg", [href])[0]
60
+ self.results.append(Result(" ".join(self._title).strip(), href))
61
+ self._href, self._in_result, self._in_snippet = None, False, False
62
+ elif self._in_snippet and tag in {"a", "div"}:
63
+ if self._snippet_target:
64
+ self._snippet_target.snippet = " ".join(self._snippet).strip()
65
+ self._in_snippet = False
66
+ self._snippet_target = None
67
+
68
+ def handle_data(self, data):
69
+ if self._href and not self._in_snippet:
70
+ self._title.append(data)
71
+ elif self._in_snippet:
72
+ self._snippet.append(data)
73
+
74
+
75
+ def search(query: str) -> list[Result]:
76
+ # Bing's RSS endpoint is structured, compact, and avoids brittle scraping
77
+ # of a JavaScript search-results page.
78
+ url = "https://www.bing.com/search?" + urllib.parse.urlencode({"format": "rss", "q": query})
79
+ try:
80
+ root = ET.fromstring(request(url))
81
+ except ET.ParseError as exc:
82
+ raise RuntimeError("The search provider returned an unreadable response.") from exc
83
+ results = []
84
+ for item in root.findall("./channel/item"):
85
+ title = " ".join((item.findtext("title") or "").split())
86
+ link = (item.findtext("link") or "").strip()
87
+ snippet = " ".join(html.unescape(item.findtext("description") or "").split())
88
+ if title and link:
89
+ results.append(Result(title, link, snippet))
90
+ return results
91
+
92
+
93
+ class TextParser(HTMLParser):
94
+ def __init__(self):
95
+ super().__init__()
96
+ self.parts: list[str] = []
97
+ self.skip_depth = 0
98
+ self.title: list[str] = []
99
+ self.in_title = False
100
+
101
+ def handle_starttag(self, tag, attrs):
102
+ if tag in SKIP_TAGS:
103
+ self.skip_depth += 1
104
+ if tag == "title": self.in_title = True
105
+ if not self.skip_depth and tag in BLOCK_TAGS:
106
+ self.parts.append("\n")
107
+
108
+ def handle_endtag(self, tag):
109
+ if tag in SKIP_TAGS and self.skip_depth:
110
+ self.skip_depth -= 1
111
+ if tag == "title": self.in_title = False
112
+ if not self.skip_depth and tag in BLOCK_TAGS:
113
+ self.parts.append("\n")
114
+
115
+ def handle_data(self, data):
116
+ if self.in_title: self.title.append(data)
117
+ if not self.skip_depth: self.parts.append(data)
118
+
119
+ def text(self) -> str:
120
+ text = html.unescape("".join(self.parts)).replace("\xa0", " ")
121
+ text = re.sub(r"[ \t]+", " ", text)
122
+ text = re.sub(r"\n\s*\n+", "\n\n", text)
123
+ return text.strip()
124
+
125
+
126
+ def fetch_page(url: str) -> tuple[str, str]:
127
+ parser = TextParser()
128
+ parser.feed(request(url))
129
+ return (" ".join(parser.title).strip() or url, parser.text() or "This page contained no readable text.")
130
+
131
+
132
+ def clip(window, y: int, x: int, value: str, attr=0):
133
+ height, width = window.getmaxyx()
134
+ if 0 <= y < height and x < width:
135
+ window.addnstr(y, x, value, max(0, width - x - 1), attr)
136
+
137
+
138
+ class App:
139
+ def __init__(self, query: str):
140
+ self.query, self.results, self.selected, self.offset = query, [], 0, 0
141
+ self.screen = None
142
+
143
+ def run(self):
144
+ try:
145
+ self.results = search(self.query)
146
+ except Exception as exc:
147
+ self.error(f"Search failed: {exc}")
148
+ return
149
+ curses.wrapper(self.results_view)
150
+
151
+ def error(self, message):
152
+ print(message, file=sys.stderr)
153
+
154
+ def status(self, message: str):
155
+ height, _ = self.screen.getmaxyx()
156
+ self.screen.move(height - 1, 0); self.screen.clrtoeol(); clip(self.screen, height - 1, 0, message, curses.A_REVERSE)
157
+
158
+ def results_view(self, screen):
159
+ self.screen = screen
160
+ curses.curs_set(0); screen.keypad(True)
161
+ if curses.has_colors():
162
+ curses.start_color()
163
+ curses.use_default_colors()
164
+ curses.init_pair(1, curses.COLOR_BLACK, curses.COLOR_CYAN)
165
+ while True:
166
+ screen.erase(); h, w = screen.getmaxyx(); usable = max(1, (h - 4) // 3)
167
+ clip(screen, 0, 0, f" Librario — {self.query}", curses.A_BOLD)
168
+ clip(screen, 1, 0, " ↑/↓ or j/k: move Enter: open q: quit", curses.A_DIM)
169
+ if not self.results:
170
+ clip(screen, 3, 0, "No results found.")
171
+ self.offset = min(self.offset, max(0, len(self.results) - usable))
172
+ for slot, index in enumerate(range(self.offset, min(len(self.results), self.offset + usable))):
173
+ row = 3 + slot * 3
174
+ result = self.results[index]
175
+ selected = index == self.selected
176
+ attr = curses.color_pair(1) | curses.A_BOLD if selected and curses.has_colors() else (curses.A_REVERSE | curses.A_BOLD if selected else 0)
177
+ marker = ">" if selected else " "
178
+ clip(screen, row, 0, f"{marker} {index + 1:2}. {result.title}", attr)
179
+ clip(screen, row + 1, 4, result.url, attr | curses.A_DIM)
180
+ if row + 2 < h - 1: clip(screen, row + 2, 4, result.snippet, attr)
181
+ if self.results:
182
+ current = self.results[self.selected]
183
+ self.status(f" > {self.selected + 1}/{len(self.results)} selected: {current.title}")
184
+ else:
185
+ self.status(" No results")
186
+ key = screen.getch()
187
+ if key in (ord("q"), 27): return
188
+ if key in (curses.KEY_DOWN, ord("j")) and self.selected < len(self.results) - 1:
189
+ self.selected += 1
190
+ if self.selected >= self.offset + usable: self.offset += 1
191
+ elif key in (curses.KEY_UP, ord("k")) and self.selected > 0:
192
+ self.selected -= 1
193
+ if self.selected < self.offset: self.offset -= 1
194
+ elif key in (10, 13, curses.KEY_ENTER) and self.results:
195
+ self.page_view(self.results[self.selected])
196
+
197
+ def page_view(self, result: Result):
198
+ self.screen.erase(); self.status(" Fetching page…"); self.screen.refresh()
199
+ try:
200
+ title, body = fetch_page(result.url)
201
+ except Exception as exc:
202
+ title, body = result.title, f"Could not open this page:\n\n{exc}"
203
+ scroll = 0
204
+ while True:
205
+ h, w = self.screen.getmaxyx(); wrap_width = max(20, w - 2)
206
+ lines = [line for para in body.splitlines() for line in (textwrap.wrap(para, wrap_width) or [""])]
207
+ self.screen.erase(); clip(self.screen, 0, 0, f" {title}", curses.A_BOLD); clip(self.screen, 1, 0, f" {result.url}", curses.A_DIM)
208
+ visible = max(1, h - 4)
209
+ for row, line in enumerate(lines[scroll:scroll + visible], start=3): clip(self.screen, row, 1, line)
210
+ self.status(f" ↑/↓ or j/k: scroll b/Esc: back q: quit {scroll + 1}/{max(1, len(lines))}")
211
+ key = self.screen.getch()
212
+ if key == ord("q"): raise SystemExit
213
+ if key in (ord("b"), 27): return
214
+ if key in (curses.KEY_DOWN, ord("j")): scroll = min(max(0, len(lines) - visible), scroll + 1)
215
+ if key in (curses.KEY_UP, ord("k")): scroll = max(0, scroll - 1)
216
+ if key == curses.KEY_NPAGE: scroll = min(max(0, len(lines) - visible), scroll + visible)
217
+ if key == curses.KEY_PPAGE: scroll = max(0, scroll - visible)
218
+
219
+
220
+ def main():
221
+ parser = argparse.ArgumentParser(prog="librario", description="A keyboard-first text browser.")
222
+ sub = parser.add_subparsers(dest="command", required=True)
223
+ search_parser = sub.add_parser("search", help="Search the web in a text interface")
224
+ search_parser.add_argument("query", help="What to search for")
225
+ args = parser.parse_args()
226
+ if args.command == "search": App(args.query).run()
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: librario
3
+ Version: 0.1.0
4
+ Summary: A small keyboard-first text web browser for the terminal
5
+ Requires-Python: >=3.10
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ librario/__init__.py
4
+ librario/__main__.py
5
+ librario.egg-info/PKG-INFO
6
+ librario.egg-info/SOURCES.txt
7
+ librario.egg-info/dependency_links.txt
8
+ librario.egg-info/entry_points.txt
9
+ librario.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ librario = librario.__main__:main
@@ -0,0 +1 @@
1
+ librario
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "librario"
7
+ version = "0.1.0"
8
+ description = "A small keyboard-first text web browser for the terminal"
9
+ requires-python = ">=3.10"
10
+
11
+ [project.scripts]
12
+ librario = "librario.__main__:main"
13
+
14
+ [tool.setuptools]
15
+ packages = ["librario"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+