编写一个谷歌插件,用于油管视频下载

1,215 阅读3分钟

背景

有时候我们看油管视频的时候,想下载一些学习视频的时候,往往是得把视频的url复制好了,粘贴到一个些下载的网站(savefrom.net)下载,但是这些网站会有一定的限制,如视频格式和清晰度。我们可以尝试搭建一个通过谷歌插件去下载。

前提

你能科学上网或者有一台国外的云服务器。简单暴力的科学上网是,购买台国外的window系统服务器,远程登录,打开浏览器就可以访问油管了。

思路

如果有台国外的服务器,可以通过在服务器那边部署一个web服务,把要下载的地址给通过web服务给到服务器那里,然后通过yt-dlp命令下载油管视频。还有一种方式通过代理方式,yt-dlp是支持代理的。首先编写谷歌插件,谷歌插件那边要实现的就是根据地址栏是油管,发现有视频,就给视频的标题下添加下载的界面。视频的下载分辨率和下载的操作都是访问web后端,右侧栏就是显示下载进度和下载完成。

QQ20241129-151317.png

谷歌插件

  • content.js 打开和刷新页面加载时就加载,可以修改网页样式
  • service-worker.js 主要监听浏览器的事件
  • sidepanel.html 右侧栏界面

教程可以看官网有中文文档。github上有很多的demo案例,我这里使用的是右侧栏(cookbook.sidepanel-global)做模板,可以方便的看到下载任务。

QQ20241129-131900.png

manifest.json代码

{
  "manifest_version": 3,
  "name": "油管视频下载",
  "version": "1.0",
  "description": "油管视频下载插件",
  "permissions": [
    "activeTab",
    "scripting",
    "sidePanel",
    "tabs"
  ],
  "background": {
    "service_worker": "service-worker.js"
  },
  "action": {
    "default_title": "点击打开右侧栏"
  },
  "side_panel": {
    "default_path": "sidepanel.html"
  },
  "content_scripts": [
    {
      "css": [
        "./content/styles.css"
      ],
      "js": [
        "./content/content.js"
      ],
      "matches": [
        "https://www.youtube.com/*"
      ]
    }
  ],
  "icons": {
    "16": "images/icon-16.png",
    "48": "images/icon-48.png",
    "128": "images/icon-128.png"
  }
}

谷歌浏览器插件加载地址

后端

先在服务端安装yt-dlp

apt install yt-dlp

选择使用nodejs,web服务框架使用express,通过命令去调用yt-dlp下载,解析命令返回的结构,后端部分代码如下:

var express = require('express');
var router = express.Router();
var config = require('../config');
const fs = require('fs')
const { spawn } = require('child_process');

var task = [];

function sendWs(req, data) {
  req.wss.clients.forEach((client) => {
    if (client.readyState === 1) {
      client.send(JSON.stringify(data));
    }
  });
}

router.get('/download', function (req, res) {
  const queryObject = req.query;
  var taskIndex = 0;

  const command = 'yt-dlp';
  var args = ['--no-playlist', '-F', queryObject.url];
  if (config.socks) {
    args = [
      '--proxy', config.socks,
      '--no-playlist',
      '-F', queryObject.url
    ];
  }

  const ytDlpProcess = spawn(command, args);

  let outputBuffer = '';
  let read = false
  const info = [];
  let uid = '';
  const regex = /for\s(.*?):/

  ytDlpProcess.stdout.on('data', (data) => {

    console.log(`stdout: ${data.toString()}`);

    outputBuffer += data.toString();

    const lines = outputBuffer.split(/(\n|\r)/);
    outputBuffer = lines.pop();
    lines.forEach(line => {

      if (read) {
        const l = line.split('\|')[0];
        const d = l.split(/\s+/);
        if (d.length > 2) {

          if (d[1] == 'mp4') {
            var id = d[0];
            var ext = d[1];
            var resolution = d[2];
            var ch = d[4];

            if (resolution == 'audio') {
              if (!info.some(obj => obj.resolution == resolution)) {
                info.push({ id, ext, resolution,uid })
              }
            } else {
              if (!info.some(obj => obj.resolution == resolution && obj.ch == ch)) {
                info.push({ id, ext, resolution,uid, ch })
              }
            }
          }
        }
      }

      if (line.indexOf('-----------------------------') != -1) {
        read = true
      }else if(line.indexOf('[info] Available formats for')!=-1){
        const match = line.match(regex);
        if (match) {
          uid = match[1]
        }
      }

    });

  });

  ytDlpProcess.stderr.on('data', (data) => {
    console.error(`stderr: ${data.toString()}`);
  });

  ytDlpProcess.on('close', (code) => {
    info.sort((a, b) => {
      var x = a.ch? 1:0;
      var y = b.ch? 1:0;

      return y - x;
    });

    res.json({ code: 0, msg: 'success', data: info });
  });

  ytDlpProcess.on('error', (error) => {
    console.error(`Failed to start yt-dlp process: ${error.message}`);

  });

  //下载封面
  const regexes = [
    /.*\?v=([^&]+)/,
    /\/shorts\/(.*)$/,
  ];

  var videoId;
  for(var i = 0;i<regexes.length;i++){
    var match = queryObject.url.match(regexes[i])
    if(match){
      videoId = match[1];
      break;
    }
  }
  
  if(videoId){
    const imgCommand = 'yt-dlp';
    var imgArgs = ['--no-playlist','--write-thumbnail','--skip-download','-P', config.downloadPath,'-o',videoId ,queryObject.url];
    if (config.socks) {
      imgArgs = [
        '--proxy', config.socks,
        '--no-playlist',
        '--write-thumbnail', 
        '--skip-download',
        '-P', config.downloadPath,
        '-o',videoId,
        queryObject.url
      ];
    }

    const imgProcess = spawn(imgCommand, imgArgs);
  
    imgProcess.stdout.on('data', (data) => {
      console.log(`imgProcess: ${data.toString()}`);
    });
  
    imgProcess.stderr.on('data', (data) => {
      console.error(`imgProcess: ${data.toString()}`);
    });
  
    imgProcess.on('close', (code) => {
      console.log(`imgProcess: ${code.toString()}`);
    });
  
    imgProcess.on('error', (error) => {
      console.error(`imgProcess Failed to start yt-dlp process: ${error.message}`);
    });
  }

});

module.exports = router;

为了保证页面进度的实时更新,使用websocket推送信息

const WebSocket = require('ws');  

const createWebSocketServer = (httpServer) => {  
  const wss = new WebSocket.Server({ server: httpServer });  

  wss.on('connection', (ws) => {  
    console.log('Client connected');  

    ws.on('message', (message) => {  
      console.log(`Received: ${message}`);  
      // Echo the received message back to the client  
      ws.send(`Echo: ${message}`);  
    });  

    ws.on('close', () => {  
      console.log('Client disconnected');  
    });  
  });  

  return wss;  
};  

module.exports = createWebSocketServer;  

图标

图标可以通过阿里的icon,找一个图标

QQ20241129-131034.png

可以去通过IconKitchen调整一下图标

QQ20241129-131331.png

通过python去生成一下对应尺寸16,48,128

from PIL import Image

if __name__ == '__main__':
    # 原始图片路径
    input_image_path = 'icon-512.png'

    # 读取原始图片
    original_image = Image.open(input_image_path)

    # 定义需要生成的尺寸列表
    sizes = [
        (16, 16),
        (48, 48),
        (128, 128)
    ]

    # 遍历尺寸列表,为每个尺寸创建并保存图片
    for size in sizes:
        # 调整图片大小
        resized_image = original_image.resize(size, Image.LANCZOS)  # 使用 LANCZOS 替换 ANTIALIAS

        # 保存图片,文件名包含尺寸信息
        output_image_path = f'icon-{size[0]}.png'
        resized_image.save(output_image_path)

        print(f"Saved {output_image_path}")

遇到问题

1.express使用默认端口,启动Websocket有问题,更换端口解决,好奇怪更换端口了默认3000端口还是可以访问的。

2.使用内联事件会违反了内容安全策略(CSP),就是html标签上不能使用onclick等事件监听,通js去监听事件解决。

效果

output.gif

完整代码

ydownload: 油管视频下载插件