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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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 '![Minh họa](https://example.com/image.jpg)' 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)