vvr-scraper 1.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.
- vvr_scraper-1.1.0/LICENSE +8 -0
- vvr_scraper-1.1.0/PKG-INFO +127 -0
- vvr_scraper-1.1.0/README.md +104 -0
- vvr_scraper-1.1.0/pyproject.toml +35 -0
- vvr_scraper-1.1.0/setup.cfg +4 -0
- vvr_scraper-1.1.0/tests/test_scraper.py +392 -0
- vvr_scraper-1.1.0/tests/test_session_manager.py +12 -0
- vvr_scraper-1.1.0/tests/test_tao_so_do_cay.py +477 -0
- vvr_scraper-1.1.0/vvr_scraper/__init__.py +0 -0
- vvr_scraper-1.1.0/vvr_scraper/cli.py +462 -0
- vvr_scraper-1.1.0/vvr_scraper/exporter.py +280 -0
- vvr_scraper-1.1.0/vvr_scraper/models.py +65 -0
- vvr_scraper-1.1.0/vvr_scraper/scraper_core.py +203 -0
- vvr_scraper-1.1.0/vvr_scraper/session_manager.py +46 -0
- vvr_scraper-1.1.0/vvr_scraper/tao_so_do_cay.py +207 -0
- vvr_scraper-1.1.0/vvr_scraper/utils.py +136 -0
- vvr_scraper-1.1.0/vvr_scraper.egg-info/PKG-INFO +127 -0
- vvr_scraper-1.1.0/vvr_scraper.egg-info/SOURCES.txt +20 -0
- vvr_scraper-1.1.0/vvr_scraper.egg-info/dependency_links.txt +1 -0
- vvr_scraper-1.1.0/vvr_scraper.egg-info/entry_points.txt +2 -0
- vvr_scraper-1.1.0/vvr_scraper.egg-info/requires.txt +12 -0
- vvr_scraper-1.1.0/vvr_scraper.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
Copyright © 2025 Trần Phan Thanh Tùng
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
5
|
+
|
|
6
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
7
|
+
|
|
8
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vvr-scraper
|
|
3
|
+
Version: 1.1.0
|
|
4
|
+
Summary: Công cụ tải web novel từ Valvrare Team (Asynchronous & Optimized)
|
|
5
|
+
Author: Valvrare Team Crawler Contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.8
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: alive-progress==3.3.0
|
|
11
|
+
Requires-Dist: beautifulsoup4==4.13.4
|
|
12
|
+
Requires-Dist: EbookLib==0.19
|
|
13
|
+
Requires-Dist: httpx>=0.28.0
|
|
14
|
+
Requires-Dist: lxml>=6.0.0
|
|
15
|
+
Requires-Dist: pillow>=11.3.0
|
|
16
|
+
Requires-Dist: playwright>=1.54.0
|
|
17
|
+
Requires-Dist: prompt-toolkit>=3.0.43
|
|
18
|
+
Requires-Dist: reportlab>=4.4.3
|
|
19
|
+
Requires-Dist: simple-term-menu>=1.6.6
|
|
20
|
+
Requires-Dist: loguru>=0.7.2
|
|
21
|
+
Requires-Dist: rich>=13.7.0
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# Web Novel Scraper
|
|
25
|
+
|
|
26
|
+
## Mô tả dự án
|
|
27
|
+
Dự án **Web Novel Scraper** là một công cụ được viết bằng Python để tải và lưu các bộ truyện từ trang web [Valvrare Team](https://valvrareteam.net) dưới dạng file PDF và/hoặc EPUB. Công cụ này sử dụng các thư viện như `playwright`, `BeautifulSoup`, `ebooklib`, và `reportlab` để thu thập nội dung (bao gồm văn bản và hình ảnh minh họa) từ các chương truyện, sau đó tạo file đầu ra theo định dạng người dùng chọn.
|
|
28
|
+
|
|
29
|
+
## Tính năng
|
|
30
|
+
- **Tìm kiếm truyện trực tiếp (Live Search)**: Tìm kiếm truyện ngay trong terminal với gợi ý thời gian thực khi nhập (từ 3 ký tự). Tự động xác định slug chính xác từ tên truyện.
|
|
31
|
+
- **Tải nội dung song song**: Hỗ trợ tải nhiều chương cùng lúc với số lượng tác vụ song song tùy chỉnh.
|
|
32
|
+
- **Hỗ trợ Metadata chuyên sâu (Genres/Tags)**: Tự động trích xuất thể loại và nhúng vào metadata của file EPUB (`DC:subject`), giúp quản lý thư viện (ví dụ: trên Calibre) dễ dàng hơn.
|
|
33
|
+
- **Tiêu đề chương nguyên bản**: Sử dụng tiêu đề thực tế từ website thay vì slug URL, giúp tên file và mục lục rõ ràng, dễ đọc.
|
|
34
|
+
- **Vượt rào cản (Session Capture)**: Hỗ trợ lấy session thủ công qua trình duyệt (non-headless) để vượt qua Cloudflare hoặc truy cập các chương yêu cầu đăng nhập.
|
|
35
|
+
- **Định dạng đầu ra**: Lưu nội dung dưới dạng PDF, EPUB, hoặc các định dạng khác.
|
|
36
|
+
- **Ghi log lỗi**: Lưu danh sách các chương bị lỗi vào file `cac_chuong_da_bo_qua.txt`.
|
|
37
|
+
- **Tự động sắp xếp files**: Tự động tạo và sắp xếp các file chương(chapter) vào các thư mục tập(volume) với tên file đã được chuẩn hóa nhưng vẫn giữ nguyên tiếng Việt.
|
|
38
|
+
|
|
39
|
+
## Yêu cầu cài đặt
|
|
40
|
+
Để chạy dự án, bạn cần cài đặt Python 3.8+ và các thư viện sau:
|
|
41
|
+
|
|
42
|
+
-**Cách 1: Cài đặt thủ công**
|
|
43
|
+
```bash
|
|
44
|
+
pip install -r requirements.txt
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Cài đặt trình duyệt Playwright (chromium-headless-shell):
|
|
48
|
+
```bash
|
|
49
|
+
playwright install chromium-headless-shell
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Font hỗ trợ tiếng Việt (nếu tải PDF):
|
|
53
|
+
- **DejaVuSans** (mặc định): Tự động tải xuống khi cần.
|
|
54
|
+
- **NotoSerif**: Tự động tải xuống khi cần.
|
|
55
|
+
- Nếu muốn dùng font có sẵn, đặt file font (.ttf) vào cùng thư mục với mã nguồn.
|
|
56
|
+
|
|
57
|
+
-**Cách 1: Sử dụng file cài đặt tự động**
|
|
58
|
+
|
|
59
|
+
Chạy file `install.bat` (Windows) hoặc `install.sh` (Linux/macOS) để tự động cài đặt các thư viện cần thiết trong môi trường ảo (venv) và trình duyệt Playwright.
|
|
60
|
+
|
|
61
|
+
-**Cách 2: Sử dụng file setup.sh (Linux/macOS) - Khuyến nghị**
|
|
62
|
+
|
|
63
|
+
File `setup.sh` cung cấp các tính năng nâng cao:
|
|
64
|
+
- Tạo môi trường ảo sử dụng `uv` (nếu có) hoặc `venv` mặc định
|
|
65
|
+
- Cài đặt dependencies từ `requirements.txt`
|
|
66
|
+
- Cài đặt trình duyệt Playwright
|
|
67
|
+
- **Thêm alias `vvrt`** vào shell config (bash/zsh) để chạy scraper từ bất kỳ đâu
|
|
68
|
+
- Hỗ trợ chạy kiểm thử (pytest) sau khi cài đặt
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
chmod +x setup.sh
|
|
72
|
+
./setup.sh
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**Lưu ý:** Alias `vvrt` có thể không hoạt động ngay lập tức. Nếu lệnh `vvrt` không được nhận diện, hãy chạy lệnh `source ~/.bashrc` (bash) hoặc `source ~/.zshrc` (zsh) hoặc khởi động lại terminal.
|
|
76
|
+
|
|
77
|
+
Sau khi cài đặt, bạn có thể chạy scraper bằng lệnh `vvrt` từ bất kỳ thư mục nào.
|
|
78
|
+
## Xử lý Cloudflare và Đăng nhập
|
|
79
|
+
|
|
80
|
+
Valvrare Team đã áp dụng các biện pháp bảo vệ (Cloudflare, JWT tokens). Công cụ này hỗ trợ chế độ **Dynamic Session Capture** để vượt qua các rào cản này:
|
|
81
|
+
|
|
82
|
+
1. **`--login`**: Khi chạy lệnh này, một trình duyệt thực (không ẩn danh) sẽ mở ra. Bạn hãy thực hiện đăng nhập trên trình duyệt đó. Sau khi hoàn tất và thấy nội dung truyện, hãy quay lại terminal và nhấn **Enter**.
|
|
83
|
+
2. **Lưu Session**: Thông tin đăng nhập (cookies, local storage) sẽ được lưu vào file ẩn `.vvr_session.json`. Các lần chạy sau sẽ tự động sử dụng session này mà không cần đăng nhập lại.
|
|
84
|
+
3. **`--refresh-session`**: Sử dụng khi session cũ đã hết hạn và bạn cần đăng nhập lại để lấy session mới.
|
|
85
|
+
4. **`--verbose`**: Hiển thị log chi tiết về các request, bao gồm cookies và headers. Rất hữu ích để kiểm tra xem session có được áp dụng đúng hay không.
|
|
86
|
+
|
|
87
|
+
Ví dụ:
|
|
88
|
+
```bash
|
|
89
|
+
python scraper.py "ten-truyen" --login --verbose
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Cảnh báo:** Không chia sẻ log khi dùng `--verbose` lên mạng hoặc cho người lạ vì nó chứa session token cá nhân của bạn.
|
|
93
|
+
|
|
94
|
+
## Cách sử dụng
|
|
95
|
+
|
|
96
|
+
**Phương pháp 1: Sử dụng Python trực tiếp**
|
|
97
|
+
```bash
|
|
98
|
+
python scraper.py
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
**Phương pháp 2: Sử dụng alias `vvrt` (sau khi chạy `setup.sh`)**
|
|
102
|
+
```bash
|
|
103
|
+
vvrt
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Sau đó làm theo hướng dẫn trong terminal.
|
|
107
|
+
|
|
108
|
+
## Lưu ý
|
|
109
|
+
- Đảm bảo kết nối internet ổn định để tải nội dung và hình ảnh.
|
|
110
|
+
- Một số chương có thể bị bỏ qua nếu gặp lỗi tải (xem file `cac_chuong_da_bo_qua.txt`).
|
|
111
|
+
- Font tiếng Việt sẽ tự động tải xuống khi cần.
|
|
112
|
+
- Tôn trọng quyền tác giả và chỉ sử dụng nội dung tải về cho mục đích cá nhân.
|
|
113
|
+
|
|
114
|
+
## Loi thuong gap
|
|
115
|
+
- After running `setup.py`: can not run `vvrt` command not found -> manualy add alias to your shell config
|
|
116
|
+
```
|
|
117
|
+
# Bash/Zsh
|
|
118
|
+
alias vvrt='path-to-your-python path-to-scraper.py'
|
|
119
|
+
# Other shell
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Giấy phép
|
|
124
|
+
Dự án này được phát hành dưới [Giấy phép MIT](LICENSE). Xem file `LICENSE` để biết thêm chi tiết.
|
|
125
|
+
|
|
126
|
+
## Liên hệ
|
|
127
|
+
Nếu bạn có câu hỏi hoặc góp ý, vui lòng liên hệ qua email: notthanhtung@gmail.com hoặc mở issue trên repository.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# Web Novel Scraper
|
|
2
|
+
|
|
3
|
+
## Mô tả dự án
|
|
4
|
+
Dự án **Web Novel Scraper** là một công cụ được viết bằng Python để tải và lưu các bộ truyện từ trang web [Valvrare Team](https://valvrareteam.net) dưới dạng file PDF và/hoặc EPUB. Công cụ này sử dụng các thư viện như `playwright`, `BeautifulSoup`, `ebooklib`, và `reportlab` để thu thập nội dung (bao gồm văn bản và hình ảnh minh họa) từ các chương truyện, sau đó tạo file đầu ra theo định dạng người dùng chọn.
|
|
5
|
+
|
|
6
|
+
## Tính năng
|
|
7
|
+
- **Tìm kiếm truyện trực tiếp (Live Search)**: Tìm kiếm truyện ngay trong terminal với gợi ý thời gian thực khi nhập (từ 3 ký tự). Tự động xác định slug chính xác từ tên truyện.
|
|
8
|
+
- **Tải nội dung song song**: Hỗ trợ tải nhiều chương cùng lúc với số lượng tác vụ song song tùy chỉnh.
|
|
9
|
+
- **Hỗ trợ Metadata chuyên sâu (Genres/Tags)**: Tự động trích xuất thể loại và nhúng vào metadata của file EPUB (`DC:subject`), giúp quản lý thư viện (ví dụ: trên Calibre) dễ dàng hơn.
|
|
10
|
+
- **Tiêu đề chương nguyên bản**: Sử dụng tiêu đề thực tế từ website thay vì slug URL, giúp tên file và mục lục rõ ràng, dễ đọc.
|
|
11
|
+
- **Vượt rào cản (Session Capture)**: Hỗ trợ lấy session thủ công qua trình duyệt (non-headless) để vượt qua Cloudflare hoặc truy cập các chương yêu cầu đăng nhập.
|
|
12
|
+
- **Định dạng đầu ra**: Lưu nội dung dưới dạng PDF, EPUB, hoặc các định dạng khác.
|
|
13
|
+
- **Ghi log lỗi**: Lưu danh sách các chương bị lỗi vào file `cac_chuong_da_bo_qua.txt`.
|
|
14
|
+
- **Tự động sắp xếp files**: Tự động tạo và sắp xếp các file chương(chapter) vào các thư mục tập(volume) với tên file đã được chuẩn hóa nhưng vẫn giữ nguyên tiếng Việt.
|
|
15
|
+
|
|
16
|
+
## Yêu cầu cài đặt
|
|
17
|
+
Để chạy dự án, bạn cần cài đặt Python 3.8+ và các thư viện sau:
|
|
18
|
+
|
|
19
|
+
-**Cách 1: Cài đặt thủ công**
|
|
20
|
+
```bash
|
|
21
|
+
pip install -r requirements.txt
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Cài đặt trình duyệt Playwright (chromium-headless-shell):
|
|
25
|
+
```bash
|
|
26
|
+
playwright install chromium-headless-shell
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Font hỗ trợ tiếng Việt (nếu tải PDF):
|
|
30
|
+
- **DejaVuSans** (mặc định): Tự động tải xuống khi cần.
|
|
31
|
+
- **NotoSerif**: Tự động tải xuống khi cần.
|
|
32
|
+
- Nếu muốn dùng font có sẵn, đặt file font (.ttf) vào cùng thư mục với mã nguồn.
|
|
33
|
+
|
|
34
|
+
-**Cách 1: Sử dụng file cài đặt tự động**
|
|
35
|
+
|
|
36
|
+
Chạy file `install.bat` (Windows) hoặc `install.sh` (Linux/macOS) để tự động cài đặt các thư viện cần thiết trong môi trường ảo (venv) và trình duyệt Playwright.
|
|
37
|
+
|
|
38
|
+
-**Cách 2: Sử dụng file setup.sh (Linux/macOS) - Khuyến nghị**
|
|
39
|
+
|
|
40
|
+
File `setup.sh` cung cấp các tính năng nâng cao:
|
|
41
|
+
- Tạo môi trường ảo sử dụng `uv` (nếu có) hoặc `venv` mặc định
|
|
42
|
+
- Cài đặt dependencies từ `requirements.txt`
|
|
43
|
+
- Cài đặt trình duyệt Playwright
|
|
44
|
+
- **Thêm alias `vvrt`** vào shell config (bash/zsh) để chạy scraper từ bất kỳ đâu
|
|
45
|
+
- Hỗ trợ chạy kiểm thử (pytest) sau khi cài đặt
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
chmod +x setup.sh
|
|
49
|
+
./setup.sh
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Lưu ý:** Alias `vvrt` có thể không hoạt động ngay lập tức. Nếu lệnh `vvrt` không được nhận diện, hãy chạy lệnh `source ~/.bashrc` (bash) hoặc `source ~/.zshrc` (zsh) hoặc khởi động lại terminal.
|
|
53
|
+
|
|
54
|
+
Sau khi cài đặt, bạn có thể chạy scraper bằng lệnh `vvrt` từ bất kỳ thư mục nào.
|
|
55
|
+
## Xử lý Cloudflare và Đăng nhập
|
|
56
|
+
|
|
57
|
+
Valvrare Team đã áp dụng các biện pháp bảo vệ (Cloudflare, JWT tokens). Công cụ này hỗ trợ chế độ **Dynamic Session Capture** để vượt qua các rào cản này:
|
|
58
|
+
|
|
59
|
+
1. **`--login`**: Khi chạy lệnh này, một trình duyệt thực (không ẩn danh) sẽ mở ra. Bạn hãy thực hiện đăng nhập trên trình duyệt đó. Sau khi hoàn tất và thấy nội dung truyện, hãy quay lại terminal và nhấn **Enter**.
|
|
60
|
+
2. **Lưu Session**: Thông tin đăng nhập (cookies, local storage) sẽ được lưu vào file ẩn `.vvr_session.json`. Các lần chạy sau sẽ tự động sử dụng session này mà không cần đăng nhập lại.
|
|
61
|
+
3. **`--refresh-session`**: Sử dụng khi session cũ đã hết hạn và bạn cần đăng nhập lại để lấy session mới.
|
|
62
|
+
4. **`--verbose`**: Hiển thị log chi tiết về các request, bao gồm cookies và headers. Rất hữu ích để kiểm tra xem session có được áp dụng đúng hay không.
|
|
63
|
+
|
|
64
|
+
Ví dụ:
|
|
65
|
+
```bash
|
|
66
|
+
python scraper.py "ten-truyen" --login --verbose
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Cảnh báo:** Không chia sẻ log khi dùng `--verbose` lên mạng hoặc cho người lạ vì nó chứa session token cá nhân của bạn.
|
|
70
|
+
|
|
71
|
+
## Cách sử dụng
|
|
72
|
+
|
|
73
|
+
**Phương pháp 1: Sử dụng Python trực tiếp**
|
|
74
|
+
```bash
|
|
75
|
+
python scraper.py
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
**Phương pháp 2: Sử dụng alias `vvrt` (sau khi chạy `setup.sh`)**
|
|
79
|
+
```bash
|
|
80
|
+
vvrt
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Sau đó làm theo hướng dẫn trong terminal.
|
|
84
|
+
|
|
85
|
+
## Lưu ý
|
|
86
|
+
- Đảm bảo kết nối internet ổn định để tải nội dung và hình ảnh.
|
|
87
|
+
- Một số chương có thể bị bỏ qua nếu gặp lỗi tải (xem file `cac_chuong_da_bo_qua.txt`).
|
|
88
|
+
- Font tiếng Việt sẽ tự động tải xuống khi cần.
|
|
89
|
+
- Tôn trọng quyền tác giả và chỉ sử dụng nội dung tải về cho mục đích cá nhân.
|
|
90
|
+
|
|
91
|
+
## Loi thuong gap
|
|
92
|
+
- After running `setup.py`: can not run `vvrt` command not found -> manualy add alias to your shell config
|
|
93
|
+
```
|
|
94
|
+
# Bash/Zsh
|
|
95
|
+
alias vvrt='path-to-your-python path-to-scraper.py'
|
|
96
|
+
# Other shell
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Giấy phép
|
|
101
|
+
Dự án này được phát hành dưới [Giấy phép MIT](LICENSE). Xem file `LICENSE` để biết thêm chi tiết.
|
|
102
|
+
|
|
103
|
+
## Liên hệ
|
|
104
|
+
Nếu bạn có câu hỏi hoặc góp ý, vui lòng liên hệ qua email: notthanhtung@gmail.com hoặc mở issue trên repository.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vvr-scraper"
|
|
7
|
+
version = "1.1.0"
|
|
8
|
+
description = "Công cụ tải web novel từ Valvrare Team (Asynchronous & Optimized)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Valvrare Team Crawler Contributors"}
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"alive-progress==3.3.0",
|
|
17
|
+
"beautifulsoup4==4.13.4",
|
|
18
|
+
"EbookLib==0.19",
|
|
19
|
+
"httpx>=0.28.0",
|
|
20
|
+
"lxml>=6.0.0",
|
|
21
|
+
"pillow>=11.3.0",
|
|
22
|
+
"playwright>=1.54.0",
|
|
23
|
+
"prompt-toolkit>=3.0.43",
|
|
24
|
+
"reportlab>=4.4.3",
|
|
25
|
+
"simple-term-menu>=1.6.6",
|
|
26
|
+
"loguru>=0.7.2",
|
|
27
|
+
"rich>=13.7.0"
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
vvrt = "vvr_scraper.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
where = ["."]
|
|
35
|
+
include = ["vvr_scraper*"]
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for scraper.py - Main crawler functionality
|
|
3
|
+
"""
|
|
4
|
+
import pytest
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import json
|
|
9
|
+
import asyncio
|
|
10
|
+
from unittest.mock import AsyncMock, MagicMock, patch, mock_open
|
|
11
|
+
|
|
12
|
+
# Add parent directory to path for imports
|
|
13
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
14
|
+
|
|
15
|
+
from vvr_scraper.utils import sanitize_filename, HEADERS
|
|
16
|
+
from vvr_scraper.exporter import (
|
|
17
|
+
tao_file_epub,
|
|
18
|
+
tao_file_pdf,
|
|
19
|
+
tao_file_html,
|
|
20
|
+
tao_file_md,
|
|
21
|
+
tao_file_txt,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# =============================================================================
|
|
26
|
+
# TESTS FOR sanitize_filename
|
|
27
|
+
# =============================================================================
|
|
28
|
+
|
|
29
|
+
class TestSanitizeFilename:
|
|
30
|
+
"""Tests for the sanitize_filename function"""
|
|
31
|
+
|
|
32
|
+
def test_removes_invalid_chars(self):
|
|
33
|
+
"""Test that invalid filename characters are removed"""
|
|
34
|
+
assert sanitize_filename('test<file>*name?.txt') == 'testfilename.txt'
|
|
35
|
+
assert sanitize_filename('file:name.txt') == 'filename.txt'
|
|
36
|
+
assert sanitize_filename('test"quotes".txt') == 'testquotes.txt'
|
|
37
|
+
assert sanitize_filename('file<angle>.txt') == 'fileangle.txt'
|
|
38
|
+
|
|
39
|
+
def test_removes_backslash_and_pipe(self):
|
|
40
|
+
"""Test removal of backslash and pipe characters"""
|
|
41
|
+
assert sanitize_filename('test\\backslash.txt') == 'testbackslash.txt'
|
|
42
|
+
assert sanitize_filename('test|pipe.txt') == 'testpipe.txt'
|
|
43
|
+
|
|
44
|
+
def test_strips_leading_trailing_spaces_dots(self):
|
|
45
|
+
"""Test that leading/trailing spaces and dots are stripped"""
|
|
46
|
+
assert sanitize_filename(' filename.txt') == 'filename.txt'
|
|
47
|
+
assert sanitize_filename('filename.txt ') == 'filename.txt'
|
|
48
|
+
assert sanitize_filename('.filename.txt') == 'filename.txt'
|
|
49
|
+
assert sanitize_filename('filename.txt.') == 'filename.txt'
|
|
50
|
+
|
|
51
|
+
def test_collapses_multiple_spaces(self):
|
|
52
|
+
"""Test that multiple spaces are collapsed to single space"""
|
|
53
|
+
assert sanitize_filename('multiple spaces') == 'multiple spaces'
|
|
54
|
+
assert sanitize_filename('a b c') == 'a b c'
|
|
55
|
+
|
|
56
|
+
def test_handles_empty_string(self):
|
|
57
|
+
"""Test handling of empty string"""
|
|
58
|
+
assert sanitize_filename('') == ''
|
|
59
|
+
|
|
60
|
+
def test_handles_none(self):
|
|
61
|
+
"""Test handling of None input"""
|
|
62
|
+
assert sanitize_filename(None) == ''
|
|
63
|
+
|
|
64
|
+
def test_handles_vietnamese_characters(self):
|
|
65
|
+
"""Test that Vietnamese characters are preserved"""
|
|
66
|
+
assert sanitize_filename('truyện tranh') == 'truyện tranh'
|
|
67
|
+
assert sanitize_filename('đầy đủ dấu') == 'đầy đủ dấu'
|
|
68
|
+
|
|
69
|
+
def test_complex_filename(self):
|
|
70
|
+
"""Test with a complex realistic filename"""
|
|
71
|
+
input_name = ' Chương 1: Mở Đầu <Phiên Bản Mới>*.txt '
|
|
72
|
+
expected = 'Chương 1 Mở Đầu Phiên Bản Mới.txt'
|
|
73
|
+
assert sanitize_filename(input_name) == expected
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# =============================================================================
|
|
77
|
+
# TESTS FOR EXPORT FUNCTIONS
|
|
78
|
+
# =============================================================================
|
|
79
|
+
|
|
80
|
+
@pytest.mark.asyncio
|
|
81
|
+
class TestTaoFileHtml:
|
|
82
|
+
"""Tests for HTML export function"""
|
|
83
|
+
|
|
84
|
+
async def test_creates_valid_html_file(self, tmp_path):
|
|
85
|
+
"""Test that a valid HTML file is created"""
|
|
86
|
+
content = [
|
|
87
|
+
{'type': 'text', 'data': 'Paragraph 1'},
|
|
88
|
+
{'type': 'text', 'data': 'Paragraph 2'},
|
|
89
|
+
]
|
|
90
|
+
filepath = str(tmp_path / "test.html")
|
|
91
|
+
|
|
92
|
+
await tao_file_html(content, filepath, "Test Chapter")
|
|
93
|
+
|
|
94
|
+
assert os.path.exists(filepath)
|
|
95
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
96
|
+
html = f.read()
|
|
97
|
+
|
|
98
|
+
assert '<!DOCTYPE html>' in html
|
|
99
|
+
assert '<title>Test Chapter</title>' in html
|
|
100
|
+
assert '<p>Paragraph 1</p>' in html
|
|
101
|
+
assert '<p>Paragraph 2</p>' in html
|
|
102
|
+
assert "lang='vi'" in html or 'lang="vi"' in html
|
|
103
|
+
|
|
104
|
+
async def test_html_with_images(self, tmp_path):
|
|
105
|
+
"""Test HTML export with image content"""
|
|
106
|
+
content = [
|
|
107
|
+
{'type': 'text', 'data': 'Text before'},
|
|
108
|
+
{'type': 'image', 'data': 'https://example.com/image.jpg'},
|
|
109
|
+
{'type': 'text', 'data': 'Text after'},
|
|
110
|
+
]
|
|
111
|
+
filepath = str(tmp_path / "test.html")
|
|
112
|
+
|
|
113
|
+
await tao_file_html(content, filepath, "Test Chapter")
|
|
114
|
+
|
|
115
|
+
assert os.path.exists(filepath)
|
|
116
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
117
|
+
html = f.read()
|
|
118
|
+
|
|
119
|
+
assert 'src=\'https://example.com/image.jpg\'' in html or 'src="https://example.com/image.jpg"' in html
|
|
120
|
+
|
|
121
|
+
async def test_html_file_unicode_content(self, tmp_path):
|
|
122
|
+
"""Test HTML export with Vietnamese unicode content"""
|
|
123
|
+
content = [
|
|
124
|
+
{'type': 'text', 'data': 'Đây là nội dung tiếng Việt'},
|
|
125
|
+
{'type': 'text', 'data': 'Chữ ơ, ư, ê đầy đủ'},
|
|
126
|
+
]
|
|
127
|
+
filepath = str(tmp_path / "test.html")
|
|
128
|
+
|
|
129
|
+
await tao_file_html(content, filepath, "Chương Tiếng Việt")
|
|
130
|
+
|
|
131
|
+
assert os.path.exists(filepath)
|
|
132
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
133
|
+
html = f.read()
|
|
134
|
+
|
|
135
|
+
assert 'Đây là nội dung tiếng Việt' in html
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@pytest.mark.asyncio
|
|
139
|
+
class TestTaoFileMd:
|
|
140
|
+
"""Tests for Markdown export function"""
|
|
141
|
+
|
|
142
|
+
async def test_creates_valid_markdown_file(self, tmp_path):
|
|
143
|
+
"""Test that a valid Markdown file is created"""
|
|
144
|
+
content = [
|
|
145
|
+
{'type': 'text', 'data': 'Paragraph 1'},
|
|
146
|
+
{'type': 'text', 'data': 'Paragraph 2'},
|
|
147
|
+
]
|
|
148
|
+
filepath = str(tmp_path / "test.md")
|
|
149
|
+
|
|
150
|
+
await tao_file_md(content, filepath, "Test Chapter")
|
|
151
|
+
|
|
152
|
+
assert os.path.exists(filepath)
|
|
153
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
154
|
+
md = f.read()
|
|
155
|
+
|
|
156
|
+
assert md.startswith('# Test Chapter')
|
|
157
|
+
assert 'Paragraph 1' in md
|
|
158
|
+
assert 'Paragraph 2' in md
|
|
159
|
+
|
|
160
|
+
async def test_markdown_with_images(self, tmp_path):
|
|
161
|
+
"""Test Markdown export with image content"""
|
|
162
|
+
content = [
|
|
163
|
+
{'type': 'text', 'data': 'Text'},
|
|
164
|
+
{'type': 'image', 'data': 'https://example.com/image.jpg'},
|
|
165
|
+
]
|
|
166
|
+
filepath = str(tmp_path / "test.md")
|
|
167
|
+
|
|
168
|
+
await tao_file_md(content, filepath, "Test Chapter")
|
|
169
|
+
|
|
170
|
+
assert os.path.exists(filepath)
|
|
171
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
172
|
+
md = f.read()
|
|
173
|
+
|
|
174
|
+
assert '' in md
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@pytest.mark.asyncio
|
|
178
|
+
class TestTaoFileTxt:
|
|
179
|
+
"""Tests for plain text export function"""
|
|
180
|
+
|
|
181
|
+
async def test_creates_valid_txt_file(self, tmp_path):
|
|
182
|
+
"""Test that a valid text file is created"""
|
|
183
|
+
content = [
|
|
184
|
+
{'type': 'text', 'data': 'Line 1'},
|
|
185
|
+
{'type': 'text', 'data': 'Line 2'},
|
|
186
|
+
]
|
|
187
|
+
filepath = str(tmp_path / "test.txt")
|
|
188
|
+
|
|
189
|
+
await tao_file_txt(content, filepath, "Test Chapter")
|
|
190
|
+
|
|
191
|
+
assert os.path.exists(filepath)
|
|
192
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
193
|
+
txt = f.read()
|
|
194
|
+
|
|
195
|
+
assert txt.startswith('Test Chapter')
|
|
196
|
+
assert 'Line 1' in txt
|
|
197
|
+
assert 'Line 2' in txt
|
|
198
|
+
|
|
199
|
+
async def test_txt_with_images(self, tmp_path):
|
|
200
|
+
"""Test text export with image placeholders"""
|
|
201
|
+
content = [
|
|
202
|
+
{'type': 'text', 'data': 'Text'},
|
|
203
|
+
{'type': 'image', 'data': 'https://example.com/image.jpg'},
|
|
204
|
+
]
|
|
205
|
+
filepath = str(tmp_path / "test.txt")
|
|
206
|
+
|
|
207
|
+
await tao_file_txt(content, filepath, "Test Chapter")
|
|
208
|
+
|
|
209
|
+
assert os.path.exists(filepath)
|
|
210
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
211
|
+
txt = f.read()
|
|
212
|
+
|
|
213
|
+
assert '[Ảnh: https://example.com/image.jpg]' in txt
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@pytest.mark.asyncio
|
|
217
|
+
class TestTaoFileEpub:
|
|
218
|
+
"""Tests for EPUB export function"""
|
|
219
|
+
|
|
220
|
+
async def test_creates_epub_file(self, tmp_path):
|
|
221
|
+
"""Test that an EPUB file is created"""
|
|
222
|
+
chapters_data = [
|
|
223
|
+
{
|
|
224
|
+
'title': 'Chapter 1',
|
|
225
|
+
'content': [
|
|
226
|
+
{'type': 'text', 'data': 'Hello World'},
|
|
227
|
+
]
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
filepath = str(tmp_path / "test.epub")
|
|
231
|
+
|
|
232
|
+
await tao_file_epub(filepath, "Test Book", "Test Author", chapters_data, genres=["Action", "Fantasy"])
|
|
233
|
+
|
|
234
|
+
assert os.path.exists(filepath)
|
|
235
|
+
# EPUB files are ZIP archives, check basic structure
|
|
236
|
+
import zipfile
|
|
237
|
+
with zipfile.ZipFile(filepath, 'r') as zip_ref:
|
|
238
|
+
names = zip_ref.namelist()
|
|
239
|
+
assert any('chap_' in n for n in names)
|
|
240
|
+
assert 'mimetype' in names
|
|
241
|
+
|
|
242
|
+
async def test_epub_with_volume_structure(self, tmp_path):
|
|
243
|
+
"""Test EPUB export with volume/chapter hierarchy"""
|
|
244
|
+
chapters_data = [
|
|
245
|
+
{
|
|
246
|
+
'volume': 'Volume 1',
|
|
247
|
+
'chapters': [
|
|
248
|
+
{
|
|
249
|
+
'title': 'Chapter 1',
|
|
250
|
+
'content': [{'type': 'text', 'data': 'Content'}]
|
|
251
|
+
}
|
|
252
|
+
]
|
|
253
|
+
}
|
|
254
|
+
]
|
|
255
|
+
filepath = str(tmp_path / "test.epub")
|
|
256
|
+
|
|
257
|
+
await tao_file_epub(filepath, "Test Book", "Test Author", chapters_data)
|
|
258
|
+
|
|
259
|
+
assert os.path.exists(filepath)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@pytest.mark.asyncio
|
|
263
|
+
class TestTaoFilePdf:
|
|
264
|
+
"""Tests for PDF export function"""
|
|
265
|
+
|
|
266
|
+
async def test_pdf_creation_basic(self, tmp_path):
|
|
267
|
+
"""Test basic PDF creation"""
|
|
268
|
+
content = [
|
|
269
|
+
{'type': 'text', 'data': 'Test content'},
|
|
270
|
+
]
|
|
271
|
+
filepath = str(tmp_path / "test.pdf")
|
|
272
|
+
|
|
273
|
+
# This test may fail if fonts are not available, which is expected
|
|
274
|
+
await tao_file_pdf(content, filepath, "Test Chapter")
|
|
275
|
+
|
|
276
|
+
# File should be created even if font fallback occurs
|
|
277
|
+
assert os.path.exists(filepath)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# =============================================================================
|
|
281
|
+
# TESTS FOR HEADERS CONFIGURATION
|
|
282
|
+
# =============================================================================
|
|
283
|
+
|
|
284
|
+
class TestHeadersConfiguration:
|
|
285
|
+
"""Tests for HTTP headers configuration"""
|
|
286
|
+
|
|
287
|
+
def test_headers_contains_required_fields(self):
|
|
288
|
+
"""Test that HEADERS contains all required fields"""
|
|
289
|
+
assert 'User-Agent' in HEADERS
|
|
290
|
+
assert 'Accept' in HEADERS
|
|
291
|
+
assert 'Accept-Language' in HEADERS
|
|
292
|
+
|
|
293
|
+
def test_user_agent_format(self):
|
|
294
|
+
"""Test User-Agent string format"""
|
|
295
|
+
assert HEADERS['User-Agent'].startswith('Mozilla/5.0')
|
|
296
|
+
assert 'Chrome' in HEADERS['User-Agent']
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# =============================================================================
|
|
300
|
+
# INTEGRATION TESTS (Mocked)
|
|
301
|
+
# =============================================================================
|
|
302
|
+
|
|
303
|
+
class TestScrapingIntegration:
|
|
304
|
+
"""Integration tests with mocked external dependencies"""
|
|
305
|
+
|
|
306
|
+
@pytest.mark.asyncio
|
|
307
|
+
async def test_lay_thong_tin_truyen_structure(self):
|
|
308
|
+
"""Test story info scraping with mocked response"""
|
|
309
|
+
from vvr_scraper.scraper_core import lay_thong_tin_truyen
|
|
310
|
+
import httpx
|
|
311
|
+
|
|
312
|
+
mock_html = """
|
|
313
|
+
<html>
|
|
314
|
+
<h1 class="rd-novel-title">Test Story</h1>
|
|
315
|
+
<span class="rd-author-name">Author Name</span>
|
|
316
|
+
<div class="rd-description-content">Story description</div>
|
|
317
|
+
<span class="rd-genre-tag">Action</span>
|
|
318
|
+
<span class="rd-genre-tag">Fantasy</span>
|
|
319
|
+
</html>
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
mock_response = MagicMock()
|
|
323
|
+
mock_response.text = mock_html
|
|
324
|
+
mock_response.status_code = 200
|
|
325
|
+
mock_response.raise_for_status = MagicMock()
|
|
326
|
+
|
|
327
|
+
with patch('httpx.AsyncClient') as MockClient:
|
|
328
|
+
instance = MockClient.return_value
|
|
329
|
+
instance.get = AsyncMock(return_value=mock_response)
|
|
330
|
+
instance.__aenter__ = AsyncMock(return_value=instance)
|
|
331
|
+
instance.__aexit__ = AsyncMock(return_value=None)
|
|
332
|
+
|
|
333
|
+
result = await lay_thong_tin_truyen(instance, "test-story")
|
|
334
|
+
|
|
335
|
+
assert result.title == 'Test Story'
|
|
336
|
+
assert result.author == 'Author Name'
|
|
337
|
+
assert result.description == 'Story description'
|
|
338
|
+
assert result.genres == ['Action', 'Fantasy']
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# =============================================================================
|
|
342
|
+
# EDGE CASE TESTS
|
|
343
|
+
# =============================================================================
|
|
344
|
+
|
|
345
|
+
class TestEdgeCases:
|
|
346
|
+
"""Tests for edge cases and error handling"""
|
|
347
|
+
|
|
348
|
+
@pytest.mark.asyncio
|
|
349
|
+
async def test_empty_content_export(self, tmp_path):
|
|
350
|
+
"""Test exporting empty content"""
|
|
351
|
+
content = []
|
|
352
|
+
|
|
353
|
+
# HTML should still create valid file
|
|
354
|
+
filepath = str(tmp_path / "empty.html")
|
|
355
|
+
await tao_file_html(content, filepath, "Empty")
|
|
356
|
+
assert os.path.exists(filepath)
|
|
357
|
+
|
|
358
|
+
def test_very_long_filename(self):
|
|
359
|
+
"""Test sanitization of very long filename"""
|
|
360
|
+
long_name = "a" * 500
|
|
361
|
+
result = sanitize_filename(long_name)
|
|
362
|
+
assert len(result) == len(long_name) # Length preserved after sanitization
|
|
363
|
+
|
|
364
|
+
def test_special_unicode_characters(self):
|
|
365
|
+
"""Test handling of special unicode characters"""
|
|
366
|
+
test_cases = [
|
|
367
|
+
('file\u200bname.txt', 'filename.txt'), # Zero-width space
|
|
368
|
+
('file\u00a0name.txt', 'file\u00a0name.txt'), # Non-breaking space (kept)
|
|
369
|
+
]
|
|
370
|
+
for input_name, expected in test_cases:
|
|
371
|
+
result = sanitize_filename(input_name)
|
|
372
|
+
# Just ensure no exception is raised
|
|
373
|
+
|
|
374
|
+
@pytest.mark.asyncio
|
|
375
|
+
async def test_mixed_content_types(self, tmp_path):
|
|
376
|
+
"""Test export with mixed text and multiple images"""
|
|
377
|
+
content = [
|
|
378
|
+
{'type': 'text', 'data': 'Intro'},
|
|
379
|
+
{'type': 'image', 'data': 'https://example.com/img1.png'},
|
|
380
|
+
{'type': 'text', 'data': 'Middle'},
|
|
381
|
+
{'type': 'image', 'data': 'https://example.com/img2.gif'},
|
|
382
|
+
{'type': 'text', 'data': 'End'},
|
|
383
|
+
]
|
|
384
|
+
|
|
385
|
+
for fmt, func in [
|
|
386
|
+
('html', tao_file_html),
|
|
387
|
+
('md', tao_file_md),
|
|
388
|
+
('txt', tao_file_txt),
|
|
389
|
+
]:
|
|
390
|
+
filepath = str(tmp_path / f"mixed.{fmt}")
|
|
391
|
+
await func(content, filepath, "Test")
|
|
392
|
+
assert os.path.exists(filepath)
|