夜雨聆风学习资料网

ARTICLE · 1133208

《B站视频批量下载工具:告别"点到手酸",实现"一键搞定",谁用谁知道!》

《B站视频批量下载工具:告别"点到手酸",实现"一键搞定",谁用谁知道!》

“带你横跨办公自动化的数据江海”

@摸鱼

前言

关于摸鱼

(

ABOUT MOYU

)

闻道有先后,术业有专攻。

各位大佬朋友们好!

~我依旧是你们的老朋友摸鱼~

在职场摸爬滚打的这十多年里,我用Python悄悄干了不少“正事”——不知不觉攒下了一整套办公自动化的实用项目技巧。自2025年10月初创立了公众号 「码海听潮」 ,初衷很简单:把重复的劳动交给代码,把摸鱼的时间留给生活。

目前已经吭哧吭哧更新了160余篇原创文章,每一篇都是实操干货,不讲虚的,只聊怎么用代码真正解放双手,帮大家早点下班、准点摸鱼!

好了,多了不说,少了不唠,今天给大佬们分享一款本人原创的【B站视频批量下载工具】。经常做视频收藏、整理学习资料的朋友都懂:B站里的视频一个个躺在收藏夹和合集里,想批量下载到本地却只能一个个手动点,几百上千个视频,光是复制 BV 号就能复制到手指发酸,还容易漏集、错序。要是碰上 UP 主投稿几百个、合集动辄几十 P,那更是噩梦。这种"视频在列表里,下载靠手点"的困境,几乎人人都踩过。今天要分享的这款工具,正是为了解决这份尴尬。它可以自动识别你粘贴的 BVID、视频链接、UP 主空间链接或 UID,选中目标后自动抓取合集 / 分 P / 全部投稿的视频列表,并支持扫码登录获取高清画质、自定义保存目录、批量勾选下载,自动下载音视频流并调用 ffmpeg 无损合并为 mp4,方便后续做本地收藏、资料整理和离线观看。

《该办公工具成品展示:》

《该办公工具操作演示视频:》

关注
重播 分享 赞

《该办公工具核心功能亮点:》

01

 亮点一:扫码登录,登录态自动管理

        • 点击「扫码登录」弹出独立二维码窗口,用 B站手机 App 扫一扫即可完成登录

        • 二维码过期自动刷新,无需手动重新点击

        • 登录后 Cookie 自动写入 cookie.json,并实时显示剩余有效期

        • 启动时若未登录会自动弹窗提醒,无需手动检查

        02

         亮点二:多形态输入,一个入口全兼容

            • 支持 BVID(如 BV1zGgr6rEkF)

            • 支持 完整视频链接(粘贴即用,自动提取 BV 号)

            • 支持 UP 主空间链接(https://space.bilibili.com/xxxx)

            • 支持 纯数字 UID

            • 自动识别输入类型,无需手动切换模式

            03

            亮点三:智能抓取,覆盖全部场景

            • 合集视频:自动读取 ugc_season 里的全部选集

            • 多分 P 视频:自动按分 P 逐个抓取

            • 普通单视频:自动回退抓取相关推荐列表

            • UP 主投稿:翻页抓取全部投稿,实时打印进度

            一句话总结:从"扫码登录 → 识别输入 → 智能抓取 → 勾选下载 → 自动合并"全流程打通,既解决了手动下载的繁琐,又兼顾了画质、风控和文件管理,是一款真正能拿来干活的批量下载工具。

            《该办公工具代码展示:》

            下面,我就奉上python代码让各位大佬见识一下,什么叫"传统文化"遇上"赛博效率"

             # -*- coding: utf-8 -*-import osimport sysimport timeimport threadingimport tracebackfrom contextlib import contextmanagerfrom PyQt6.QtCore import Qt, QTimer, pyqtSignal, QObjectfrom PyQt6.QtGui import QPixmap, QFontfrom PyQt6.QtWidgets import (    QApplication, QMainWindow, QWidget, QLabel, QPushButton, QLineEdit,    QVBoxLayout, QHBoxLayout, QGroupBox, QListWidget,    QPlainTextEdit, QFileDialog, QMessageBox, QDialog, QCheckBox,    QScrollArea, QFrame, QSplitter)# ----------------------------------------------------------------------# 输出重定向: 把子线程里的 print 转发到 Qt 信号# ----------------------------------------------------------------------class EmittingStream(QObject):    text_written = pyqtSignal(str)    def write(self, text: str) -> None:        if text:            self.text_written.emit(text)    def flush(self) -> None:        pass# ----------------------------------------------------------------------# 临时切换工作目录的上下文管理器# VideoDownloader 用相对路径写 __video.m4s / 输出 .mp4,# ----------------------------------------------------------------------@contextmanagerdef chdir(path: str):    old = os.getcwd()    try:        os.makedirs(path, exist_ok=True)        os.chdir(path)        yield    finally:        os.chdir(old)# ----------------------------------------------------------------------# 二维码登录窗口# ----------------------------------------------------------------------class QRLoginDialog(QDialog):    """独立的扫码登录窗口,自动刷新二维码。"""    login_succeeded = pyqtSignal()    def __init__(self, parent=None):        super().__init__(parent)        self.setWindowTitle('扫码登录 B站')        self.setModal(False)        self.setFixedSize(360, 460)        self.qr_time = 0.0        self._stop = False        layout = QVBoxLayout(self)        layout.setContentsMargins(16, 16, 16, 16)        layout.setSpacing(10)        title = QLabel('请使用 B站手机 App 扫描下方二维码')        title.setAlignment(Qt.AlignmentFlag.AlignCenter)        title.setStyleSheet('font-size: 13px; font-weight: bold;')        layout.addWidget(title)        self.qr_label = QLabel('二维码加载中...\n请稍候')        self.qr_label.setAlignment(Qt.AlignmentFlag.AlignCenter)        self.qr_label.setFixedSize(300, 300)        self.qr_label.setStyleSheet(            'border: 1px solid #ccc; background: #fafafa; color: #888;'        )        layout.addWidget(self.qr_label, alignment=Qt.AlignmentFlag.AlignCenter)        self.status_label = QLabel('等待二维码生成...')        self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter)        self.status_label.setWordWrap(True)        self.status_label.setStyleSheet('color: #555;')        layout.addWidget(self.status_label)        btn_row = QHBoxLayout()        btn_row.addStretch()        self.close_btn = QPushButton('关闭')        self.close_btn.clicked.connect(self.close)        btn_row.addWidget(self.close_btn)        layout.addLayout(btn_row)        # 启动后台登录线程        self.login_thread = threading.Thread(target=self._run_login, daemon=True)        self.login_thread.start()        # 轮询二维码文件        self.timer = QTimer(self)        self.timer.timeout.connect(self._refresh_qr)        self.timer.start(800)    # -------- 后台: 复用原有 QRLogin 逻辑 --------    def _run_login(self) -> None:        try:            QRLogin().run()   # 阻塞直到登录成功            self.login_succeeded.emit()        except Exception as e:            print('[扫码登录异常]', e)    # -------- 刷新二维码图片 --------    def _refresh_qr(self) -> None:        path = os.path.join(program_dir(), 'login_qr.png')        if not os.path.exists(path):            return        try:            mtime = os.path.getmtime(path)        except OSError:            return        if mtime == self.qr_time:            return        self.qr_time = mtime        pix = QPixmap(path)        if pix.isNull():            return        scaled = pix.scaled(            290, 290,            Qt.AspectRatioMode.KeepAspectRatio,            Qt.TransformationMode.SmoothTransformation        )        self.qr_label.setPixmap(scaled)        self.qr_label.setText('')        self.status_label.setText('已刷新二维码,请用 B站App 扫码')    def closeEvent(self, event) -> None:        self._stop = True        self.timer.stop()        super().closeEvent(event)# ----------------------------------------------------------------------# 视频选择对话框 (复选框列表)# ----------------------------------------------------------------------class SelectVideosDialog(QDialog):    """显示抓取到的链接,让用户勾选要下载的。"""    def __init__(self, links, titles, parent=None):        super().__init__(parent)        self.setWindowTitle('选择要下载的视频')        self.resize(680, 560)        self.links = links        self.titles = titles        self.selected_indices = []        layout = QVBoxLayout(self)        layout.setContentsMargins(10, 10, 10, 10)        layout.setSpacing(8)        info = QLabel('共 %d 个视频 (默认全选),勾选后点「下载选中的」:' % len(links))        layout.addWidget(info)        btn_row = QHBoxLayout()        all_btn = QPushButton('全选')        none_btn = QPushButton('全不选')        all_btn.clicked.connect(lambda: self._set_all(True))        none_btn.clicked.connect(lambda: self._set_all(False))        btn_row.addWidget(all_btn)        btn_row.addWidget(none_btn)        btn_row.addStretch()        layout.addLayout(btn_row)        # 滚动区域        scroll = QScrollArea()        scroll.setWidgetResizable(True)        scroll.setFrameShape(QFrame.Shape.StyledPanel)        inner = QWidget()        inner_layout = QVBoxLayout(inner)        inner_layout.setContentsMargins(6, 6, 6, 6)        inner_layout.setSpacing(2)        self.checkboxes = []        for i, (link, title) in enumerate(zip(links, titles)):            cb = QCheckBox('[%d] %s' % (i + 1, title or link))            cb.setChecked(True)            cb.setToolTip(link)            self.checkboxes.append(cb)            inner_layout.addWidget(cb)        inner_layout.addStretch()        scroll.setWidget(inner)        layout.addWidget(scroll, 1)        # 底部按钮        bottom = QHBoxLayout()        bottom.addStretch()        cancel_btn = QPushButton('取消')        cancel_btn.clicked.connect(self.reject)        ok_btn = QPushButton('下载选中的')        ok_btn.setDefault(True)        ok_btn.clicked.connect(self._on_ok)        bottom.addWidget(cancel_btn)        bottom.addWidget(ok_btn)        layout.addLayout(bottom)    def _set_all(self, state: bool) -> None:        for cb in self.checkboxes:            cb.setChecked(state)    def _on_ok(self) -> None:        self.selected_indices = [            i for i, cb in enumerate(self.checkboxes) if cb.isChecked()        ]        if not self.selected_indices:            QMessageBox.information(self, '提示', '没有勾选任何视频')            return        self.accept()# ----------------------------------------------------------------------# 主窗口# ----------------------------------------------------------------------class BiliMainWindow(QMainWindow):    log_signal = pyqtSignal(str)    task_done_signal = pyqtSignal(object, str)    ffmpeg_result_signal = pyqtSignal(str)   # 用于后台检测 ffmpeg    def __init__(self):        super().__init__()        self.setWindowTitle('B站视频下载工具 (PyQt6)')        self.resize(1020, 720)        self.busy = False        self.current_crawler: CollectionCrawler | None = None        self.qr_dialog: QRLoginDialog | None = None        # 记录上次抓取时的输入,用于判断能否复用        self._crawled_input: str = ''        # 任务完成回调(一次性)        self._pending_callback = None        # 信号连接        self.log_signal.connect(self._append_log)        self.task_done_signal.connect(self._on_task_done)        self.ffmpeg_result_signal.connect(self._on_ffmpeg_checked)        self._build_ui()        self._refresh_login_state()        self._auto_fill_ffmpeg()        # 首次启动时若未登录,自动弹出扫码登录窗口        QTimer.singleShot(300, self._auto_prompt_login)    # ================= UI 构建 =================    def _build_ui(self) -> None:        central = QWidget()        self.setCentralWidget(central)        root = QVBoxLayout(central)        root.setContentsMargins(10, 10, 10, 10)        root.setSpacing(8)        # ---------- 顶部状态栏 ----------        top = QHBoxLayout()        self.login_label = QLabel('登录态: 检测中...')        self.login_label.setStyleSheet('font-weight: bold;')        top.addWidget(self.login_label)        top.addStretch()        self.scan_qr_btn = QPushButton('扫码登录')        self.scan_qr_btn.clicked.connect(self._on_click_qr_login)        top.addWidget(self.scan_qr_btn)        self.check_btn = QPushButton('自检登录态')        self.check_btn.clicked.connect(self._on_click_check)        top.addWidget(self.check_btn)        root.addLayout(top)        # ---------- 设置组: ffmpeg + 保存目录 ----------        settings_box = QGroupBox('设置')        settings_layout = QVBoxLayout(settings_box)        settings_layout.setContentsMargins(8, 8, 8, 8)        settings_layout.setSpacing(6)        # --- ffmpeg 行 ---        ff_layout = QHBoxLayout()        ff_layout.addWidget(QLabel('ffmpeg.exe:'))        self.ffmpeg_input = QLineEdit()        self.ffmpeg_input.setPlaceholderText('例如 C:\\ffmpeg\\bin\\ffmpeg.exe')        ff_layout.addWidget(self.ffmpeg_input, 1)        browse_btn = QPushButton('浏览...')        browse_btn.clicked.connect(self._on_browse_ffmpeg)        ff_layout.addWidget(browse_btn)        check_ff_btn = QPushButton('检测')        check_ff_btn.clicked.connect(self._on_check_ffmpeg_clicked)        ff_layout.addWidget(check_ff_btn)        self.ffmpeg_status = QLabel('未检测')        self.ffmpeg_status.setStyleSheet('color: #888;')        ff_layout.addWidget(self.ffmpeg_status)        settings_layout.addLayout(ff_layout)        # --- 保存目录行 ---        dir_layout = QHBoxLayout()        dir_layout.addWidget(QLabel('保存目录:'))        self.save_dir_input = QLineEdit()        self.save_dir_input.setPlaceholderText('默认为程序所在目录,可点右侧「浏览...」修改')        self.save_dir_input.setText(program_dir())        dir_layout.addWidget(self.save_dir_input, 1)        browse_dir_btn = QPushButton('浏览...')        browse_dir_btn.clicked.connect(self._on_browse_save_dir)        dir_layout.addWidget(browse_dir_btn)        open_dir_btn = QPushButton('打开目录')        open_dir_btn.clicked.connect(self._on_open_save_dir)        dir_layout.addWidget(open_dir_btn)        settings_layout.addLayout(dir_layout)        root.addWidget(settings_box)        # ---------- 单视频下载 ----------        single_box = QGroupBox('单视频下载')        single_layout = QHBoxLayout(single_box)        single_layout.setContentsMargins(8, 8, 8, 8)        single_layout.addWidget(QLabel('BVID/链接:'))        self.single_input = QLineEdit()        self.single_input.setPlaceholderText('BV号 或 完整视频链接')        single_layout.addWidget(self.single_input, 1)        self.download_btn = QPushButton('下载并合并 mp4')        self.download_btn.clicked.connect(self._on_click_download)        single_layout.addWidget(self.download_btn)        root.addWidget(single_box)        # ---------- 合集抓取 / 批量下载 ----------        col_box = QGroupBox('合集抓取 / 批量下载 (支持 UP主空间链接或 UID)')        col_layout = QHBoxLayout(col_box)        col_layout.setContentsMargins(8, 8, 8, 8)        col_layout.addWidget(QLabel('BVID/链接/UP主:'))        self.collection_input = QLineEdit()        self.collection_input.setPlaceholderText(            '视频链接 / BV号 / https://space.bilibili.com/xxxx / 纯UID'        )        col_layout.addWidget(self.collection_input, 1)        self.crawl_btn = QPushButton('抓取链接')        self.crawl_btn.setToolTip('只抓取链接,填入下方列表并保存到文件')        self.crawl_btn.clicked.connect(self._on_click_crawl)        col_layout.addWidget(self.crawl_btn)        self.batch_btn = QPushButton('批量下载')        self.batch_btn.setToolTip('对已抓取的链接弹选择框并批量下载(不会重新抓取)')        self.batch_btn.clicked.connect(self._on_click_batch_download)        col_layout.addWidget(self.batch_btn)        root.addWidget(col_box)        # ---------- 中间: 链接列表 + 日志 ----------        splitter = QSplitter(Qt.Orientation.Vertical)        link_wrap = QWidget()        link_layout = QVBoxLayout(link_wrap)        link_layout.setContentsMargins(0, 0, 0, 0)        link_layout.setSpacing(4)        link_layout.addWidget(QLabel('已抓取的链接:'))        self.link_list = QListWidget()        link_layout.addWidget(self.link_list)        splitter.addWidget(link_wrap)        log_wrap = QWidget()        log_layout = QVBoxLayout(log_wrap)        log_layout.setContentsMargins(0, 0, 0, 0)        log_layout.setSpacing(4)        log_layout.addWidget(QLabel('运行日志:'))        self.log_text = QPlainTextEdit()        self.log_text.setReadOnly(True)        self.log_text.setFont(QFont('Consolas', 9))        log_layout.addWidget(self.log_text)        splitter.addWidget(log_wrap)        splitter.setSizes([220, 300])        root.addWidget(splitter, 1)        # ---------- 底部状态栏 ----------        self.statusBar().showMessage('就绪')        self.all_buttons = [            self.scan_qr_btn, self.check_btn, self.download_btn,            self.crawl_btn, self.batch_btn,        ]        self._append_log(            '提示: 首次使用请先点「扫码登录」。\n'            '下载需要 ffmpeg,请在上方选择 ffmpeg.exe 的完整路径并点「检测」。\n'            '下载的视频会保存到「保存目录」里。\n'            '操作流程: ① 填链接 → ②「抓取链接」→ ③「批量下载」选择要下的视频。\n\n'        )    # ================= 日志 =================    def _append_log(self, text: str) -> None:        self.log_text.moveCursor(self.log_text.textCursor().MoveOperation.End)        self.log_text.insertPlainText(text)        self.log_text.moveCursor(self.log_text.textCursor().MoveOperation.End)    # ================= 登录状态 =================    def _refresh_login_state(self) -> None:        try:            j = CookieManager.load()            remain = (CookieManager.expires_at() - int(time.time())) / 86400.0            self.login_label.setText(                '已登录: %s | Cookie 剩 %.1f 天' % (j.get('DedeUserID', '?'), remain)            )            self.login_label.setStyleSheet('font-weight: bold; color: green;')        except Exception:            self.login_label.setText('未登录 / Cookie 失效 —— 请点「扫码登录」')            self.login_label.setStyleSheet('font-weight: bold; color: #c00;')    def _auto_prompt_login(self) -> None:        """启动时若无 Cookie,自动弹扫码窗口。"""        try:            CookieManager.load()        except Exception:            self._on_click_qr_login()    # ================= 扫码登录 =================    def _on_click_qr_login(self) -> None:        if self.qr_dialog is not None and self.qr_dialog.isVisible():            self.qr_dialog.raise_()            self.qr_dialog.activateWindow()            return        dlg = QRLoginDialog(self)        dlg.login_succeeded.connect(self._on_login_success)        dlg.finished.connect(lambda _: setattr(self, 'qr_dialog', None))        self.qr_dialog = dlg        dlg.show()    def _on_login_success(self) -> None:        self._refresh_login_state()        if self.qr_dialog is not None:            self.qr_dialog.accept()            self.qr_dialog = None        QMessageBox.information(self, '登录成功', '已成功写入 cookie.json')    # ================= 自检 =================    def _on_click_check(self) -> None:        self.run_task(CookieManager.check)    # ================= 保存目录 =================    def _on_browse_save_dir(self) -> None:        current = self.save_dir_input.text().strip()        start_dir = current if current and os.path.isdir(current) \            else os.path.expanduser('~')        path = QFileDialog.getExistingDirectory(            self, '选择保存目录', start_dir        )        if path:            self.save_dir_input.setText(path)    def _on_open_save_dir(self) -> None:        path = self.save_dir_input.text().strip()        if not path:            QMessageBox.information(self, '提示', '请先设置保存目录')            return        try:            os.makedirs(path, exist_ok=True)        except OSError as e:            QMessageBox.warning(self, '无法创建目录', str(e))            return        # Windows 下用 explorer 打开        try:            if sys.platform.startswith('win'):                os.startfile(path)   # type: ignore[attr-defined]            elif sys.platform == 'darwin':                os.system('open "%s"' % path)            else:                os.system('xdg-open "%s"' % path)        except Exception as e:            QMessageBox.warning(self, '打开目录失败', str(e))    def _get_save_dir(self) -> str:        """返回可用的保存目录路径,未设置则回退到程序目录。"""        path = self.save_dir_input.text().strip()        if not path:            path = program_dir()            self.save_dir_input.setText(path)        try:            os.makedirs(path, exist_ok=True)        except OSError as e:            QMessageBox.warning(                self, '保存目录不可用',                '无法创建/访问目录:\n%s\n%s' % (path, e)            )            return ''        return path    # ================= ffmpeg =================    def _auto_fill_ffmpeg(self) -> None:        """启动时尝试从常见路径里挑一个能用的自动填入。"""        # 只检查环境变量 PATH + 常见路径,不再全盘扫描        candidates = VideoDownloader.candidate_ffmpeg(scan_drive=False)        for c in candidates:            if VideoDownloader.verify_ffmpeg(c):                self.ffmpeg_input.setText(c)                self.ffmpeg_status.setText('已自动填入,可用 ✅')                self.ffmpeg_status.setStyleSheet('color: green;')                return        self.ffmpeg_status.setText('未找到,请手动选择')        self.ffmpeg_status.setStyleSheet('color: #c00;')    def _on_browse_ffmpeg(self) -> None:        current = self.ffmpeg_input.text().strip()        start_dir = os.path.dirname(current) if current and os.path.isfile(current) \            else os.path.expanduser('~')        path, _ = QFileDialog.getOpenFileName(            self, '选择 ffmpeg 可执行文件', start_dir,            'ffmpeg (ffmpeg.exe ffmpeg);;所有文件 (*)'        )        if not path:            return        self.ffmpeg_input.setText(path)        self._check_ffmpeg_async(path)    def _on_check_ffmpeg_clicked(self) -> None:        path = self.ffmpeg_input.text().strip()        if not path:            QMessageBox.information(self, '提示', '请先填写或选择 ffmpeg.exe 路径')            return        self._check_ffmpeg_async(path)    def _check_ffmpeg_async(self, path: str) -> None:        self.ffmpeg_status.setText('检测中...')        self.ffmpeg_status.setStyleSheet('color: #888;')        threading.Thread(            target=self._check_ffmpeg_worker, args=(path,), daemon=True        ).start()    def _check_ffmpeg_worker(self, path: str) -> None:        ok = VideoDownloader.verify_ffmpeg(path, force=True)        self.ffmpeg_result_signal.emit(path if ok else '')    def _on_ffmpeg_checked(self, path: str) -> None:        if path:            self.ffmpeg_status.setText('可用 ✅')            self.ffmpeg_status.setStyleSheet('color: green; font-weight: bold;')            self._append_log('[ffmpeg] 检测通过: %s\n' % path)        else:            self.ffmpeg_status.setText('不可用 ❌')            self.ffmpeg_status.setStyleSheet('color: #c00; font-weight: bold;')            self._append_log('[ffmpeg] 检测失败,请重新选择 ffmpeg.exe\n')    def _get_ffmpeg_path(self) -> str:        path = self.ffmpeg_input.text().strip()        if not path:            QMessageBox.warning(                self, '缺少 ffmpeg',                '请先在上方选择 ffmpeg.exe 的完整路径。\n'                '可以从 https://www.gyan.dev/ffmpeg/builds/ 下载。'            )            return ''        if not VideoDownloader.verify_ffmpeg(path):            QMessageBox.warning(                self, 'ffmpeg 不可用',                '指定的 ffmpeg 无法运行:\n%s\n请重新选择或换一个版本。' % path            )            return ''        return path    # ================= 通用任务调度 =================    def run_task(self, task_fn, on_done=None) -> bool:        if self.busy:            QMessageBox.information(self, '提示', '已有任务在运行,请稍候')            return False        self.busy = True        self._set_buttons_enabled(False)        self.statusBar().showMessage('任务进行中...')        self._pending_callback = on_done        def wrapper():            try:                result, error = task_fn(), ''            except Exception as e:                result, error = None, str(e) or type(e).__name__                traceback.print_exc()            self.task_done_signal.emit(result, error)        threading.Thread(target=wrapper, daemon=True).start()        return True    def _on_task_done(self, result, error: str) -> None:        self.busy = False        self._set_buttons_enabled(True)        self._refresh_login_state()        if error:            self.statusBar().showMessage('任务出错')            tip = '未登录或 Cookie 失效,请先点「扫码登录」' \                if 'cookie.json' in error or 'SESSDATA' in error else error            QMessageBox.warning(self, '任务出错', tip)        else:            self.statusBar().showMessage('任务完成')        cb = self._pending_callback        self._pending_callback = None        if cb:            cb(result, error)    def _set_buttons_enabled(self, enabled: bool) -> None:        for btn in self.all_buttons:            btn.setEnabled(enabled)    # ================= 单视频下载 =================    def _on_click_download(self) -> None:        bvid = self.single_input.text().strip()        if not bvid:            QMessageBox.information(self, '提示', '请先填写 BVID 或视频链接')            return        ffmpeg = self._get_ffmpeg_path()        if not ffmpeg:            return        save_dir = self._get_save_dir()        if not save_dir:            return        def task():            # VideoDownloader 用相对路径写临时文件/输出,切目录到保存目录            with chdir(save_dir):                VideoDownloader(bvid, ffmpeg).run()        self.run_task(task)    # ================= 合集抓取 =================    def _on_click_crawl(self) -> None:        """只抓取链接,不弹下载选择框。"""        bvid = self.collection_input.text().strip()        if not bvid:            QMessageBox.information(                self, '提示',                '请先填写 BVID / 视频链接 / UP主空间链接'            )            return        def do_crawl():            self.current_crawler = CollectionCrawler(bvid)            result = self.current_crawler.crawl()            self._crawled_input = bvid      # 记录本次抓取对应的输入            return result        def on_done(result, error):            if error or not result:                return            self._fill_links(result)            self._append_log(                '\n抓取完成: 共 %d 条链接,已填入下方列表。\n'                '如需下载,请点击「批量下载」按钮。\n\n' % len(result)            )        self.run_task(do_crawl, on_done)    def _on_click_batch_download(self) -> None:        """直接对已抓取的链接弹选择框下载,不再重新抓取。"""        if not self.current_crawler or not self.current_crawler.links:            QMessageBox.information(                self, '提示',                '还没有抓取到链接,请先点「抓取链接」。'            )            return        # 如果用户改了输入框内容,提醒一下当前列表还是旧的        current_input = self.collection_input.text().strip()        if current_input and current_input != self._crawled_input:            ret = QMessageBox.question(                self, '输入已变化',                '输入框内容与上次抓取时不同:\n'                '  上次抓取: %s\n'                '  当前输入: %s\n\n'                '是否使用「上次抓取的 %d 条链接」继续下载?\n'                '(选择"否"将重新抓取)'                % (self._crawled_input, current_input,                   len(self.current_crawler.links)),                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No            )            if ret != QMessageBox.StandardButton.Yes:                # 重新抓取                self._on_click_crawl()                return        self._show_select_dialog()    def _fill_links(self, links) -> None:        self.link_list.clear()        for link in links:            self.link_list.addItem(link)    def _show_select_dialog(self) -> None:        crawler = self.current_crawler        if not crawler or not crawler.links:            return        dlg = SelectVideosDialog(crawler.links, crawler.titles, self)        if dlg.exec() != QDialog.DialogCode.Accepted:            return        selected = dlg.selected_indices        crawler.links = [crawler.links[i] for i in selected]        crawler.titles = [crawler.titles[i] for i in selected]        ffmpeg = self._get_ffmpeg_path()        if not ffmpeg:            return        save_dir = self._get_save_dir()        if not save_dir:            return        self._append_log(            '\n开始批量下载 %d 个视频 -> %s\n' % (len(crawler.links), save_dir)        )        def task():            with chdir(save_dir):                crawler.download_all(ffmpeg)        self.run_task(task)    # ================= 关闭 =================    def closeEvent(self, event) -> None:        if self.busy:            ret = QMessageBox.question(                self, '退出', '任务还在进行,确定退出吗?',                QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No            )            if ret != QMessageBox.StandardButton.Yes:                event.ignore()                return        if self.qr_dialog is not None:            self.qr_dialog.close()        event.accept()# ----------------------------------------------------------------------# 入口# ----------------------------------------------------------------------def launch_gui() -> None:    os.chdir(program_dir())    app = QApplication(sys.argv)    app.setApplicationName('B站视频下载工具')    window = BiliMainWindow()    stream = EmittingStream()    stream.text_written.connect(window._append_log)    sys.stdout = stream    sys.stderr = stream    window.show()    try:        sys.exit(app.exec())    finally:        sys.stdout = sys.__stdout__        sys.stderr = sys.__stderr__if __name__ == '__main__':    launch_gui()

            这款「B站视频批量下载工具」,可自动完成原需手动翻查合集、逐P点开视频、逐个复制BV号、反复切换页面下载、再单独找工具合并音视频的一整天归档工作。从面对UP主几百个投稿、合集几十个分P,逐个点开、反复核对、不断重复机械操作的繁琐与崩溃,到只需填好BVID或视频链接或UP主空间链接、选好保存目录、指定ffmpeg路径、一键启动,工具即可自动识别输入类型、自动扫码登录获取高清画质、自动翻页抓取全部投稿列表、自动区分合集与分P与相关推荐、自动调用WBI签名接口拿到真实直链、自动挑选最高画质视频流与最高码率音频流、自动下载并调用ffmpeg无损合并为mp4、自动清理临时m4s文件、自动按序号命名输出文件,还能实时打印抓取与下载进度、完成后一键打开保存目录,全程无需人工干预,实时显示运行日志,工作效率获得指数级提升,终于解放了双手和时间!

            大佬们也可以举一反三,参照上面的代码思路根据自己工作中的实际情况来具体问题具体分析,实现自己定制化的需求。

            《该办公工具的应用场景:》

              一、内容运营 / 新媒体运营

              • 竞品账号监测:把对标 UP 主的全部投稿一键下载到本地,建立竞品视频库,方便逐条分析选题、封面、标题、时长和更新节奏

              • 选题素材收集:按 UP 主或合集批量抓取,快速攒出一批可参考的爆款视频,用于内部选题会

              • 跨平台搬运合规留存:需要授权或评估的二创素材,先本地归档一份,留痕备查

              • 账号内容备份:自己运营的 UP 主账号,定期批量下载全部投稿,防止被下架或误删后无法找回

              二、教育培训 / 知识付费

              • 课程资源归档:把 B站上的系列公开课、讲座合集一次性下载,做成内部学习资料库

              • 培训素材整理:讲师需要引用某段视频做案例,提前下载好,上课时离线播放不卡顿

              • 学员资料包制作:把优质教程视频按模块下载、编号整理,打包发给学员

              • 离线学习:出差、通勤、网络不稳定时,提前批量下载好,随时看

              三、市场调研 / 数据分析

              • UP 主投稿全景分析:批量抓取某 UP 主全部投稿,配合标题、时长做发布频率、内容分布统计

              • 行业趋势采样:针对某个垂类,抓取头部 UP 主视频,做选题方向和热度趋势的初步分析

              • 广告/合作素材留存:品牌方找 UP 主合作前,先把对方历史视频下载一份,评估内容调性

              四、电商 / 直播 / 短视频团队

              • 竞品直播回放留存:抓取竞品直播回放,分析话术、节奏、选品

              • 带货视频拆解:批量下载带货类视频,研究脚本结构和转化话术

              • 素材二次利用:把视频中的音频、画面拆出来,做二次创作或内部培训

              • 内容审核留痕:需要复核的视频,本地保存后逐条审查,比在线翻查更高效

              五、影视 / 剪辑 / 二创

              • 素材库建设:把需要的视频批量下载,作为剪辑素材源

              • 参考片拆解:下载竞品或参考视频,逐帧分析运镜、节奏、字幕样式

              • 混剪素材收集:按主题抓取多个 UP 主的视频,快速攒出混剪素材池

              结语

              当Python遇见办公,牛马打工人终于笑出了猪叫声

              【职场人必看】每天早上一睁眼,想到又要面对:

              1.📊 堆积如山的Excel表格

              2.📑 机械重复的复制粘贴

              3.✍️ 永远改不完的各类文档

              4.诸如此类的更多........

              是不是连Ctrl+Alt+Delete的心都有了?别慌!别急,摸鱼这位“职场外挂”已经带着Python代码来拯救你了!

              另外,本文的代码和打包好的 exe 已同步上传至 NAS 私有云盘。有需要的大佬请私信摸鱼,仅需9.9 元进群即可获取下载链接,链接统一放在群公告里。目前公众号九成以上的文章资源都已上传 NAS 云盘,仅群内大佬可免费下载!

              最后,臣无大佬,无以至今日;大佬无臣,无以摸大鱼。感谢各位大佬看到最后。码字不易,摸鱼更不易,还望抬抬贵手,关注、点赞、转发、评论四连走起。大佬们每一下支持,都是摸鱼下次继续更新原创干货的动力!

              相关学习资料