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.
- librario-0.1.0/PKG-INFO +5 -0
- librario-0.1.0/README.md +37 -0
- librario-0.1.0/librario/__init__.py +1 -0
- librario-0.1.0/librario/__main__.py +230 -0
- librario-0.1.0/librario.egg-info/PKG-INFO +5 -0
- librario-0.1.0/librario.egg-info/SOURCES.txt +9 -0
- librario-0.1.0/librario.egg-info/dependency_links.txt +1 -0
- librario-0.1.0/librario.egg-info/entry_points.txt +2 -0
- librario-0.1.0/librario.egg-info/top_level.txt +1 -0
- librario-0.1.0/pyproject.toml +15 -0
- librario-0.1.0/setup.cfg +4 -0
librario-0.1.0/PKG-INFO
ADDED
librario-0.1.0/README.md
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|
librario-0.1.0/setup.cfg
ADDED