nonebot-plugin-bilinovel 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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AuYaMi
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.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonebot-plugin-bilinovel
3
+ Version: 0.1.0
4
+ Summary: NoneBot2 哔哩轻小说下载爬虫插件
5
+ Author-email: AuYaMi <SIX18318463527@163.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 AuYaMi
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.10
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: nonebot2>=2.5.0
32
+ Requires-Dist: nonebot-adapter-onebot>=2.4.6
33
+ Requires-Dist: aiohttp
34
+ Requires-Dist: lxml
35
+ Requires-Dist: loguru
36
+ Requires-Dist: playwright
37
+ Requires-Dist: ebooklib
38
+ Dynamic: license-file
39
+
40
+ # nonebot-plugin-bilinovel
41
+
42
+ > NoneBot2 哔哩轻小说爬虫插件,搜索小说并下载生成 EPUB 电子书
43
+
44
+ ## 📦 安装
45
+
46
+ ### 方式一:使用 nb‑cli
47
+
48
+
49
+ ```bash
50
+ nb plugin install nonebot-plugin-bilinovel
51
+ ```
52
+
53
+ ### 方式二:pip 安装插件
54
+ ```bash
55
+ pip install nonebot-plugin-bilinovel
56
+ ```
57
+
58
+ ### 安装 Playwright 浏览器内核
59
+
60
+ ```bash
61
+ playwright install chromium
62
+ ```
63
+
64
+ ## 🎯 使用命令
65
+
66
+ ### 参数标记约定
67
+
68
+ - `< >` 必填参数,不可省略
69
+ - `[ ]` 可选参数,可以省略
70
+
71
+ 表格
72
+
73
+ | 指令 | 语法 | 说明 |
74
+ | --- | --- | --- |
75
+ | `/sear` | `/sear <小说名称>` | 搜索哔哩轻小说,返回书本 id |
76
+ | `/down` | `/down <书本id> <卷号> [下载格式]` | 下载小说并生成 EPUB 或 txt |
77
+
78
+ > 搜索示例:`/sear 关于我转生变成史莱姆这档事`
79
+ > `/sear 游戏人生`
80
+ >
81
+ >
82
+ > 下载示例:`/down 9 1 epub` 即下载书籍 id 为 9 的第一卷,导出格式为 epub
83
+ >
84
+ >
85
+ > [下载格式不填默认txt]
86
+
87
+ ## ⚙️ .env自定义参数配置
88
+
89
+
90
+ 可调整参数列表:
91
+
92
+ ```
93
+ # Playwright浏览器无头开关,True:无头 /False:有头
94
+ BROWSER_HEADLESS = True #无头情况下epub模式可能获取不到图片
95
+ # 章节并发数,并发多容易限流
96
+ WORKER_COUNT = 2
97
+ ```
98
+
99
+
100
+ ## ⚠️ 使用须知
101
+
102
+ 1. 本插件仅用于个人学习研究。
103
+ 2. 请勿高频、大规模爬取网站,避免给目标站点造成压力。
104
+ 3. 下载内容版权归原作者与平台所有。
105
+
106
+ ## 📁 文件输出
107
+
108
+ 生成的 EPUB 文件将会保存至插件运行目录下。
109
+
110
+ ## 📝 更新日志
111
+
112
+ ### v0.1.0
113
+
114
+ - 基础小说搜索功能
115
+ - 小说章节错峰并发爬取
116
+ - EPUB 电子书导出
@@ -0,0 +1,77 @@
1
+ # nonebot-plugin-bilinovel
2
+
3
+ > NoneBot2 哔哩轻小说爬虫插件,搜索小说并下载生成 EPUB 电子书
4
+
5
+ ## 📦 安装
6
+
7
+ ### 方式一:使用 nb‑cli
8
+
9
+
10
+ ```bash
11
+ nb plugin install nonebot-plugin-bilinovel
12
+ ```
13
+
14
+ ### 方式二:pip 安装插件
15
+ ```bash
16
+ pip install nonebot-plugin-bilinovel
17
+ ```
18
+
19
+ ### 安装 Playwright 浏览器内核
20
+
21
+ ```bash
22
+ playwright install chromium
23
+ ```
24
+
25
+ ## 🎯 使用命令
26
+
27
+ ### 参数标记约定
28
+
29
+ - `< >` 必填参数,不可省略
30
+ - `[ ]` 可选参数,可以省略
31
+
32
+ 表格
33
+
34
+ | 指令 | 语法 | 说明 |
35
+ | --- | --- | --- |
36
+ | `/sear` | `/sear <小说名称>` | 搜索哔哩轻小说,返回书本 id |
37
+ | `/down` | `/down <书本id> <卷号> [下载格式]` | 下载小说并生成 EPUB 或 txt |
38
+
39
+ > 搜索示例:`/sear 关于我转生变成史莱姆这档事`
40
+ > `/sear 游戏人生`
41
+ >
42
+ >
43
+ > 下载示例:`/down 9 1 epub` 即下载书籍 id 为 9 的第一卷,导出格式为 epub
44
+ >
45
+ >
46
+ > [下载格式不填默认txt]
47
+
48
+ ## ⚙️ .env自定义参数配置
49
+
50
+
51
+ 可调整参数列表:
52
+
53
+ ```
54
+ # Playwright浏览器无头开关,True:无头 /False:有头
55
+ BROWSER_HEADLESS = True #无头情况下epub模式可能获取不到图片
56
+ # 章节并发数,并发多容易限流
57
+ WORKER_COUNT = 2
58
+ ```
59
+
60
+
61
+ ## ⚠️ 使用须知
62
+
63
+ 1. 本插件仅用于个人学习研究。
64
+ 2. 请勿高频、大规模爬取网站,避免给目标站点造成压力。
65
+ 3. 下载内容版权归原作者与平台所有。
66
+
67
+ ## 📁 文件输出
68
+
69
+ 生成的 EPUB 文件将会保存至插件运行目录下。
70
+
71
+ ## 📝 更新日志
72
+
73
+ ### v0.1.0
74
+
75
+ - 基础小说搜索功能
76
+ - 小说章节错峰并发爬取
77
+ - EPUB 电子书导出
@@ -0,0 +1,9 @@
1
+ from nonebot.plugin import PluginMetadata
2
+ from . import novel
3
+
4
+ __plugin_meta__ = PluginMetadata(
5
+ name="哔哩轻小说爬虫",
6
+ description="搜索并下载哔哩轻小说,生成EPUB电子书",
7
+ usage="/sear 小说名",
8
+ supported_adapters={"~onebot.v11"},
9
+ )
@@ -0,0 +1,93 @@
1
+ import asyncio
2
+ import random
3
+ from pathlib import Path
4
+ from nonebot import get_driver
5
+
6
+ driver = get_driver()
7
+
8
+ ROOT = Path(__file__).parent.parent
9
+ env_file_path = ROOT / ".env"
10
+
11
+ # 手动解析 .env 文件,自己读取键值对
12
+ env_config = {}
13
+ if env_file_path.exists():
14
+ with open(env_file_path, "r", encoding="utf‑8") as f:
15
+ for line in f:
16
+ line = line.strip()
17
+ if not line or line.startswith("#"):
18
+ continue
19
+ key, value = line.split("=", 1)
20
+ env_config[key.strip()] = value.strip()
21
+
22
+
23
+ task_queue: asyncio.Queue = asyncio.Queue(maxsize=30)
24
+ END_SENTINEL = None
25
+ OUTPUT_FOLDER = ROOT / "shared_output"
26
+ OUTPUT_FOLDER.mkdir(exist_ok=True)
27
+ BASE_URL = "https://w.linovelib.com"
28
+
29
+
30
+ def get_worker_count() -> int:
31
+ val = env_config.get("WORKER_COUNT", "2")
32
+ return int(val)
33
+
34
+
35
+ def get_browser_headless() -> bool:
36
+ val = env_config.get("BROWSER_HEADLESS", "True").strip().lower()
37
+ return val in ("true", "1", "yes", "on")
38
+
39
+
40
+ def get_temp_json_path() -> Path:
41
+ val = env_config.get("TEMP_JSON", "./output/chap_temp.json")
42
+ return Path(val).resolve()
43
+
44
+
45
+ @driver.on_startup
46
+ def print_config_info():
47
+ w = get_worker_count()
48
+ h = get_browser_headless()
49
+ t = get_temp_json_path()
50
+ print("✅ 插件配置加载完成")
51
+ print(f" BROWSER_HEADLESS = {h}, type:{type(h)}")
52
+ print(f" WORKER_COUNT = {w}")
53
+ print(f" TEMP_JSON_PATH = {t}")
54
+
55
+
56
+
57
+ USER_AGENTS = [
58
+ "Mozilla/5.0 (Linux; U; Android 4.0.2; en-us; Galaxy Nexus Build/ICL53F) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30",
59
+ "Mozilla/5.0 (Linux; U; Android 2.3.6; en-us; Nexus S Build/GRK39F) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1",
60
+ "Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Mobile Safari/537.36",
61
+ "Mozilla/5.0 (Linux; Android 10; Pixel 4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Mobile Safari/537.36",
62
+ "Mozilla/5.0 (Linux; Android 4.3; Nexus 7 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
63
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/91.0.4472.124 Mobile/15E148 Safari/604.1",
64
+ "Mozilla/5.0 (iPad; CPU OS 13_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/91.0.4472.124 Mobile/15E148 Safari/604.1",
65
+ "Mozilla/5.0 (Android 4.4; Mobile; rv:70.0) Gecko/70.0 Firefox/70.0",
66
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4",
67
+ "Mozilla/5.0 (iPad; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4",
68
+ "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)",
69
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 12_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.1.1 EdgiOS/44.5.0.10 Mobile/15E148 Safari/604.1",
70
+ "Mozilla/5.0 (iPad; CPU OS 12_3_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 EdgiOS/44.5.2 Mobile/15E148 Safari/604.1",
71
+ "Mozilla/5.0 (Linux; Android 8.1.0; Pixel Build/OPM4.171019.021.D1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.109 Mobile Safari/537.36 EdgA/42.0.0.2057",
72
+ "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 7 Build/MOB30X) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.109 Safari/537.36 EdgA/42.0.0.2057",
73
+ "Opera/12.02 (Android 4.1; Linux; Opera Mobi/ADR-1111101157; U; en-US) Presto/2.9.201 Version/12.02",
74
+ "Opera/9.80 (iPhone; Opera Mini/8.0.0/34.2336; U; en) Presto/2.8.119 Version/11.10",
75
+ "Mozilla/5.0 (iPad; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
76
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1",
77
+ "Mozilla/5.0 (Linux; U; Android 8.1.0; en-US; Nexus 6P Build/OPM7.181205.001) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.11.1.1197 Mobile Safari/537.36",
78
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X; zh-CN) AppleWebKit/537.51.1 (KHTML, like Gecko) Mobile/16B92 UCBrowser/12.1.7.1109 Mobile AliApp(TUnionSDK/0.1.20.3)",
79
+ ]
80
+
81
+ COOKIE_STR = "_ga=GA1.2.373713668.1646927652; _gid=GA1.2.1447053390.1651231171; Hm_lpvt_d29ecd95ff28d58324c09b9dc0bee919=1651231349; Hm_lvt_d29ecd95ff28d58324c09b9dc0bee919=1649823562,1651231165; jieqiUserInfo=jieqiUserId%3D627182%2CjieqiUserUname%3Dfangxx3863%2CjieqiUserName%3Dfangxx3863%2CjieqiUserGroup%3D3%2CjieqiUserGroupName%3D%E6%99%AE%E9%80%9A%E4%BC%9A%E5%91%98%2CjieqiUserVip%3D0%2CjieqiUserHonorId%3D1%2CjieqiUserHonor%3D%E5%A4%A9%E7%84%B6%2CjieqiUserToken%3D8ea5ef793d94938673124b15cb3a7102%2CjieqiCodeLogin%3D0%2CjieqiCodePost%3D0%2CjieqiUserPassword%3D5c82b131f01843ca05e751717d74a992%2CjieqiUserLogin%3D1651231169; jieqiVisitId=article_articleviews%3D2939; jieqiVisitInfo=jieqiUserLogin%3D1651231169%2CjieqiUserId%3D627182; night=0; PHPSESSID=bsdrsrdj916v5etol006ji2odl"
82
+
83
+ def get_random_headers() -> dict:
84
+ """每次调用生成一份全新随机UA的请求头"""
85
+ random_ua = random.choice(USER_AGENTS)
86
+ headers = {
87
+ "Cookie": COOKIE_STR,
88
+ "Referer": BASE_URL + "/",
89
+ "User-Agent": random_ua,
90
+ "Accept-Language": "zh-CN,zh;q=0.9",
91
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
92
+ }
93
+ return headers
@@ -0,0 +1,524 @@
1
+ import aiohttp
2
+ import asyncio
3
+ import random
4
+ import json
5
+ import base64
6
+ import re
7
+ from lxml import etree
8
+ from .search import getbook_html
9
+ from . import config
10
+ from playwright.async_api import async_playwright
11
+ from loguru import logger
12
+ from urllib.parse import urljoin
13
+ from ebooklib import epub
14
+
15
+
16
+ async def get_url(session, url):
17
+ async with session.get(url) as resp:
18
+ return await resp.text(encoding='utf-8')
19
+
20
+
21
+ async def get_filtered_text(page, url, load_image: bool = False):
22
+ await page.goto(
23
+ url,
24
+ timeout=30000,
25
+ wait_until="domcontentloaded"
26
+ )
27
+ await page.wait_for_timeout(3000)
28
+ page_html = await page.content()
29
+ if "Sorry, you have been blocked" in page_html:
30
+ logger.error("❌ 当前IP触发Cloudflare封禁!任务终止")
31
+ raise Exception("CLOUDFLARE_BLOCKED")
32
+ await page.wait_for_selector('//div[@class="TextContent"]', timeout=25000, state="visible")
33
+ # 删除解锁广告弹窗,两边模式都需要
34
+ await page.evaluate("""
35
+ const ad = document.querySelector('div.fc-monetization-dialog.fc-dialog');
36
+ if(ad){
37
+ ad.remove();
38
+ }
39
+ """)
40
+ # 只有EPUB插图模式才执行滚动+延时
41
+ if load_image:
42
+ await page.evaluate("window.scrollTo({top: document.body.scrollHeight, behavior:'instant'})")
43
+ await page.wait_for_timeout(1000)
44
+ # ========== 正文提取,共用同一段JS,无重复 ==========
45
+ res = await page.locator('div[class="TextContent"]').evaluate(r"""
46
+ root => {
47
+ const raw = root.innerText.trim();
48
+ const hasText = raw !== "";
49
+ if(hasText === false){
50
+ return {hasText:false, content:"", img_list:[]};
51
+ }
52
+ const children = Array.from(root.querySelectorAll(':scope > *'));
53
+ const visibleNodes = children.filter(el=>{
54
+ const rect = el.getBoundingClientRect();
55
+ if(rect.width <= 0 || rect.height <= 0) return false;
56
+ const style = window.getComputedStyle(el);
57
+ if(style.display === 'none') return false;
58
+ return true;
59
+ })
60
+ let textBuilder = [];
61
+ let imgSrcList = [];
62
+ for(const elem of visibleNodes){
63
+ if(elem.tagName === 'P'){
64
+ const paraText = elem.innerText.trim();
65
+ if(paraText) textBuilder.push(paraText);
66
+ }
67
+ else if(elem.tagName === 'IMG'){
68
+ if(elem.closest('center ruby')) continue;
69
+ const realSrc = elem.dataset.src || elem.src;
70
+ if(realSrc){
71
+ textBuilder.push(`[插图:${imgSrcList.length}]`);
72
+ imgSrcList.push(realSrc);
73
+ }
74
+ }
75
+ }
76
+ const cleanText = textBuilder.join("\n\n");
77
+ return {
78
+ hasText:true,
79
+ content:cleanText,
80
+ img_list: imgSrcList
81
+ };
82
+ }
83
+ """)
84
+ logger.info(f"页面正文检测结果 hasText: {res['hasText']}, 插图数量:{len(res['img_list'])}")
85
+ return {
86
+ "hasText": res["hasText"],
87
+ "content": res["content"],
88
+ "img_url_list": res["img_list"]
89
+ }
90
+
91
+
92
+ # ---------------- TXT抓取分支 ----------------
93
+ async def crawl_chapter_txt(page, first_page_url):
94
+ base_url, ext = first_page_url.rsplit(".", 1)
95
+ full_text = ""
96
+ page_index = 2
97
+ MAX_SAFE_PAGE = 25
98
+ IMG_NOTICE_MAX_LEN = 60
99
+ ban_keywords = ["插图"]
100
+ res = await get_filtered_text(page, first_page_url, load_image=False)
101
+ if res["hasText"]:
102
+ full_text += res["content"] + "\n\n\n"
103
+ while page_index <= MAX_SAFE_PAGE:
104
+ current_url = f"{base_url}_{page_index}.{ext}"
105
+ try:
106
+ res = await get_filtered_text(page, current_url, load_image=False)
107
+ except Exception:
108
+ break
109
+ if not res["hasText"]:
110
+ break
111
+ h1_loc = page.locator('//div[@id="mlfy_main_text"]/h1')
112
+ if await h1_loc.count() > 0:
113
+ h1_text = await h1_loc.inner_text()
114
+ if "插图(3/1)" in h1_text or "插图(3/1)" in h1_text:
115
+ logger.info("✅txt模式命中插图分页h1标题,结束翻页")
116
+ break
117
+ page_content = res["content"].strip()
118
+ has_img_tag = any(word in page_content for word in ban_keywords)
119
+ is_short_text = len(page_content) < IMG_NOTICE_MAX_LEN
120
+ if has_img_tag and is_short_text:
121
+ logger.info("✅txt模式命中短插图提示文本,结束翻页")
122
+ break
123
+ full_text += page_content + "\n\n\n"
124
+ page_index += 1
125
+ return full_text
126
+
127
+
128
+ # ---------------- EPUB抓取分支----------------
129
+ async def crawl_chapter_epub(page, first_page_url, load_image=True):
130
+ base_url, ext = first_page_url.rsplit(".", 1)
131
+ full_text = ""
132
+ all_img_base64 = []
133
+ page_index = 2
134
+ MAX_SAFE_PAGE = 25
135
+ res = await get_filtered_text(page, first_page_url, load_image=True)
136
+ if res["hasText"]:
137
+ full_text += res["content"] + "\n\n\n"
138
+ img_locators = page.locator('//div[@class="TextContent"]/img[not(ancestor::center/ruby)]')
139
+ img_count = await img_locators.count()
140
+ for i in range(img_count):
141
+ loc = img_locators.nth(i)
142
+ try:
143
+ await loc.scroll_into_view_if_needed(timeout=8000)
144
+ await page.wait_for_timeout(2000)
145
+ pic_bytes = await loc.screenshot()
146
+ b64_str = base64.b64encode(pic_bytes).decode("utf‑8")
147
+ all_img_base64.append(b64_str)
148
+ except Exception as e:
149
+ logger.warning(f"截图插图{i}失败:{e}")
150
+ all_img_base64.append(None)
151
+ while page_index <= MAX_SAFE_PAGE:
152
+ current_url = f"{base_url}_{page_index}.{ext}"
153
+ try:
154
+ res = await get_filtered_text(page, current_url)
155
+ except Exception:
156
+ break
157
+ if not res["hasText"]:
158
+ break
159
+ h1_loc = page.locator('//div[@id="mlfy_main_text"]/h1')
160
+ if await h1_loc.count() > 0:
161
+ h1_text = await h1_loc.inner_text()
162
+ if "插图(3/1)" in h1_text or "插图(3/1)" in h1_text:
163
+ logger.info("✅h1标题命中插图分页,结束翻页")
164
+ break
165
+ full_text += res["content"] + "\n\n\n"
166
+ img_locators = page.locator('//div[@class="TextContent"]/img[not(ancestor::center/ruby)]')
167
+ img_count = await img_locators.count()
168
+ for i in range(img_count):
169
+ loc = img_locators.nth(i)
170
+ try:
171
+ await loc.scroll_into_view_if_needed(timeout=8000)
172
+ await page.wait_for_timeout(600)
173
+ pic_bytes = await loc.screenshot()
174
+ b64_str = base64.b64encode(pic_bytes).decode("utf‑8")
175
+ all_img_base64.append(b64_str)
176
+ except Exception as e:
177
+ logger.warning(f"分页截图插图{i}失败:{e}")
178
+ all_img_base64.append(None)
179
+ page_index += 1
180
+ return {"content": full_text, "img_base64_list": all_img_base64}
181
+
182
+
183
+ async def download_one_chapter_txt(page, chap_url, retry_times=3):
184
+ for attempt in range(retry_times):
185
+ try:
186
+ return await crawl_chapter_txt(page, chap_url)
187
+ except Exception as e:
188
+ logger.warning(f"章节尝试 {attempt+1}/{retry_times} 失败: {e}")
189
+ await asyncio.sleep(2)
190
+ raise Exception("多次重试下载失败")
191
+
192
+
193
+ async def download_one_chapter_epub(page, chap_url, retry_times=3):
194
+ for attempt in range(retry_times):
195
+ try:
196
+ return await crawl_chapter_epub(page, chap_url)
197
+ except Exception as e:
198
+ logger.warning(f"章节尝试 {attempt+1}/{retry_times} 失败: {e}")
199
+ await asyncio.sleep(2)
200
+ raise Exception("多次重试下载失败")
201
+
202
+
203
+ async def volume_get(session, url):
204
+ response = await get_url(session, url)
205
+ e = etree.HTML(response)
206
+ vol_name = e.xpath('//div/ul[@class="module-content"]/li/a/span/text()')
207
+ vol_url = e.xpath('//div/ul[@class="module-content"]/li/a/@href')
208
+ return vol_name, vol_url
209
+
210
+
211
+ # ---------------- Producer(两个模式共用) ----------------
212
+ async def producer(book_id: int, vol_number: int, session: aiohttp.ClientSession):
213
+ base_domain = "https://www.bilinovel.com"
214
+ vol_name_list, book_htmls = await getbook_html(book_id, session)
215
+ if not vol_name_list or not book_htmls:
216
+ logger.error("未获取到任何分卷列表")
217
+ await config.task_queue.put(config.END_SENTINEL)
218
+ return
219
+ vol_index = vol_number - 1
220
+ if vol_index < 0 or vol_index >= len(book_htmls):
221
+ logger.error(f"卷号 {vol_number} 越界")
222
+ await config.task_queue.put(config.END_SENTINEL)
223
+ return
224
+ target_volume_rel_url = book_htmls[vol_index]
225
+ target_volume_full_url = urljoin(base_domain, target_volume_rel_url)
226
+ chap_name_list, chap_rel_list = await volume_get(session, target_volume_full_url)
227
+ if not chap_name_list or not chap_rel_list:
228
+ logger.error("未解析出章节")
229
+ await config.task_queue.put(config.END_SENTINEL)
230
+ return
231
+ for idx, (chap_title, chap_href) in enumerate(zip(chap_name_list, chap_rel_list)):
232
+ full_url = urljoin(base_domain, chap_href)
233
+ await config.task_queue.put((idx, chap_title, full_url))
234
+ logger.info(f"加入队列: {chap_title}")
235
+ # 发送哨兵
236
+ worker_num = config.get_worker_count()
237
+ for _ in range(worker_num):
238
+ await config.task_queue.put(config.END_SENTINEL)
239
+ logger.info("✅全部章节任务获取完成")
240
+
241
+
242
+ # ---------------- Worker‑TXT版:直接写入txt ----------------
243
+ async def worker_txt(worker_id: int, save_path: str, file_lock: asyncio.Lock):
244
+ init_delay = worker_id * 5.0
245
+ logger.info(f"工人{worker_id},等待 {init_delay}s 后开始工作")
246
+ await asyncio.sleep(init_delay)
247
+ async with async_playwright() as p:
248
+ headless_flag = config.get_browser_headless()
249
+ logger.info(f"[Worker调试] headless_flag = {headless_flag}, type:{type(headless_flag)}")
250
+ if headless_flag:
251
+ launch_args = {
252
+ "headless": True,
253
+ "args": ["--no-sandbox", "--disable-gpu"]
254
+ }
255
+ else:
256
+ launch_args = {
257
+ "headless": False,
258
+ "channel": "chrome",
259
+ "args": ["--start-maximized"]
260
+ }
261
+ browser = await p.chromium.launch(**launch_args)
262
+
263
+ context = await browser.new_context(
264
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
265
+ viewport={"width": 1280, "height": 720}
266
+ )
267
+ while True:
268
+ job = await config.task_queue.get()
269
+ if job is config.END_SENTINEL:
270
+ break
271
+ _, chap_title, chap_full_url = job
272
+ logger.info(f"[工人{worker_id}]开始下载:{chap_title}")
273
+ page = await context.new_page()
274
+ await page.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico}", lambda r: r.abort())
275
+ await page.route("**/*.css", lambda r: r.abort())
276
+ try:
277
+ chapter_text = await download_one_chapter_txt(page, chap_full_url)
278
+ if chapter_text.strip():
279
+ async with file_lock:
280
+ with open(save_path, "a", encoding="utf-8") as f:
281
+ f.write(f"\n---------- {chap_title} ----------\n{chapter_text}")
282
+ f.flush()
283
+ logger.info(f"{chap_title} → 完成")
284
+ else:
285
+ logger.info(f"{chap_title} → 插图占位,跳过写入")
286
+ except Exception as err:
287
+ logger.error(f"章节 {chap_title} 下载失败跳过:{err}")
288
+ finally:
289
+ await page.close()
290
+ await asyncio.sleep(random.uniform(2.0, 3.0))
291
+ await browser.close()
292
+ logger.info(f"✅工人{worker_id}退出")
293
+
294
+
295
+ # ---------------- Worker‑EPUB版:写入临时json ----------------
296
+ async def worker_epub(worker_id: int, file_lock: asyncio.Lock):
297
+ init_delay = worker_id * 4.0
298
+ logger.info(f"工人{worker_id},等待 {init_delay}s 后启动")
299
+ await asyncio.sleep(init_delay)
300
+ async with async_playwright() as p:
301
+ headless_flag = config.get_browser_headless()
302
+ logger.info(f"[Worker调试] headless_flag = {headless_flag}, type:{type(headless_flag)}")
303
+ if headless_flag:
304
+ launch_args = {
305
+ "headless": True,
306
+ "args": ["--no-sandbox", "--disable-gpu"]
307
+ }
308
+ else:
309
+ launch_args = {
310
+ "headless": False,
311
+ "channel": "chrome",
312
+ "args": ["--start-maximized"]
313
+ }
314
+
315
+
316
+ browser = await p.chromium.launch(**launch_args)
317
+ context = await browser.new_context(
318
+ user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
319
+ viewport={"width": 1280, "height": 720}
320
+ )
321
+ temp_json_path = config.get_temp_json_path()
322
+ while True:
323
+ job = await config.task_queue.get()
324
+ if job is config.END_SENTINEL:
325
+ break
326
+ chap_idx, chap_title, chap_full_url = job
327
+ logger.info(f"[工人{worker_id}]开始下载:{chap_title}")
328
+ page = await context.new_page()
329
+ await page.route("**/*.css", lambda r: r.abort())
330
+ try:
331
+ chapter_result = await download_one_chapter_epub(page, chap_full_url)
332
+ async with file_lock:
333
+ line = json.dumps({
334
+ "idx": chap_idx,
335
+ "title": chap_title,
336
+ "text": chapter_result["content"],
337
+ "img_base64_list": chapter_result["img_base64_list"]
338
+ }, ensure_ascii=False)
339
+ with open(temp_json_path, "a", encoding="utf‑8") as f:
340
+ f.write(line + "\n")
341
+ logger.info(f"{chap_title} → 完成")
342
+ except Exception as err:
343
+ logger.error(f"章节 {chap_title} 下载失败跳过:{err}")
344
+ finally:
345
+ await page.close()
346
+ await asyncio.sleep(random.uniform(2, 3))
347
+ await browser.close()
348
+ logger.info(f"✅工人{worker_id}浏览器退出")
349
+
350
+
351
+ # ---------------- 导出工具函数 ----------------
352
+ def export_sorted_txt(json_path: str, out_path: str, volume_name: str):
353
+ chapters = []
354
+ with open(json_path, "r", encoding="utf‑8") as f:
355
+ for line in f:
356
+ line = line.strip()
357
+ if not line:
358
+ continue
359
+ chapters.append(json.loads(line))
360
+ chapters.sort(key=lambda x: x["idx"])
361
+ with open(out_path, "w", encoding="utf-8") as f:
362
+ f.write(f"==================== {volume_name} ====================\n")
363
+ for ch in chapters:
364
+ f.write(f"\n---------- {ch['title']} ----------\n{ch['text']}")
365
+ logger.info(f"✅排序导出完成,输出文件:{out_path}")
366
+
367
+
368
+ def build_epub_from_json(json_path: str, epub_output: str, book_title: str):
369
+ chapters = []
370
+ img_assets = dict()
371
+ with open(json_path, "r", encoding="utf‑8") as f:
372
+ for line in f:
373
+ line = line.strip()
374
+ if not line:
375
+ continue
376
+ data = json.loads(line)
377
+ chapters.append(data)
378
+ chapters.sort(key=lambda x: x["idx"])
379
+ # 插图错位校验
380
+ for chap in chapters:
381
+ text = chap["text"]
382
+ img_arr = chap["img_base64_list"]
383
+ mark_list = re.findall(r'\[插图:(\d+)\]', text)
384
+ if len(mark_list) == 0:
385
+ continue
386
+ max_index = max(int(x) for x in mark_list)
387
+ if max_index >= len(img_arr):
388
+ logger.warning(f"⚠️插图错位警告【{chap['title']}】文本标记最大下标:{max_index},截图图片总数:{len(img_arr)}")
389
+ book = epub.EpubBook()
390
+ book.set_identifier("novel_book")
391
+ book.set_title(book_title)
392
+ book.set_language("zh‑CN")
393
+ epub_chapter_items = []
394
+ for chap in chapters:
395
+ chap_title = chap["title"]
396
+ raw_text = chap["text"]
397
+ img_b64_arr = chap["img_base64_list"]
398
+ for img_idx, b64_str in enumerate(img_b64_arr):
399
+ placeholder = f"[插图:{img_idx}]"
400
+ if placeholder not in raw_text:
401
+ continue
402
+ if b64_str is None:
403
+ raw_text = raw_text.replace(placeholder, "\n【插图加载失败】\n")
404
+ continue
405
+ asset_id = f"img_{chap['idx']}_{img_idx}"
406
+ if asset_id not in img_assets:
407
+ img_bytes = base64.b64decode(b64_str)
408
+ image_item = epub.EpubImage(
409
+ uid=asset_id,
410
+ file_name=f"images/{asset_id}.jpg",
411
+ media_type="image/jpeg",
412
+ content=img_bytes
413
+ )
414
+ book.add_item(image_item)
415
+ img_assets[asset_id] = True
416
+ img_html_tag = f'<div style="text-align:center"><img src="images/{asset_id}.jpg" alt="插图{img_idx}" /></div>'
417
+ raw_text = raw_text.replace(placeholder, img_html_tag)
418
+ html_body = raw_text.replace("\n\n", "<br/><br/>")
419
+ c = epub.EpubHtml(
420
+ title=chap_title,
421
+ file_name=f"chap_{chap['idx']}.xhtml",
422
+ lang="zh‑CN",
423
+ content=f"<h2>{chap_title}</h2><div>{html_body}</div>"
424
+ )
425
+ book.add_item(c)
426
+ epub_chapter_items.append(c)
427
+ book.toc = epub_chapter_items
428
+ book.add_item(epub.EpubNcx())
429
+ book.add_item(epub.EpubNav())
430
+ book.spine = ["nav"] + epub_chapter_items
431
+ epub.write_epub(epub_output, book, {})
432
+ logger.info(f"🎉 EPUB打包完成!输出文件: {epub_output}")
433
+
434
+
435
+ # ---------------- 对外统一入口函数(模式选择) ----------------
436
+ from pathlib import Path
437
+ import asyncio
438
+
439
+
440
+ async def download_volume(book_id: int,
441
+ vol_number: int,
442
+ out_txt_path: str = "",
443
+ mode: str = "txt"):
444
+ """
445
+ :param book_id: 书籍id
446
+ :param vol_number: 卷号
447
+ :param out_txt_path: 备用输出路径(当前未使用)
448
+ :param mode: "txt" =仅生成文本,上传txt; "epub"=生成txt+epub,仅上传epub
449
+ :return: list[str] 文件路径列表
450
+ """
451
+ async with aiohttp.ClientSession() as session:
452
+ vol_name_list, book_htmls = await getbook_html(book_id, session)
453
+ vol_index = vol_number - 1
454
+ target_volume_name = vol_name_list[vol_index]
455
+
456
+ txt_file = config.OUTPUT_FOLDER / f"{target_volume_name}.txt"
457
+ epub_file = config.OUTPUT_FOLDER / f"{target_volume_name}.epub"
458
+ temp_json_path = config.get_temp_json_path()
459
+
460
+ result_files = []
461
+ if mode == "txt":
462
+ # txt缓存:只要txt存在就命中
463
+ if txt_file.exists():
464
+ logger.info(f"✅缓存命中!已检测到文件,跳过下载:{txt_file}")
465
+ result_files.append(str(txt_file))
466
+ return result_files
467
+ elif mode == "epub":
468
+ # epub缓存:只要epub存在就算命中,忽略txt
469
+ if epub_file.exists():
470
+ logger.info(f"✅缓存命中!检测到epub,跳过下载,直接上传epub")
471
+ result_files.append(str(epub_file))
472
+ return result_files
473
+
474
+ # ============ 缓存未命中,开始爬虫下载 ============
475
+ file_lock = asyncio.Lock()
476
+ worker_tasks = []
477
+ worker_num = config.get_worker_count()
478
+
479
+ if mode == "txt":
480
+ with open(txt_file, "w", encoding="utf-8") as f:
481
+ f.write(f"==================== {target_volume_name} ====================\n")
482
+ for wid in range(worker_num):
483
+ t = asyncio.create_task(worker_txt(wid, str(txt_file), file_lock))
484
+ worker_tasks.append(t)
485
+ async with aiohttp.ClientSession() as session:
486
+ await asyncio.gather(
487
+ producer(book_id, vol_number, session),
488
+ *worker_tasks
489
+ )
490
+ logger.info(f"✅【TXT模式】卷 {vol_number}【{target_volume_name}】下载完毕")
491
+ result_files.append(str(txt_file))
492
+
493
+ elif mode == "epub":
494
+ with open(temp_json_path, "w", encoding="utf‑8") as f:
495
+ pass
496
+ for wid in range(worker_num):
497
+ t = asyncio.create_task(worker_epub(wid, file_lock))
498
+ worker_tasks.append(t)
499
+ async with aiohttp.ClientSession() as session:
500
+ await asyncio.gather(
501
+ producer(book_id, vol_number, session),
502
+ *worker_tasks
503
+ )
504
+ export_sorted_txt(str(temp_json_path), str(txt_file), target_volume_name)
505
+ epub_name = str(epub_file)
506
+ build_epub_from_json(str(temp_json_path), epub_name, target_volume_name)
507
+ logger.info(f"✅【EPUB模式】全部任务结束!生成txt + epub")
508
+ result_files.append(epub_name)
509
+ else:
510
+ raise ValueError('mode只能为 "txt" 或 "epub"')
511
+ logger.info(result_files)
512
+ return result_files
513
+
514
+
515
+ # if __name__ == "__main__":
516
+ # import asyncio
517
+ # async def test():
518
+ # # 修改这里为你要测试的书籍ID、卷号
519
+ # await download_volume(
520
+ # book_id=9,
521
+ # vol_number=4,
522
+ # mode="epub"
523
+ # )
524
+ # asyncio.run(test())
@@ -0,0 +1,118 @@
1
+ from nonebot import on_command
2
+ from nonebot.params import CommandArg
3
+ from nonebot.adapters.onebot.v11 import Bot, MessageEvent, Message, GroupMessageEvent, PrivateMessageEvent
4
+ import asyncio
5
+ from .config import*
6
+ from .download import download_volume
7
+ from .search import search_book
8
+
9
+ # 全局信号量:同一时刻只允许1个下载任务
10
+ GLOBAL_SEM = asyncio.Semaphore(1)
11
+
12
+
13
+ async def send_notice(bot: Bot, target_id: int, is_group: bool, text: str):
14
+ """统一发送群聊/私聊消息工具函数"""
15
+ if is_group:
16
+ await bot.send_group_msg(group_id=target_id, message=text)
17
+ else:
18
+ await bot.send_private_msg(user_id=target_id, message=text)
19
+
20
+
21
+ down = on_command("down", priority=5, block=True)
22
+
23
+
24
+ @down.handle()
25
+ async def down_handler(bot: Bot, event: MessageEvent, arg: Message = CommandArg()):
26
+ raw_text = arg.extract_plain_text().strip()
27
+ if not raw_text:
28
+ await down.finish("用法:\n/down 书籍ID 卷号\n例:/down 123 1\n/down 123 1 epub")
29
+
30
+ parts = raw_text.split()
31
+ if len(parts) < 2:
32
+ await down.finish("参数不足!格式:/down 书籍ID 卷号")
33
+
34
+ try:
35
+ book_id = int(parts[0])
36
+ vol = int(parts[1])
37
+ run_mode = parts[2] if len(parts) >= 3 else "txt"
38
+ except ValueError:
39
+ await down.finish("❌书籍ID、卷号必须为数字")
40
+
41
+ await down.send(f"📥开始下载 {book_id} 第{vol}卷,后台执行...")
42
+
43
+ # 提前提取信息,后台任务不再依赖event对象
44
+ if isinstance(event, GroupMessageEvent):
45
+ is_group = True
46
+ target_id = event.group_id
47
+ elif isinstance(event, PrivateMessageEvent):
48
+ is_group = False
49
+ target_id = event.user_id
50
+ else:
51
+ await down.finish("不支持该场景")
52
+ return
53
+
54
+ async def background_task(bot: Bot, tid: int, is_grp: bool, bid: int, v: int, mode: str):
55
+ # 检测锁是否被占用,发送排队提示
56
+ if GLOBAL_SEM.locked():
57
+ await send_notice(bot, tid, is_grp, "⏳当前已有下载任务正在运行,你的任务进入排队队列,请耐心等待...")
58
+
59
+ async with GLOBAL_SEM:
60
+ try:
61
+ file_list = await download_volume(
62
+ book_id=bid,
63
+ vol_number=v,
64
+ mode=mode
65
+ )
66
+ for file_path in file_list:
67
+ filename = Path(file_path).name
68
+ if is_grp:
69
+ await bot.call_api(
70
+ "upload_group_file",
71
+ group_id=tid,
72
+ file=file_path,
73
+ name=filename
74
+ )
75
+ else:
76
+ await bot.call_api(
77
+ "upload_private_file",
78
+ user_id=tid,
79
+ file=file_path,
80
+ name=filename
81
+ )
82
+ await send_notice(bot, tid, is_grp, "✅下载任务执行完成,文件已上传至文件面板")
83
+
84
+ except Exception as e:
85
+ import traceback
86
+ traceback.print_exc()
87
+ await send_notice(bot, tid, is_grp, f"❌下载失败:{str(e)}")
88
+
89
+ asyncio.create_task(background_task(bot, target_id, is_group, book_id, vol, run_mode))
90
+
91
+
92
+ sear = on_command("sear", priority=4, block=True)
93
+
94
+
95
+ @sear.handle()
96
+ async def sear_handler(bot: Bot, event: MessageEvent, arg: Message = CommandArg()):
97
+ keyword = arg.extract_plain_text().strip()
98
+ if not keyword:
99
+ await sear.finish("🔍搜索用法:\n/sear 小说名字\n示例:/sear 刀剑神域")
100
+ try:
101
+ result = await search_book(keyword)
102
+ reply_lines = ["————搜索结果————"]
103
+ if isinstance(result, str):
104
+ reply_lines.append(result)
105
+ elif len(result) == 2:
106
+ name, bid = result
107
+ reply_lines.append(f"1. {name}|ID:{bid}")
108
+ elif len(result) == 3:
109
+ names, urls, ids = result
110
+ for idx, (name, bid) in enumerate(zip(names, ids), start=1):
111
+ reply_lines.append(f"{idx}. {name}|ID:{bid}")
112
+ reply_msg = "\n".join(reply_lines)
113
+ await sear.send(reply_msg)
114
+ return
115
+ except Exception as e:
116
+ import traceback
117
+ traceback.print_exc()
118
+ await sear.send(f"❌搜索出错:{str(e)}")
@@ -0,0 +1,87 @@
1
+ from playwright.async_api import async_playwright
2
+ from playwright.async_api import expect
3
+ import aiohttp
4
+ from lxml import etree
5
+ from loguru import logger
6
+ from .config import *
7
+
8
+ async def search_book(keyword):
9
+ from playwright.async_api import async_playwright, expect
10
+ browser = None
11
+ async with async_playwright()as p:
12
+ try:
13
+ browser = await p.chromium.launch(headless=True)
14
+ page = await browser.new_page(viewport={"width":1280,"height":720})
15
+
16
+ # ==========新增:仅拦截图片,其他资源全部放行==========
17
+ async def block_images(route, request):
18
+ if request.resource_type == "image":
19
+ await route.abort()
20
+ else:
21
+ await route.continue_()
22
+ await page.route("**/*", block_images)
23
+
24
+ url = 'https://www.bilinovel.com/search.html'
25
+ await page.goto(url, timeout=30000)
26
+ await page.wait_for_load_state("networkidle")
27
+ search_input = page.locator("id=searchkey")
28
+ await search_input.wait_for(timeout=15000)
29
+ await search_input.fill(keyword)
30
+ await search_input.press("Enter")
31
+ await expect(page.locator("id=a_addbookcase").or_(page.locator('//b[@class="hot"]'))).to_be_visible(timeout=15000)
32
+ if await page.locator("#a_addbookcase").is_visible():
33
+ book_url = await page.locator("//div[@class='book-detail-btn']/ul/li[1]/a").get_attribute("href")
34
+ book_name = await page.locator("//div/h1").inner_text()
35
+ novel_id = book_url.split("/novel/")[1].split("/")[0]
36
+ return book_name,novel_id
37
+ else:
38
+ h3_text = await page.locator('//h3').text_content()
39
+ book_urls = await page.locator("//li/a").evaluate_all("items=>items.map(e=>e.href)")
40
+ book_names = await page.locator("//li/a/div/img").evaluate_all("items=>items.map(e=>e.alt)")
41
+ ids = []
42
+ if not book_names:
43
+ logger.warning(h3_text)
44
+ return h3_text
45
+ for book_url in book_urls:
46
+ novel_id = book_url.split("/novel/")[1].split("/")[0].split(".")[0]
47
+ ids.append(novel_id)
48
+ return book_names,book_urls,ids
49
+ finally:
50
+ if browser:
51
+ await browser.close()
52
+
53
+
54
+
55
+
56
+
57
+
58
+ async def getbook_html(book_id: int, session: aiohttp.ClientSession):
59
+
60
+ url = f'https://www.bilinovel.com/novel/{book_id}/catalog'
61
+ HEADERS = get_random_headers()
62
+
63
+ async with session.get(url, headers=HEADERS, timeout=aiohttp.ClientTimeout(total=15)) as resp:
64
+ resp_text = await resp.text(encoding="utf-8")
65
+ # logger.info(resp_text)
66
+ e = etree.HTML(resp_text)
67
+ book_nums = e.xpath('//div/ul/li[@class="chapter-bar chapter-li"]/a/h3/text()')
68
+ book_htmls = e.xpath('//div/ul/li[@class="chapter-bar chapter-li"]/a/@href')
69
+ if not book_nums:
70
+ logger.error('该id不存在')
71
+ return [],[]
72
+ # logger.info(book_htmls)
73
+ # logger.info(book_nums)
74
+ return book_nums,book_htmls
75
+
76
+
77
+
78
+ async def main():
79
+ keyword = "游戏人生"
80
+ print(f"正在搜索:{keyword}")
81
+ res = await search_book(keyword)
82
+ print("====搜索返回结果====")
83
+ print(f"返回类型: {type(res)}")
84
+ print(f"内容: {res}")
85
+
86
+ if __name__ == "__main__":
87
+ asyncio.run(main())
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.4
2
+ Name: nonebot-plugin-bilinovel
3
+ Version: 0.1.0
4
+ Summary: NoneBot2 哔哩轻小说下载爬虫插件
5
+ Author-email: AuYaMi <SIX18318463527@163.com>
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 AuYaMi
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+
28
+ Requires-Python: >=3.10
29
+ Description-Content-Type: text/markdown
30
+ License-File: LICENSE
31
+ Requires-Dist: nonebot2>=2.5.0
32
+ Requires-Dist: nonebot-adapter-onebot>=2.4.6
33
+ Requires-Dist: aiohttp
34
+ Requires-Dist: lxml
35
+ Requires-Dist: loguru
36
+ Requires-Dist: playwright
37
+ Requires-Dist: ebooklib
38
+ Dynamic: license-file
39
+
40
+ # nonebot-plugin-bilinovel
41
+
42
+ > NoneBot2 哔哩轻小说爬虫插件,搜索小说并下载生成 EPUB 电子书
43
+
44
+ ## 📦 安装
45
+
46
+ ### 方式一:使用 nb‑cli
47
+
48
+
49
+ ```bash
50
+ nb plugin install nonebot-plugin-bilinovel
51
+ ```
52
+
53
+ ### 方式二:pip 安装插件
54
+ ```bash
55
+ pip install nonebot-plugin-bilinovel
56
+ ```
57
+
58
+ ### 安装 Playwright 浏览器内核
59
+
60
+ ```bash
61
+ playwright install chromium
62
+ ```
63
+
64
+ ## 🎯 使用命令
65
+
66
+ ### 参数标记约定
67
+
68
+ - `< >` 必填参数,不可省略
69
+ - `[ ]` 可选参数,可以省略
70
+
71
+ 表格
72
+
73
+ | 指令 | 语法 | 说明 |
74
+ | --- | --- | --- |
75
+ | `/sear` | `/sear <小说名称>` | 搜索哔哩轻小说,返回书本 id |
76
+ | `/down` | `/down <书本id> <卷号> [下载格式]` | 下载小说并生成 EPUB 或 txt |
77
+
78
+ > 搜索示例:`/sear 关于我转生变成史莱姆这档事`
79
+ > `/sear 游戏人生`
80
+ >
81
+ >
82
+ > 下载示例:`/down 9 1 epub` 即下载书籍 id 为 9 的第一卷,导出格式为 epub
83
+ >
84
+ >
85
+ > [下载格式不填默认txt]
86
+
87
+ ## ⚙️ .env自定义参数配置
88
+
89
+
90
+ 可调整参数列表:
91
+
92
+ ```
93
+ # Playwright浏览器无头开关,True:无头 /False:有头
94
+ BROWSER_HEADLESS = True #无头情况下epub模式可能获取不到图片
95
+ # 章节并发数,并发多容易限流
96
+ WORKER_COUNT = 2
97
+ ```
98
+
99
+
100
+ ## ⚠️ 使用须知
101
+
102
+ 1. 本插件仅用于个人学习研究。
103
+ 2. 请勿高频、大规模爬取网站,避免给目标站点造成压力。
104
+ 3. 下载内容版权归原作者与平台所有。
105
+
106
+ ## 📁 文件输出
107
+
108
+ 生成的 EPUB 文件将会保存至插件运行目录下。
109
+
110
+ ## 📝 更新日志
111
+
112
+ ### v0.1.0
113
+
114
+ - 基础小说搜索功能
115
+ - 小说章节错峰并发爬取
116
+ - EPUB 电子书导出
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ nonebot_plugin_bilinovel/__init__.py
5
+ nonebot_plugin_bilinovel/config.py
6
+ nonebot_plugin_bilinovel/download.py
7
+ nonebot_plugin_bilinovel/novel.py
8
+ nonebot_plugin_bilinovel/search.py
9
+ nonebot_plugin_bilinovel.egg-info/PKG-INFO
10
+ nonebot_plugin_bilinovel.egg-info/SOURCES.txt
11
+ nonebot_plugin_bilinovel.egg-info/dependency_links.txt
12
+ nonebot_plugin_bilinovel.egg-info/entry_points.txt
13
+ nonebot_plugin_bilinovel.egg-info/requires.txt
14
+ nonebot_plugin_bilinovel.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [nonebot.plugins]
2
+ nonebot-plugin-bilinovel = nonebot_plugin_bilinovel
@@ -0,0 +1,7 @@
1
+ nonebot2>=2.5.0
2
+ nonebot-adapter-onebot>=2.4.6
3
+ aiohttp
4
+ lxml
5
+ loguru
6
+ playwright
7
+ ebooklib
@@ -0,0 +1 @@
1
+ nonebot_plugin_bilinovel
@@ -0,0 +1,31 @@
1
+ [project]
2
+ name = "nonebot-plugin-bilinovel"
3
+ version = "0.1.0"
4
+ authors = [
5
+ { name = "AuYaMi", email = "SIX18318463527@163.com" }
6
+ ]
7
+ description = "NoneBot2 哔哩轻小说下载爬虫插件"
8
+ readme = "README.md"
9
+ license = { file = "LICENSE" }
10
+ requires-python = ">=3.10"
11
+ dependencies = [
12
+ "nonebot2>=2.5.0",
13
+ "nonebot-adapter-onebot>=2.4.6",
14
+ "aiohttp",
15
+ "lxml",
16
+ "loguru",
17
+ "playwright",
18
+ "ebooklib"
19
+ ]
20
+ [project.entry-points."nonebot.plugins"]
21
+ nonebot-plugin-bilinovel = "nonebot_plugin_bilinovel"
22
+ [build-system]
23
+ requires = ["setuptools>=61.0"]
24
+ build-backend = "setuptools.build_meta"
25
+ [tool.nonebot]
26
+ adapters = [
27
+ { name = "OneBot V11", module_name = "nonebot.adapters.onebot.v11" }
28
+ ]
29
+ plugins = ["nonebot_plugin_bilinovel"]
30
+ # 新增这一行,自定义环境变量白名单
31
+ env_startswith = ["WORKER_", "BROWSER_", "TEMP_"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+