2026-05-10 13:12:44 +08:00

489 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ═══════════════════════════════════════════════════════════
* 笔趣阁聚合搜索适配器 · Biquge Aggregator Direct Adapter
* ═══════════════════════════════════════════════════════════
*
* 聚合多个免费小说网站进行搜索和下载
* 这些站点对海外IP友好无地域封锁
*
* 搜索策略:
* 1. 69shu.buzs.cc (69书吧 · TXT资源丰富)
* 2. www.bqgda.cc (笔趣阁 · 免费小说源)
*
* 守护: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
* ═══════════════════════════════════════════════════════════
*/
'use strict';
const http = require('http');
const https = require('https');
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
const CHAPTER_DOWNLOAD_DELAY_MS = 500;
/**
* 清洁化并验证数字ID
* @param {string} id - 原始ID
* @param {string} label - 用于错误消息的标签
* @returns {string} 纯数字ID
*/
function sanitizeNumericId(id, label) {
const safe = String(id || '').replace(/[^0-9]/g, '');
if (!safe) throw new Error(`Invalid ${label}: must be numeric`);
return safe;
}
// ─── 数据源配置(可动态扩展) ───
const SOURCES = [
{
id: 'shu69',
name: '69书吧',
searchUrl: 'https://69shu.buzs.cc/modules/article/search.php',
searchMethod: 'GET',
searchParam: 'searchkey',
charset: 'utf-8',
enabled: true
}
];
/**
* HTTP(S) GET 请求 · 返回原始 HTML/文本
*/
function httpGetRaw(urlStr, timeoutMs, extraHeaders) {
const timeout = timeoutMs || 15000;
return new Promise((resolve, reject) => {
let url;
try {
url = new URL(urlStr);
} catch (e) {
return reject(new Error(`Invalid URL: ${urlStr}`));
}
const mod = url.protocol === 'https:' ? https : http;
const opts = {
hostname: url.hostname,
port: url.port || (url.protocol === 'https:' ? 443 : 80),
path: url.pathname + url.search,
method: 'GET',
timeout,
headers: {
'User-Agent': USER_AGENT,
'Accept': 'text/html,application/xhtml+xml,*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
...(extraHeaders || {})
}
};
const req = mod.request(opts, (res) => {
// Handle redirects
if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
const redirectUrl = res.headers.location.startsWith('http')
? res.headers.location
: `${url.protocol}//${url.host}${res.headers.location}`;
httpGetRaw(redirectUrl, timeout, extraHeaders).then(resolve).catch(reject);
res.resume();
return;
}
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
resolve({
body: Buffer.concat(chunks).toString('utf-8'),
status: res.statusCode,
headers: res.headers
});
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
req.end();
});
}
/**
* 从HTML中提取纯文本安全清理
* 先移除标签,再解码实体
*/
function cleanHtml(html) {
if (!html || typeof html !== 'string') return '';
let text = html;
// 保留段落换行
text = text.replace(/<br\s*\/?>/gi, '\n');
text = text.replace(/<\/p>/gi, '\n');
text = text.replace(/<\/div>/gi, '\n');
// 循环移除标签最多10轮防DoS
let prev;
let rounds = 0;
do {
prev = text;
text = text.replace(/<[^>]*>/g, '');
rounds++;
} while (text !== prev && rounds < 10);
// 解码HTML实体&amp; 最后)
text = text
.replace(/&#(\d+);/g, (_, code) => String.fromCharCode(parseInt(code, 10)))
.replace(/&#x([0-9a-fA-F]+);/g, (_, code) => String.fromCharCode(parseInt(code, 16)))
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/g, '&');
text = text.replace(/\n{3,}/g, '\n\n').trim();
return text;
}
/**
* 从69书吧搜索结果HTML解析书籍列表
* @param {string} html - 搜索结果页面HTML
* @returns {Array} 书籍列表
*/
function parse69shuSearchResults(html) {
const books = [];
if (!html) return books;
// 匹配搜索结果中的书籍条目
// 69shu 搜索结果格式: <div class="newbox">...<a href="/book/ID">书名</a>...<span>作者</span>...
const bookPattern = /<div[^>]*class="[^"]*newbox[^"]*"[^>]*>([\s\S]*?)<\/div>/gi;
let match;
while ((match = bookPattern.exec(html)) !== null) {
const block = match[1];
// 提取链接和书名
const linkMatch = block.match(/<a[^>]*href="\/book\/(\d+)[^"]*"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const bookId = linkMatch[1];
const title = cleanHtml(linkMatch[2]).trim();
if (!title) continue;
// 提取作者
const authorMatch = block.match(/作者[:]\s*([^<\n]+)/i)
|| block.match(/<a[^>]*class="[^"]*author[^"]*"[^>]*>([\s\S]*?)<\/a>/i);
const author = authorMatch ? cleanHtml(authorMatch[1]).trim() : '未知';
// 提取分类
const categoryMatch = block.match(/分类[:]\s*([^<\n]+)/i);
const category = categoryMatch ? cleanHtml(categoryMatch[1]).trim() : '';
books.push({
id: `shu69-${bookId}`,
title,
author,
category,
source: 'shu69',
source_name: '69书吧',
source_book_id: bookId,
word_count: 0,
has_file: false,
description: ''
});
}
// 备用解析:通过表格行或列表项解析
if (books.length === 0) {
const trPattern = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
while ((match = trPattern.exec(html)) !== null) {
const row = match[1];
const linkMatch = row.match(/<a[^>]*href="[^"]*\/book\/(\d+)[^"]*"[^>]*>([\s\S]*?)<\/a>/i)
|| row.match(/<a[^>]*href="[^"]*\/(\d+)\/?[^"]*"[^>]*>([\s\S]*?)<\/a>/i);
if (!linkMatch) continue;
const bookId = linkMatch[1];
const title = cleanHtml(linkMatch[2]).trim();
if (!title || title.length < 2) continue;
const tdPattern = /<td[^>]*>([\s\S]*?)<\/td>/gi;
const cells = [];
let tdMatch;
while ((tdMatch = tdPattern.exec(row)) !== null) {
cells.push(cleanHtml(tdMatch[1]).trim());
}
// Avoid duplicates
if (books.some(b => b.source_book_id === bookId)) continue;
books.push({
id: `shu69-${bookId}`,
title,
author: cells[1] || '未知',
category: cells[2] || '',
source: 'shu69',
source_name: '69书吧',
source_book_id: bookId,
word_count: 0,
has_file: false,
description: ''
});
}
}
// Final fallback: generic link pattern
if (books.length === 0) {
const linkPattern = /<a[^>]*href="[^"]*\/book\/(\d+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
while ((match = linkPattern.exec(html)) !== null) {
const bookId = match[1];
const title = cleanHtml(match[2]).trim();
if (!title || title.length < 2) continue;
if (books.some(b => b.source_book_id === bookId)) continue;
books.push({
id: `shu69-${bookId}`,
title,
author: '未知',
category: '',
source: 'shu69',
source_name: '69书吧',
source_book_id: bookId,
word_count: 0,
has_file: false,
description: ''
});
}
}
return books.slice(0, 20);
}
/**
* 解析69书吧书籍详情页获取章节目录
* @param {string} html - 书籍详情页HTML
* @returns {Array} 章节列表 [{item_id, title, index, url}]
*/
function parse69shuCatalog(html) {
const chapters = [];
if (!html) return chapters;
// 69shu catalog: <li><a href="/book/ID/CHAPTER_ID">章节标题</a></li>
const chPattern = /<a[^>]*href="[^"]*\/book\/\d+\/(\d+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
let match;
while ((match = chPattern.exec(html)) !== null) {
const chId = match[1];
const title = cleanHtml(match[2]).trim();
if (!title) continue;
chapters.push({
item_id: chId,
title,
index: chapters.length
});
}
// Fallback: more generic chapter link patterns
if (chapters.length === 0) {
const altPattern = /<a[^>]*href="([^"]*\d+\.html?)"[^>]*>([\s\S]*?)<\/a>/gi;
while ((match = altPattern.exec(html)) !== null) {
const url = match[1];
const title = cleanHtml(match[2]).trim();
if (!title || title.length < 2) continue;
// Extract numeric ID from URL
const idMatch = url.match(/(\d+)\.html?$/);
if (!idMatch) continue;
chapters.push({
item_id: idMatch[1],
title,
index: chapters.length,
url
});
}
}
return chapters;
}
/**
* 解析章节正文内容
* @param {string} html - 章节页面HTML
* @returns {string} 纯文本正文
*/
function parseChapterContent(html) {
if (!html) return '';
// 尝试多种正文容器选择器
const contentPatterns = [
/<div[^>]*id="(?:content|chaptercontent|BookText|booktext|htmlContent)"[^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class="[^"]*(?:content|chapter-content|book-content|read-content|txt)[^"]*"[^>]*>([\s\S]*?)<\/div>/i,
/<div[^>]*class="[^"]*txtnav[^"]*"[^>]*>([\s\S]*?)<\/div>/i
];
for (const pattern of contentPatterns) {
const match = html.match(pattern);
if (match && match[1]) {
const text = cleanHtml(match[1]);
if (text.length > 50) return text;
}
}
return '';
}
/**
* 搜索 69书吧
* @param {string} query - 搜索关键词
* @returns {Promise<Array>} 书籍列表
*/
async function search69shu(query) {
const src = SOURCES.find(s => s.id === 'shu69');
if (!src || !src.enabled) return [];
const url = `${src.searchUrl}?${src.searchParam}=${encodeURIComponent(query)}`;
const { body } = await httpGetRaw(url, 10000, {
'Referer': 'https://69shu.buzs.cc/'
});
return parse69shuSearchResults(body);
}
/**
* 搜索所有内置源
* @param {string} query - 搜索关键词
* @returns {Promise<Array>} 书籍列表
*/
async function search(query) {
const allResults = [];
const errors = [];
// 69书吧
try {
const results = await search69shu(query);
allResults.push(...results);
} catch (err) {
errors.push({ source: 'shu69', error: err.message });
}
return { results: allResults, errors };
}
/**
* 获取书籍章节目录
* @param {string} bookId - 书籍ID (纯数字)
* @returns {Promise<Array>} 章节列表
*/
async function getCatalog(bookId) {
const safeBookId = sanitizeNumericId(bookId, 'book ID');
const url = `https://69shu.buzs.cc/book/${safeBookId}/`;
const { body } = await httpGetRaw(url, 15000);
return parse69shuCatalog(body);
}
/**
* 获取单章内容
* @param {string} bookId - 书籍ID
* @param {string} chapterId - 章节ID
* @returns {Promise<string>} 章节纯文本内容
*/
async function getChapterContent(bookId, chapterId) {
const safeBookId = sanitizeNumericId(bookId, 'book ID');
const safeChapterId = sanitizeNumericId(chapterId, 'chapter ID');
const url = `https://69shu.buzs.cc/book/${safeBookId}/${safeChapterId}`;
const { body } = await httpGetRaw(url, 15000);
return parseChapterContent(body);
}
/**
* 下载完整书籍
* @param {string} bookId - 书籍ID
* @param {string} title - 书名
* @param {string} author - 作者
* @param {function} onProgress - 进度回调 (current, total, message)
* @returns {Promise<string>} 完整书籍TXT内容
*/
async function downloadBook(bookId, title, author, onProgress) {
const chapters = await getCatalog(bookId);
if (!chapters || chapters.length === 0) {
throw new Error('无法获取章节目录');
}
if (onProgress) onProgress(0, chapters.length, `${chapters.length}章,开始下载...`);
const contents = [];
let failCount = 0;
const MAX_CONSECUTIVE_FAILS = 10;
let consecutiveFails = 0;
for (let i = 0; i < chapters.length; i++) {
const ch = chapters[i];
if (!ch.item_id) continue;
try {
const text = await getChapterContent(bookId, ch.item_id);
if (text && text.length > 20) {
contents.push(`${ch.title}\n\n${text}`);
consecutiveFails = 0;
} else {
failCount++;
consecutiveFails++;
}
} catch (err) {
failCount++;
consecutiveFails++;
console.warn(`[biquge-direct] 章节 ${i + 1} 下载失败: ${err.message}`);
}
// Safety: abort if too many consecutive failures (likely blocked or wrong URL)
if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
console.warn(`[biquge-direct] 连续${MAX_CONSECUTIVE_FAILS}章失败,中止下载`);
break;
}
if (onProgress) {
onProgress(i + 1, chapters.length, `下载中 ${i + 1}/${chapters.length} 章...`);
}
// 章间延迟,避免请求过快被封
await new Promise(r => setTimeout(r, CHAPTER_DOWNLOAD_DELAY_MS));
}
if (contents.length === 0) {
throw new Error(`未能获取到任何章节内容 (尝试${chapters.length}章·全部失败)`);
}
if (contents.length < chapters.length) {
console.warn(`[biquge-direct] 部分下载: ${contents.length}/${chapters.length} 章成功 (${failCount}章失败)`);
}
return `${title}\n作者:${author || '未知'}\n来源69书吧直连聚合\n` +
`下载时间:${new Date().toISOString()}\n` +
`成功章节:${contents.length}/${chapters.length}\n\n` +
contents.join('\n\n───────────────\n\n');
}
/**
* 健康检查
*/
async function healthCheck() {
try {
const { results } = await search('斗破苍穹');
return {
reachable: Array.isArray(results) && results.length > 0,
source: 'biquge-direct',
name: '笔趣阁聚合(直连)',
result_count: results.length
};
} catch (err) {
return {
reachable: false,
source: 'biquge-direct',
name: '笔趣阁聚合(直连)',
error: err.message
};
}
}
module.exports = {
search,
search69shu,
getCatalog,
getChapterContent,
downloadBook,
healthCheck,
cleanHtml,
parse69shuSearchResults,
parse69shuCatalog,
parseChapterContent
};