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 +21 -0
- libert-0.1.0/PKG-INFO +79 -0
- libert-0.1.0/README.md +57 -0
- libert-0.1.0/libert/__init__.py +3 -0
- libert-0.1.0/libert/cli.py +120 -0
- libert-0.1.0/libert/downloader.py +139 -0
- libert-0.1.0/libert/scraper.py +386 -0
- libert-0.1.0/libert.egg-info/PKG-INFO +79 -0
- libert-0.1.0/libert.egg-info/SOURCES.txt +13 -0
- libert-0.1.0/libert.egg-info/dependency_links.txt +1 -0
- libert-0.1.0/libert.egg-info/entry_points.txt +2 -0
- libert-0.1.0/libert.egg-info/requires.txt +3 -0
- libert-0.1.0/libert.egg-info/top_level.txt +1 -0
- libert-0.1.0/pyproject.toml +36 -0
- libert-0.1.0/setup.cfg +4 -0
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,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 @@
|
|
|
1
|
+
|
|
@@ -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