Skip to content

学习的事情不算爬

  • 爬虫的使用必须遵守相关法律法规,不得侵犯他人的隐私权、著作权等合法权益。
  • 尊重网站的 robots.txt 文件中的规定,遵守网站所有者的规则。
  • 合理设置访问频率,避免对网站造成不必要的困扰。

下载角色干声utils

用于GPT-SoVITS训练前置

npm i axios@0.27.2 cheerio@^1.0.0-rc.12

爬取角色语音
js

const axios = require('axios');
const cheerio = require('cheerio');
const fs = require('fs');
const path = require('path');

// 下载目录路径
const DOWNLOAD_DIR = path.join(__dirname, 'output');

/**
 * 初始化下载目录(不存在则创建)
 */
function initDownloadDir() {
  if (!fs.existsSync(DOWNLOAD_DIR)) {
    fs.mkdirSync(DOWNLOAD_DIR, { recursive: true }); // recursive确保多级目录创建
    console.log(`已创建下载目录: ${DOWNLOAD_DIR}`);
  }
}
/**
 * 清洗文件名(去除非法字符)
 * @param {string} filename - 原始文件名
 * @returns {string} 清洗后的安全文件名
 */
function cleanFilename(filename) {
  // 替换Windows/Linux/macOS中的非法文件名字符
  const illegalChars = /[\/:*?"<>|\\]/g;
  return filename.replace(illegalChars, '_').trim();
}

/**
 * 下载单个MP3文件
 * @param {Object} item - 包含title和url的对象
 * @returns {Promise<void>}
 */
async function downloadSingleMp3(item) {
  const { title, url } = item;
  if (!title || !url) {
    console.warn('跳过无效数据:缺少标题或链接');
    return;
  }

  // 处理文件名:清洗 + 添加.mp3后缀
  const cleanTitle = cleanFilename(title);
  const filename = `${cleanTitle}.mp3`;
  const filePath = path.join(DOWNLOAD_DIR, filename);

  // 检查文件是否已存在
  if (fs.existsSync(filePath)) {
    console.log(`✅ 已存在,跳过下载:${filename}`);
    return;
  }

  try {
    console.log(`📥 开始下载:${filename}`);
    const response = await axios.get(url, {
      responseType: 'stream', // 以流形式获取数据
      headers: {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
      }
    });

    // 创建可写流并写入文件
    const writer = fs.createWriteStream(filePath);
    response.data.pipe(writer);

    // 等待写入完成
    await new Promise((resolve, reject) => {
      writer.on('finish', resolve);
      writer.on('error', reject);
    });

    console.log(`✅ 下载完成:${filename}`);
  } catch (error) {
    console.error(`❌ 下载失败 [${title}]:`, error.message);
    // 下载失败时删除可能残留的空文件
    if (fs.existsSync(filePath)) {
      fs.unlinkSync(filePath);
    }
  }
}

/**
 * 批量下载MP3文件(串行下载,避免请求过多)
 * @param {Array<Object>} mp3List - 包含title和url的对象数组
 */
async function batchDownloadMp3s(mp3List) {
  if (mp3List.length === 0) {
    console.log('没有可下载的MP3资源');
    return;
  }

  console.log(`\n开始批量下载,共 ${mp3List.length} 个文件`);
  // 串行下载(一个完成后再下载下一个)
  for (const item of mp3List) {
    await downloadSingleMp3(item);
    // 可选:添加下载间隔,避免请求过于频繁
    // await new Promise(resolve => setTimeout(resolve, 1000));
  }
  console.log('\n📦 所有下载任务已完成');
}


/**
 * 1. 爬取目标页面 HTML 内容
 * @param {string} url - 目标网页 URL
 * @returns {Promise<string>} 页面 HTML 内容
 */
async function fetchPageHtml(url) {
  try {
    const response = await axios.get(url, {
      headers: {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
      }
    });
    return response.data;
  } catch (error) {
    console.error('获取页面内容失败:', error.message);
    throw new Error('页面请求失败');
  }
}

/**
 * 2. 从 HTML 中解析 MP3 链接、大标题和具体台词文本
 * @param {string} html - 页面 HTML 内容
 * @returns {Array<{title: string, info: string, url: string}>} 包含标题、文本和 MP3 链接的数组
 */
function parseMp3Links(html) {
  const $ = cheerio.load(html);
  const mp3List = [];

  // 遍历所有行,寻找包含标题和台词的结构
  $('tr').each((trIndex, trElement) => {
    const thElement = $(trElement).find('th').first(); // 大标题
    const tdElement = $(trElement).find('td').first(); // 包含多个台词的内容块

    // 只处理有标题和内容的行
    if (thElement.length && tdElement.length) {
      const title = thElement.text().trim(); // 大标题
      if (!title) return; // 跳过无标题的行

      // 拆分<td>中的多个台词条目(按<br>分隔,处理换行分隔的多个条目)
      const tdHtml = tdElement.html() || '';
      // 使用<br>或<br/>作为分隔符,拆分多个台词块
      const lineBlocks = tdHtml.split(/<br\s*\/?>/i);

      // 处理每个台词块
      lineBlocks.forEach((block, blockIndex) => {
        if (!block.trim()) return; // 跳过空内容

        // 提取当前块的纯文本(去除HTML标签)
        const info = $(`<div>${block}</div>`).text().trim();

        // 提取当前块中的MP3链接(只匹配当前块内的链接)
        const mp3Regex = /https?:\/\/[^\s"'<>]+\.mp3/g;
        const mp3Urls = block.match(mp3Regex);

        // 如果有链接,添加到结果数组
        if (mp3Urls && mp3Urls.length > 0) {
          info.split(/\n\n/g).forEach(i => {
            mp3List.push({
              title: title, // 大标题
              info: i.split(/https:[^\s"'>]+?\.mp3(?=[\s"'>]|$)/gi)[0].trim() || `未命名台词-${blockIndex}`, // 具体台词文本
              url: i.match(/https:[^\s"'>]+?\.mp3(?=[\s"'>]|$)/gi)[0].trim()
            });
          })
        }
      });
    }
  });

  return mp3List;
}


/**
 * 3. 主函数:爬取 + 下载流程
 * @param {string} targetUrl - 目标 URL
 */
async function crawlAndDownloadMp3s(targetUrl) {
  try {
    // 初始化下载目录
    initDownloadDir();

    // 爬取数据
    console.log('开始爬取页面内容...');
    const html = await fetchPageHtml(targetUrl);

    console.log('解析 MP3 资源信息...');
    const mp3List = parseMp3Links(html);
    console.log(`解析完成,共找到 ${mp3List.length} 个 MP3 资源`);

    const downList = []
    mp3List.forEach((item, index) => {
      // console.log(`\n${index + 1}. 标题:${item.title}  台词:${item.info}`);
      // console.log(`链接:${item.url}`); // 主url
      const url = item.url; //下载链接
      const title = `${index + 1}_${item.title}_${item.info}`
      console.log({
        title,
        url,
      });

      downList.push({
        title,
        url,
      })
    });
    // 批量下载
    await batchDownloadMp3s(downList);

  } catch (error) {
    console.error('流程出错:', error.message);
  }
}

// 4. 执行爬虫并输出结果
const targetUrl = 'https://wiki.biligame.com/blhx/%E5%9F%83%E5%B0%94%E5%BE%B7%E9%87%8C%E5%A5%87';
crawlAndDownloadMp3s(targetUrl);