libert 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.
libert-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HonoravelMacho
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.
libert-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: libert
3
+ Version: 0.1.0
4
+ Summary: CLI rápida para download de livros do welib
5
+ Author: HonoravelMacho
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/HonoravelMacho/libert
8
+ Project-URL: Repository, https://github.com/HonoravelMacho/libert
9
+ Keywords: welib,ebook,download,cli,livros
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: requests>=2.28
19
+ Requires-Dist: beautifulsoup4>=4.11
20
+ Requires-Dist: rich>=13
21
+ Dynamic: license-file
22
+
23
+ # libert
24
+
25
+ CLI extremamente rápida e direta para download de livros do [welib](https://welib.st).
26
+
27
+ Instale via PyPI:
28
+
29
+ ```bash
30
+ pip install libert
31
+ ```
32
+
33
+ ## Uso
34
+
35
+ ```bash
36
+ libert <URL_DO_WELIB>
37
+ libert -f pdf <URL_DO_WELIB>
38
+ libert --format epub <URL_DO_WELIB>
39
+ ```
40
+
41
+ Exemplo:
42
+
43
+ ```bash
44
+ libert https://pt.welib.st/md5/55d053185d2b4871c7a0549b997135f9
45
+ ```
46
+
47
+ Fluxo no terminal:
48
+
49
+ ```
50
+ [+] Conectando ao welib...
51
+ [+] Livro encontrado: "Nome do Livro"
52
+ [+] Formatos disponíveis:
53
+ [1] PDF (15.4 MB)
54
+ [2] EPUB (2.1 MB)
55
+ Escolha o formato desejado (1-2):
56
+ ```
57
+
58
+ - Com apenas 1 formato disponível, o download começa automaticamente.
59
+ - Com `-f/--format`, o modo é não-interativo.
60
+ - Arquivos são salvos em `~/Downloads` (Linux) ou `~/storage/downloads` (Termux/Android).
61
+ - Barra de progresso com tamanho total, porcentagem e velocidade.
62
+
63
+ ## Opções
64
+
65
+ ```
66
+ libert [-f FORMATO] [-o PASTA] <URL>
67
+ libert -V
68
+ ```
69
+
70
+ ## Desenvolvimento
71
+
72
+ ```bash
73
+ pip install -e .
74
+ python -m build
75
+ ```
76
+
77
+ ## Licença
78
+
79
+ MIT
libert-0.1.0/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # libert
2
+
3
+ CLI extremamente rápida e direta para download de livros do [welib](https://welib.st).
4
+
5
+ Instale via PyPI:
6
+
7
+ ```bash
8
+ pip install libert
9
+ ```
10
+
11
+ ## Uso
12
+
13
+ ```bash
14
+ libert <URL_DO_WELIB>
15
+ libert -f pdf <URL_DO_WELIB>
16
+ libert --format epub <URL_DO_WELIB>
17
+ ```
18
+
19
+ Exemplo:
20
+
21
+ ```bash
22
+ libert https://pt.welib.st/md5/55d053185d2b4871c7a0549b997135f9
23
+ ```
24
+
25
+ Fluxo no terminal:
26
+
27
+ ```
28
+ [+] Conectando ao welib...
29
+ [+] Livro encontrado: "Nome do Livro"
30
+ [+] Formatos disponíveis:
31
+ [1] PDF (15.4 MB)
32
+ [2] EPUB (2.1 MB)
33
+ Escolha o formato desejado (1-2):
34
+ ```
35
+
36
+ - Com apenas 1 formato disponível, o download começa automaticamente.
37
+ - Com `-f/--format`, o modo é não-interativo.
38
+ - Arquivos são salvos em `~/Downloads` (Linux) ou `~/storage/downloads` (Termux/Android).
39
+ - Barra de progresso com tamanho total, porcentagem e velocidade.
40
+
41
+ ## Opções
42
+
43
+ ```
44
+ libert [-f FORMATO] [-o PASTA] <URL>
45
+ libert -V
46
+ ```
47
+
48
+ ## Desenvolvimento
49
+
50
+ ```bash
51
+ pip install -e .
52
+ python -m build
53
+ ```
54
+
55
+ ## Licença
56
+
57
+ MIT
@@ -0,0 +1,3 @@
1
+ """libert - download de livros do welib via CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,120 @@
1
+ """Interface de linha de comando do libert."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from libert import __version__
9
+ from libert.downloader import download_file, filename_for, get_downloads_dir
10
+ from libert.scraper import get_book_info, resolve_download_url
11
+
12
+
13
+ def build_parser() -> argparse.ArgumentParser:
14
+ p = argparse.ArgumentParser(
15
+ prog="libert",
16
+ description="Baixe livros do welib direto pelo terminal. Ex: libert <URL_DO_WELIB>",
17
+ )
18
+ p.add_argument("url", nargs="?", help="URL da página do livro no welib (ex: https://pt.welib.st/md5/...)")
19
+ p.add_argument(
20
+ "-f",
21
+ "--format",
22
+ dest="fmt",
23
+ default=None,
24
+ help="Formato desejado (ex: pdf, epub, mobi). Evita a pergunta interativa.",
25
+ )
26
+ p.add_argument(
27
+ "-o",
28
+ "--output-dir",
29
+ dest="output_dir",
30
+ default=None,
31
+ help="Pasta de destino (padrão: ~/Downloads).",
32
+ )
33
+ p.add_argument(
34
+ "-V", "--version", action="store_true", help="Mostra a versão e sai."
35
+ )
36
+ return p
37
+
38
+
39
+ def choose_format(formats, wanted: str | None):
40
+ if wanted:
41
+ w = wanted.strip().lower().lstrip(".")
42
+ for f in formats:
43
+ if f.fmt.lower() == w:
44
+ return f
45
+ validos = ", ".join(f.fmt for f in formats)
46
+ print(f'[!] Formato "{wanted}" não disponível para este livro. Formatos válidos: {validos}', file=sys.stderr)
47
+ sys.exit(2)
48
+ if len(formats) == 1:
49
+ return formats[0]
50
+ # Múltiplos formatos: pergunta interativa.
51
+ n = len(formats)
52
+ while True:
53
+ try:
54
+ ans = input(f"Escolha o formato desejado (1-{n}): ").strip()
55
+ except (EOFError, KeyboardInterrupt):
56
+ print("\n[!] Operação cancelada.", file=sys.stderr)
57
+ sys.exit(130)
58
+ if ans.isdigit() and 1 <= int(ans) <= n:
59
+ return formats[int(ans) - 1]
60
+ print(f"[!] Escolha inválida. Digite um número entre 1 e {n}.", file=sys.stderr)
61
+
62
+
63
+ def main(argv: list[str] | None = None) -> int:
64
+ parser = build_parser()
65
+ args = parser.parse_args(argv)
66
+
67
+ if args.version:
68
+ print(f"libert {__version__}")
69
+ return 0
70
+
71
+ if not args.url:
72
+ parser.print_usage(sys.stderr)
73
+ print("Erro: informe a URL do livro. Ex: libert https://pt.welib.st/md5/<hash>", file=sys.stderr)
74
+ return 2
75
+
76
+ url = args.url.strip()
77
+ if "welib" not in url and "/md5/" not in url:
78
+ print("[!] URL inválida. Esperado algo como: https://pt.welib.st/md5/<hash>", file=sys.stderr)
79
+ return 2
80
+
81
+ print("[+] Conectando ao welib...")
82
+ try:
83
+ book = get_book_info(url)
84
+ except RuntimeError as e:
85
+ print(f"[!] Erro: {e}", file=sys.stderr)
86
+ return 1
87
+ except KeyboardInterrupt:
88
+ print("\n[!] Operação cancelada.", file=sys.stderr)
89
+ return 130
90
+
91
+ print(f'[+] Livro encontrado: "{book.title}"')
92
+ print("[+] Formatos disponíveis:")
93
+ for i, f in enumerate(book.formats, start=1):
94
+ if f.size:
95
+ print(f" [{i}] {f.fmt} ({f.size})")
96
+ else:
97
+ print(f" [{i}] {f.fmt}")
98
+
99
+ chosen = choose_format(book.formats, args.fmt)
100
+
101
+ from pathlib import Path
102
+
103
+ out_dir = Path(args.output_dir).expanduser() if args.output_dir else get_downloads_dir()
104
+ dest = out_dir / filename_for(book.title, chosen.fmt, resolve_download_url(chosen))
105
+
106
+ try:
107
+ final = download_file(resolve_download_url(chosen), dest, referer=book.source_url)
108
+ except RuntimeError as e:
109
+ print(f"[!] Erro no download: {e}", file=sys.stderr)
110
+ return 1
111
+ except KeyboardInterrupt:
112
+ print("\n[!] Download cancelado.", file=sys.stderr)
113
+ return 130
114
+
115
+ print(f"[+] Download concluído: {final}")
116
+ return 0
117
+
118
+
119
+ if __name__ == "__main__":
120
+ raise SystemExit(main())
@@ -0,0 +1,139 @@
1
+ """Download de arquivos com barra de progresso (rich)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import time
8
+ from pathlib import Path
9
+ from urllib.parse import urlparse, unquote
10
+
11
+ import requests
12
+ from rich.progress import (
13
+ BarColumn,
14
+ DownloadColumn,
15
+ Progress,
16
+ TaskProgressColumn,
17
+ TimeRemainingColumn,
18
+ TransferSpeedColumn,
19
+ )
20
+
21
+ BROWSER_HEADERS = {
22
+ "User-Agent": (
23
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
24
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
25
+ ),
26
+ "Accept": "*/*",
27
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
28
+ "Referer": "https://welib.st/",
29
+ }
30
+
31
+
32
+ def get_downloads_dir() -> Path:
33
+ """Pasta de Downloads do sistema (Linux desktop e Android/Termux)."""
34
+ home = Path.home()
35
+ candidates = [
36
+ home / "storage" / "downloads", # Termux
37
+ home / "Downloads", # Linux desktop / Termux (novo)
38
+ Path("/sdcard/Download"), # Android alternativo
39
+ ]
40
+ for c in candidates:
41
+ try:
42
+ if c.exists() and c.is_dir():
43
+ return c
44
+ except OSError:
45
+ continue
46
+ # Fallback: cria ~/Downloads; se impossível, usa o cwd.
47
+ try:
48
+ (home / "Downloads").mkdir(parents=True, exist_ok=True)
49
+ return home / "Downloads"
50
+ except OSError:
51
+ cwd = Path.cwd()
52
+ return cwd
53
+
54
+
55
+ def sanitize_filename(name: str) -> str:
56
+ name = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name).strip()
57
+ name = re.sub(r"\s+", " ", name)
58
+ name = name.strip(" .")
59
+ return name[:180] if len(name) > 180 else (name or "livro")
60
+
61
+
62
+ def filename_for(title: str, fmt: str, url: str) -> str:
63
+ # Tenta extrair nome do Content-Disposition/URL depois; aqui o palpite inicial.
64
+ ext = fmt.lower().lstrip(".")
65
+ parsed = urlparse(url)
66
+ base = os.path.basename(unquote(parsed.path))
67
+ if base and "." in base and len(base) < 120:
68
+ return sanitize_filename(base)
69
+ return sanitize_filename(f"{title}.{ext}")
70
+
71
+
72
+ def _filename_from_response(resp: requests.Response, fallback: str) -> str:
73
+ cd = resp.headers.get("Content-Disposition", "")
74
+ m = re.search(r'filename\*?=(?:UTF-8\'\')?"?([^";]+)"?', cd)
75
+ if m:
76
+ cand = sanitize_filename(unquote(m.group(1)))
77
+ if cand:
78
+ return cand
79
+ # Tenta pela URL final (após redirects).
80
+ parsed = urlparse(str(resp.url))
81
+ base = os.path.basename(unquote(parsed.path))
82
+ if base and "." in base and len(base) < 120 and "md5" not in base.lower():
83
+ return sanitize_filename(base)
84
+ return fallback
85
+
86
+
87
+ def download_file(url: str, dest: Path, referer: str | None = None, timeout: int = 60) -> Path:
88
+ """Baixa `url` para `dest` com barra de progresso rich.
89
+
90
+ Mostra tamanho total, porcentagem e velocidade instantânea.
91
+ Retorna o caminho final (pode mudar via Content-Disposition).
92
+ """
93
+ headers = dict(BROWSER_HEADERS)
94
+ if referer:
95
+ headers["Referer"] = referer
96
+ dest.parent.mkdir(parents=True, exist_ok=True)
97
+
98
+ with requests.get(url, headers=headers, stream=True, timeout=timeout, allow_redirects=True) as resp:
99
+ if resp.status_code in (401, 403):
100
+ raise RuntimeError(
101
+ f"Download bloqueado (HTTP {resp.status_code}). "
102
+ "O welib pode exigir login/conta gratuita para downloads. "
103
+ "Abra a URL no navegador e verifique."
104
+ )
105
+ if resp.status_code == 404:
106
+ raise RuntimeError("Arquivo de download não encontrado (HTTP 404).")
107
+ resp.raise_for_status()
108
+
109
+ total = int(resp.headers.get("Content-Length", 0) or 0)
110
+ final_name = _filename_from_response(resp, dest.name)
111
+ final_dest = dest.parent / final_name
112
+ # Evita sobrescrever: adiciona (1), (2)...
113
+ if final_dest.exists() and final_dest != dest:
114
+ stem, suffix = final_dest.stem, final_dest.suffix
115
+ i = 1
116
+ while (dest.parent / f"{stem} ({i}){suffix}").exists():
117
+ i += 1
118
+ final_dest = dest.parent / f"{stem} ({i}){suffix}"
119
+
120
+ start = time.time()
121
+ downloaded = 0
122
+ with Progress(
123
+ "[progress.description]{task.description}",
124
+ BarColumn(),
125
+ TaskProgressColumn(),
126
+ DownloadColumn(),
127
+ TransferSpeedColumn(),
128
+ TimeRemainingColumn(),
129
+ ) as progress:
130
+ task = progress.add_task(f"[cyan]Baixando {final_dest.name}", total=total or None)
131
+ with open(final_dest, "wb") as f:
132
+ for chunk in resp.iter_content(chunk_size=1024 * 64):
133
+ if not chunk:
134
+ continue
135
+ f.write(chunk)
136
+ downloaded += len(chunk)
137
+ elapsed = max(time.time() - start, 0.001)
138
+ progress.update(task, advance=len(chunk))
139
+ return final_dest
@@ -0,0 +1,386 @@
1
+ """Scraper para páginas de livros do welib (ex: https://pt.welib.st/md5/<hash>)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from urllib.parse import urljoin, urlparse
8
+
9
+ import requests
10
+ from bs4 import BeautifulSoup
11
+
12
+ BROWSER_HEADERS = {
13
+ "User-Agent": (
14
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
15
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
16
+ ),
17
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
18
+ "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.8",
19
+ "Referer": "https://welib.st/",
20
+ "Upgrade-Insecure-Requests": "1",
21
+ "Sec-Fetch-Dest": "document",
22
+ "Sec-Fetch-Mode": "navigate",
23
+ "Sec-Fetch-Site": "same-origin",
24
+ "Cache-Control": "max-age=0",
25
+ }
26
+
27
+ MD5_RE = re.compile(r"/md5/([a-fA-F0-9]{32})")
28
+ SIZE_RE = re.compile(r"(\d+(?:[.,]\d+)?\s*(?:B|KB|MB|GB))", re.IGNORECASE)
29
+ # Formatos de e-book mais comuns no welib / Anna's Archive.
30
+ KNOWN_EXTS = ["pdf", "epub", "mobi", "azw3", "djvu", "fb2", "txt", "doc", "docx", "cbr", "cbz"]
31
+
32
+
33
+ @dataclass
34
+ class BookFormat:
35
+ fmt: str # ex: "PDF"
36
+ size: str # ex: "15.4 MB" (pode ser "" se desconhecido)
37
+ url: str # URL direta (ou intermediária) de download
38
+ label: str = "" # descrição extra (idioma, ano...)
39
+
40
+
41
+ @dataclass
42
+ class BookInfo:
43
+ title: str
44
+ formats: list[BookFormat]
45
+ source_url: str
46
+
47
+
48
+ def extract_md5(url: str) -> str | None:
49
+ m = MD5_RE.search(url)
50
+ return m.group(1).lower() if m else None
51
+
52
+
53
+ def candidate_urls(url: str) -> list[str]:
54
+ """Gera URLs candidatas para contornar bloqueios/idiomas.
55
+
56
+ Tenta a URL original primeiro e depois espelhos do mesmo md5.
57
+ """
58
+ md5 = extract_md5(url)
59
+ if not md5:
60
+ return [url]
61
+ path = f"/md5/{md5}"
62
+ cands = [url]
63
+ for base in (
64
+ "https://welib.st",
65
+ "https://welib.org",
66
+ "https://pt.welib.st",
67
+ "https://en.welib.st",
68
+ "https://es.welib.st",
69
+ ):
70
+ full = base + path
71
+ if full not in cands:
72
+ cands.append(full)
73
+ return cands
74
+
75
+
76
+ def fetch_page(url: str, timeout: int = 30) -> tuple[str, str]:
77
+ """Baixa o HTML da página do livro. Retorna (html, url_final).
78
+
79
+ Tenta a URL original e espelhos. Levanta RuntimeError com mensagem
80
+ amigável em caso de bloqueio (Cloudflare 403) ou 404.
81
+ """
82
+ last_err: Exception | None = None
83
+ for cand in candidate_urls(url):
84
+ try:
85
+ resp = requests.get(cand, headers=BROWSER_HEADERS, timeout=timeout, allow_redirects=True)
86
+ except requests.RequestException as e:
87
+ last_err = e
88
+ continue
89
+ if resp.status_code == 404:
90
+ last_err = RuntimeError(f"Página não encontrada (404): {cand}")
91
+ continue
92
+ if resp.status_code == 403:
93
+ # Provável desafio Cloudflare.
94
+ last_err = RuntimeError(
95
+ f"Acesso bloqueado (403) em {cand}. "
96
+ "O welib usa proteção Cloudflare; tente novamente em alguns segundos, "
97
+ "troque de rede ou use VPN."
98
+ )
99
+ continue
100
+ if resp.status_code != 200:
101
+ last_err = RuntimeError(f"HTTP {resp.status_code} ao acessar {cand}")
102
+ continue
103
+ ctype = resp.headers.get("Content-Type", "")
104
+ if "html" not in ctype and len(resp.text) < 500:
105
+ last_err = RuntimeError(f"Resposta inesperada de {cand} (HTTP {resp.status_code})")
106
+ continue
107
+ return resp.text, str(resp.url)
108
+ raise RuntimeError(str(last_err) if last_err else f"Não foi possível acessar {url}")
109
+
110
+
111
+ def _clean_title(raw: str) -> str:
112
+ raw = re.sub(r"\s+", " ", raw).strip()
113
+ # Remove sufixos comuns: " - WeLib", " | WeLib", etc.
114
+ raw = re.sub(r"\s*[-|–—]\s*WeLib.*$", "", raw, flags=re.IGNORECASE).strip()
115
+ raw = re.sub(r"\s*[-|–—]\s*welib.*$", "", raw, flags=re.IGNORECASE).strip()
116
+ return raw
117
+
118
+
119
+ def extract_title(soup: BeautifulSoup) -> str:
120
+ # 1. og:title
121
+ meta = soup.find("meta", attrs={"property": "og:title"})
122
+ if meta and meta.get("content"):
123
+ t = _clean_title(meta["content"])
124
+ if t:
125
+ return t
126
+ # 2. h1
127
+ h1 = soup.find("h1")
128
+ if h1 and h1.get_text(strip=True):
129
+ t = _clean_title(h1.get_text(" ", strip=True))
130
+ if t and len(t) > 2:
131
+ return t
132
+ # 3. <title>
133
+ if soup.title and soup.title.get_text(strip=True):
134
+ t = _clean_title(soup.title.get_text(" ", strip=True))
135
+ if t and t.lower() not in ("just a moment...", "welib", "welib.st"):
136
+ return t
137
+ # 4. primeiro elemento com cara de título
138
+ for sel in ("div.text-2xl", "div.text-xl", "div.font-bold", "h2"):
139
+ el = soup.select_one(sel)
140
+ if el and el.get_text(strip=True) and len(el.get_text(strip=True)) > 3:
141
+ return _clean_title(el.get_text(" ", strip=True))
142
+ return "Livro sem título"
143
+
144
+
145
+ def _size_near(tag) -> str:
146
+ """Procura tamanho (ex: 15.4 MB) no texto do link e no elemento pai."""
147
+ texts = []
148
+ try:
149
+ texts.append(tag.get_text(" ", strip=True))
150
+ except Exception:
151
+ pass
152
+ parent = getattr(tag, "parent", None)
153
+ if parent is not None:
154
+ try:
155
+ texts.append(parent.get_text(" ", strip=True)[:500])
156
+ except Exception:
157
+ pass
158
+ for t in texts:
159
+ m = SIZE_RE.search(t or "")
160
+ if m:
161
+ return m.group(1).replace(",", ".").upper().strip()
162
+ return ""
163
+
164
+
165
+ def _fmt_from_text(text: str) -> str | None:
166
+ low = (text or "").lower()
167
+ for ext in KNOWN_EXTS:
168
+ if re.search(r"\b" + re.escape(ext) + r"\b", low):
169
+ return ext.upper()
170
+ return None
171
+
172
+
173
+ def _page_format_size(soup: BeautifulSoup) -> tuple[str, str]:
174
+ """Infere (formato, tamanho) principal da página.
175
+
176
+ Ex: <title>God's Festivals ... ( PDF, 1.2 MB ) - WeLib</title>
177
+ ou filepath '.../livro.pdf' + 'pdf, 1.2 MB' no texto.
178
+ """
179
+ texts: list[str] = []
180
+ if soup.title:
181
+ texts.append(soup.title.get_text(" ", strip=True))
182
+ h1 = soup.find("h1")
183
+ if h1:
184
+ texts.append(h1.get_text(" ", strip=True))
185
+ # Cabeçalho com "( PDF, 1.2 MB )"
186
+ for t in texts:
187
+ m = re.search(
188
+ r"\(\s*([A-Za-z0-9]{2,5})\s*,\s*(\d+(?:[.,]\d+)?\s*(?:B|KB|MB|GB))\s*\)",
189
+ t,
190
+ re.IGNORECASE,
191
+ )
192
+ if m and m.group(1).lower() in KNOWN_EXTS:
193
+ return m.group(1).upper(), m.group(2).replace(",", ".").upper().strip()
194
+ full = soup.get_text(" ", strip=True)
195
+ # "pdf, 1.2 MB" perto do início (evita listas de livros relacionados).
196
+ head = full[:4000]
197
+ m = re.search(
198
+ r"\b(pdf|epub|mobi|azw3|djvu|fb2|txt|docx?|cbr|cbz)\b\s*,\s*(\d+(?:[.,]\d+)?\s*(?:B|KB|MB|GB))",
199
+ head,
200
+ re.IGNORECASE,
201
+ )
202
+ if m:
203
+ return m.group(1).upper(), m.group(2).replace(",", ".").upper().strip()
204
+ # filepath ... .pdf
205
+ m = re.search(r"filepath:\S+\.(pdf|epub|mobi|azw3|djvu|fb2|txt|docx?|cbr|cbz)\b", full, re.IGNORECASE)
206
+ if m:
207
+ fmt = m.group(1).upper()
208
+ ms = SIZE_RE.search(head)
209
+ size = ms.group(1).replace(",", ".").upper().strip() if ms else ""
210
+ return fmt, size
211
+ return "", ""
212
+
213
+
214
+ def extract_formats(soup: BeautifulSoup, page_url: str) -> list[BookFormat]:
215
+ """Extrai formatos de download da página do welib.
216
+
217
+ Numa página /md5/* real, todas as opções de download ("All download
218
+ options have the same file") correspondem ao MESMO arquivo: retorna
219
+ 1 formato. Se a página listar extensões distintas, retorna uma
220
+ entrada por formato (para satisfazer o modo interativo da CLI).
221
+ """
222
+ IGNORE_PREFIX = (
223
+ "/search", "/member_codes", "/datasets", "/account", "/donate",
224
+ "/login", "/giftcards", "/torrents", "/member", "/db/",
225
+ )
226
+
227
+ def _ignored(href: str) -> bool:
228
+ h = href.strip()
229
+ if not h or h.startswith(("javascript:", "mailto:", "#")):
230
+ return True
231
+ low = h.lower()
232
+ # .torrent não é formato de e-book.
233
+ if low.endswith(".torrent") or "/torrents" in low:
234
+ return True
235
+ for p in IGNORE_PREFIX:
236
+ if low.startswith(p):
237
+ return True
238
+ return False
239
+
240
+ page_fmt, page_size = _page_format_size(soup)
241
+
242
+ # Coleta links reais de download, na ordem de preferência.
243
+ slow: list[str] = []
244
+ auto: list[str] = []
245
+ fast: list[str] = []
246
+ libgen: list[str] = []
247
+ ipfs: list[str] = []
248
+ other: list[str] = []
249
+
250
+ for a in soup.find_all("a", href=True):
251
+ href = (a.get("href") or "").strip()
252
+ if _ignored(href):
253
+ continue
254
+ low = href.lower()
255
+ text = a.get_text(" ", strip=True)
256
+ abs_url = urljoin(page_url, href)
257
+
258
+ if low.startswith("ipfs://"):
259
+ # Converte ipfs://CID -> https://ipfs.io/ipfs/CID
260
+ cid = href[len("ipfs://"):].split("?")[0].split("#")[0]
261
+ if cid:
262
+ ipfs.append(f"https://ipfs.io/ipfs/{cid}")
263
+ continue
264
+ if "/slow_download/" in low:
265
+ slow.append(abs_url)
266
+ continue
267
+ if "/auto_download/" in low:
268
+ auto.append(abs_url)
269
+ continue
270
+ if "/fast_download/" in low:
271
+ fast.append(abs_url)
272
+ continue
273
+ if "libgen" in low and "file.php" in low:
274
+ libgen.append(abs_url)
275
+ continue
276
+ if "ipfs" in low and ("/ipfs/" in low or "gateway" in low):
277
+ ipfs.append(abs_url)
278
+ continue
279
+ # Link direto para arquivo (.pdf/.epub/...), fora de /search.
280
+ m = re.search(r"\.(pdf|epub|mobi|azw3|djvu|fb2|txt|docx?|cbr|cbz)(\?|#|$)", low)
281
+ if m and ("/download" in low or "download" in text.lower() or low.startswith("https://")):
282
+ other.append(abs_url)
283
+
284
+ ordered_urls = slow + auto + fast + libgen + ipfs + other
285
+ # Dedup preservando ordem.
286
+ seen_u: set[str] = set()
287
+ uniq_urls: list[str] = []
288
+ for u in ordered_urls:
289
+ if u not in seen_u:
290
+ seen_u.add(u)
291
+ uniq_urls.append(u)
292
+
293
+ # Tenta detectar múltiplos formatos distintos na página
294
+ # (ex: badges "PDF (15.4 MB)" / "EPUB (2.1 MB)" como links diferentes).
295
+ multi: dict[str, tuple[str, str]] = {} # fmt -> (size, url)
296
+ for a in soup.find_all("a", href=True):
297
+ href = (a.get("href") or "").strip()
298
+ if _ignored(href):
299
+ continue
300
+ low = href.lower()
301
+ if not any(k in low for k in ("/slow_download/", "/auto_download/", "/fast_download/", "/ipfs/", "file.php", ".pdf", ".epub", ".mobi", ".azw3", ".djvu", ".fb2")):
302
+ continue
303
+ text = a.get_text(" ", strip=True)
304
+ fmt = _fmt_from_text(text)
305
+ ext_in_href = re.search(r"\.(pdf|epub|mobi|azw3|djvu|fb2|txt|docx?|cbr|cbz)(\?|#|$)", low)
306
+ if fmt and ("/download" in low or ext_in_href or "ipfs" in low or "file.php" in low):
307
+ # Evita o falso positivo do link de busca "Search WeLib for filepath:..."
308
+ # (esses já foram ignorados via IGNORE_PREFIX, mas garante aqui).
309
+ size = _size_near(a)
310
+ if fmt not in multi:
311
+ multi[fmt] = (size, urljoin(page_url, href))
312
+
313
+ if len(multi) > 1:
314
+ out: list[BookFormat] = []
315
+ for fmt, (size, u) in multi.items():
316
+ out.append(BookFormat(fmt=fmt, size=size or page_size, url=u))
317
+ return out
318
+
319
+ # Caso comum: um único arquivo por página md5.
320
+ if uniq_urls:
321
+ fmt = page_fmt
322
+ if not fmt:
323
+ # Infere pela URL ou pelo texto geral (limitado ao head).
324
+ for u in uniq_urls:
325
+ f = _fmt_from_text(u)
326
+ if f:
327
+ fmt = f
328
+ break
329
+ if not fmt:
330
+ head = soup.get_text(" ", strip=True)[:4000]
331
+ fmt = _fmt_from_text(head) or "PDF"
332
+ return [BookFormat(fmt=fmt.upper(), size=page_size, url=uniq_urls[0])]
333
+
334
+ # Fallback sem links: menções textuais de formato + tamanho.
335
+ full_text = soup.get_text(" ", strip=True)
336
+ found: list[BookFormat] = []
337
+ for m in re.finditer(
338
+ r"([A-Za-z]{2,5}(?:\s*[·,|/]\s*[A-Za-z]{2,5})*)\s*[·,|]\s*[^·|]{0,60}?\s*(\d+(?:[.,]\d+)?\s*(?:B|KB|MB|GB))",
339
+ full_text[:8000],
340
+ ):
341
+ fmts_raw = m.group(1)
342
+ size = m.group(2).replace(",", ".").upper().strip()
343
+ for part in re.split(r"[·,|/]", fmts_raw):
344
+ f = part.strip().upper()
345
+ if f.lower() in KNOWN_EXTS:
346
+ found.append(BookFormat(fmt=f, size=size, url=page_url, label=m.group(0)[:120]))
347
+ if found:
348
+ break
349
+ if found:
350
+ dedup: dict[str, BookFormat] = {}
351
+ for f in found:
352
+ if f.fmt not in dedup:
353
+ dedup[f.fmt] = f
354
+ return list(dedup.values())
355
+
356
+ if page_fmt:
357
+ return [BookFormat(fmt=page_fmt, size=page_size, url=page_url)]
358
+ return []
359
+
360
+
361
+ def get_book_info(url: str) -> BookInfo:
362
+ html, final_url = fetch_page(url)
363
+ soup = BeautifulSoup(html, "html.parser")
364
+
365
+ # Detecta desafio Cloudflare que passou como 200 com "Just a moment".
366
+ title_tag = (soup.title.get_text() if soup.title else "") or ""
367
+ if "just a moment" in title_tag.lower() and len(html) < 30000 and "challenge-platform" in html:
368
+ raise RuntimeError(
369
+ "O welib bloqueou a requisição (desafio Cloudflare). "
370
+ "Aguarde alguns segundos e tente novamente, ou acesse a URL no navegador primeiro."
371
+ )
372
+
373
+ title = extract_title(soup)
374
+ formats = extract_formats(soup, final_url)
375
+ if not formats:
376
+ raise RuntimeError(
377
+ f'Nenhum formato de download encontrado na página "{title}". '
378
+ "A página pode exigir login ou estar protegida. "
379
+ "Abra a URL no navegador para verificar os downloads disponíveis."
380
+ )
381
+ return BookInfo(title=title, formats=formats, source_url=final_url)
382
+
383
+
384
+ def resolve_download_url(fmt: BookFormat) -> str:
385
+ """Por ora o href extraído já é a URL de download (segue redirects no GET)."""
386
+ return fmt.url
@@ -0,0 +1,79 @@
1
+ Metadata-Version: 2.4
2
+ Name: libert
3
+ Version: 0.1.0
4
+ Summary: CLI rápida para download de livros do welib
5
+ Author: HonoravelMacho
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/HonoravelMacho/libert
8
+ Project-URL: Repository, https://github.com/HonoravelMacho/libert
9
+ Keywords: welib,ebook,download,cli,livros
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: End Users/Desktop
14
+ Classifier: Topic :: Utilities
15
+ Requires-Python: >=3.9
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Requires-Dist: requests>=2.28
19
+ Requires-Dist: beautifulsoup4>=4.11
20
+ Requires-Dist: rich>=13
21
+ Dynamic: license-file
22
+
23
+ # libert
24
+
25
+ CLI extremamente rápida e direta para download de livros do [welib](https://welib.st).
26
+
27
+ Instale via PyPI:
28
+
29
+ ```bash
30
+ pip install libert
31
+ ```
32
+
33
+ ## Uso
34
+
35
+ ```bash
36
+ libert <URL_DO_WELIB>
37
+ libert -f pdf <URL_DO_WELIB>
38
+ libert --format epub <URL_DO_WELIB>
39
+ ```
40
+
41
+ Exemplo:
42
+
43
+ ```bash
44
+ libert https://pt.welib.st/md5/55d053185d2b4871c7a0549b997135f9
45
+ ```
46
+
47
+ Fluxo no terminal:
48
+
49
+ ```
50
+ [+] Conectando ao welib...
51
+ [+] Livro encontrado: "Nome do Livro"
52
+ [+] Formatos disponíveis:
53
+ [1] PDF (15.4 MB)
54
+ [2] EPUB (2.1 MB)
55
+ Escolha o formato desejado (1-2):
56
+ ```
57
+
58
+ - Com apenas 1 formato disponível, o download começa automaticamente.
59
+ - Com `-f/--format`, o modo é não-interativo.
60
+ - Arquivos são salvos em `~/Downloads` (Linux) ou `~/storage/downloads` (Termux/Android).
61
+ - Barra de progresso com tamanho total, porcentagem e velocidade.
62
+
63
+ ## Opções
64
+
65
+ ```
66
+ libert [-f FORMATO] [-o PASTA] <URL>
67
+ libert -V
68
+ ```
69
+
70
+ ## Desenvolvimento
71
+
72
+ ```bash
73
+ pip install -e .
74
+ python -m build
75
+ ```
76
+
77
+ ## Licença
78
+
79
+ MIT
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ libert/__init__.py
5
+ libert/cli.py
6
+ libert/downloader.py
7
+ libert/scraper.py
8
+ libert.egg-info/PKG-INFO
9
+ libert.egg-info/SOURCES.txt
10
+ libert.egg-info/dependency_links.txt
11
+ libert.egg-info/entry_points.txt
12
+ libert.egg-info/requires.txt
13
+ libert.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ libert = libert.cli:main
@@ -0,0 +1,3 @@
1
+ requests>=2.28
2
+ beautifulsoup4>=4.11
3
+ rich>=13
@@ -0,0 +1 @@
1
+ libert
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "libert"
7
+ version = "0.1.0"
8
+ description = "CLI rápida para download de livros do welib"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "HonoravelMacho" }]
13
+ keywords = ["welib", "ebook", "download", "cli", "livros"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Environment :: Console",
18
+ "Intended Audience :: End Users/Desktop",
19
+ "Topic :: Utilities",
20
+ ]
21
+ dependencies = [
22
+ "requests>=2.28",
23
+ "beautifulsoup4>=4.11",
24
+ "rich>=13",
25
+ ]
26
+
27
+ [project.scripts]
28
+ libert = "libert.cli:main"
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/HonoravelMacho/libert"
32
+ Repository = "https://github.com/HonoravelMacho/libert"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["."]
36
+ include = ["libert*"]
libert-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+