使用 nodejs 开发命令行小工具 - 谷歌翻译字幕

2,417 阅读6分钟

使用 nodejs 可以非常方便的开发命令行工具,来解决我们遇到的一些问题。

现在就让我们看看如何使用 nodejs 开发一个把 .srt 格式的字幕文件翻译成中文和外语的双语字幕,然后在把它发布到 npm 仓库中。

准备

在安装好 nodejs 环境后,进入到项目目录后使用

npm init -y

来,创建 package.json 文件,然后我选择把主文件放入 src 下。

├── package.json
└── src
    └── fysrt.js

然后我们需要安装如下依赖

commander.js

commander.js 可以帮助我们解析命令行参数和注册子命令,显示帮助信息,版本号。。。

var program = require('commander');

program
  .version('0.1.0')
  .option('-f, --foo', 'enable some foo')
  .option('-b, --bar', 'enable some bar')
  .option('-B, --baz', 'enable some baz');

program.on('--help', function(){
  console.log('')
  console.log('Examples:');
  console.log('  $ custom-help --help');
  console.log('  $ custom-help -h');
});

program.parse(process.argv);
Usage: custom-help [options]

Options:
  -h, --help     output usage information
  -V, --version  output the version number
  -f, --foo      enable some foo
  -b, --bar      enable some bar
  -B, --baz      enable some baz

Examples:
  $ custom-help --help
  $ custom-help -h

Inquirer.js

Inquirer.js 可以让命令行与用户进行交互。

signale

signale 可以用来打印信息到屏幕

fs-extra 和 klaw

fs-extra 是对 fs 的包装,它提供了 promise 支持,还有一些有用的功能。

klaw 原本属于 fs-extra 的一个功能,但是现在它被抽离出来,它可以用来遍历目录。

translate-google-cn

translate-google-cn 是我把 google-translate-api 稍微改了一下。

  • google.com 变成 google.cn
  • 修改了获取 token 的正则(原来的不起作用了)。
  • 添加了 cookie ,这样更不容易被 google 封 ip

更多

想要了解更多的命令行工具可以参考 这里

执行脚本

现在我们可以使用 $ node src/fysrt.js 来执行这个文件,但是这很麻烦,我们想使用 $ fysrt 来直接执行这个文件。

首先我们在文件开头加入

#!/usr/bin/env node

不加的话我们的脚本文件,就不会使用 node 执行它。

bin

然后我们在 package.json 中加入 bin 字段

使用 bin 字段可以将命令名和文件名映射,在安装时 npm 会将我们的可执行文件符号链接到 {prefix}/bin (全局安装)或 ./node_modules/.bin/ 本地安装,这样我们就不用输入路径来执行文件了。

{
    "name": "fysrt",
    "main": "src/fysrt.js",
    "bin": "src/fysrt.js"
}

bin 是一个字符串时,代表命令名与包名同名。

它还可以安装多个命令。

{
  "bin": {
    "c1": "bin/c1.js",
    "c2": "bin/c2.js"
  }
}

这样安装就有 c1 与 c2 两个命令。

npm link

我们想让上面设置的 bin 起作用,可以发布和安装包,npm 才会帮我们做符号链接,但是这样太麻烦,我们还可以使用 npm link 命令。

它可以简写为 npm ln,我们直接去项目目录执行 npm link 就可以了。

它会根据 package.json 的配置,在 {prefix}/lib/node_modules/<package> 中创建一个符号链接,它还会将包中的任何 bin 文件链接到 {prefix}/bin/{name}

如果我们想把它当作一个普通的包使用,我们可以去要用到它的项目文件夹,执行 npm link fysrt,它会在该项目文件夹下的 node_modules 中链接到全局的 fysrt。我们对 fysrt 的修改都可以直接映射到该项目的 fysrt。

当我们想取消链接时可以执行 npm unlink fysrt

srt 字幕文件

srt 字幕文件中的一句字幕,分为三部分。

650
00:45:07,650 --> 00:45:09,110
Fifteen minutes.

651
00:45:10,650 --> 00:45:20,110
Fifteen minutes.Fifteen minutes.Fifteen minutes.

索引编号,时间,和字幕。字幕前面可能会有一些特效代码,如 {\an6} 等等命令,或者还有 html 形式的。

每句字幕使用两个换行符分隔。

代码编写

我们使用 commander.js 来处理命令行参数。

commander
  .version(version)
  .option('-d, --delete', '删除原文件')
  .option('-s, --single', '单语字幕,而不是双语字幕')
  .option('-f, --from <lang>', '原始语言,默认 auto')
  .option('-t, --to <lang>', '翻译成什么语言,默认 zh-cn')
  .option(
    '-T, --time <time>',
    '每个字幕文件的翻译时间间隔 毫秒,默认 3000 毫秒'
  )
  .option(
    '-S, --size <size>',
    '一次给 google api 翻译的文本量,默认一次 50 行字幕'
  )
  .on('--help', () => {
    console.log();
    console.log('Examples:');
    console.log('  $ fysrt ./subtitles');
    console.log('  $ fysrt -d a.srt');
    console.log('  $ fysrt -f en a.srt');
  })
  .parse(process.argv);

然后我们可以使用 commander.args[0] 获取到输入的 目录或者字幕文件。

当没有目录或文件时,我们可以提示是否翻译当前目录下的所有字幕文件。

const ans = await inquirer.prompt([
  {
    type: 'confirm',
    name: 'dir',
    message: '翻译当前文件夹下的所有字幕文件?',
    default: false
  }
]);

if (!ans.dir) return;

如果是文件夹的话,我们使用 klaw 遍历目录,找到所有 srt 文件。

const files = [];
walk(target)
  .on('data', ({ path: p }) => p && p.endsWith('.srt') && files.push(p))
  .on('end', async () => {
    const len = files.length;
    if (len === 0) {
      signale.error(`目录下没有 .srt 文件 -> ${target}`);
      process.exit(1)
    }
    
    // ...
  });

然后我们读取字幕文件然后解析它,由于有些 srt 字幕文件不严格符合规范, 所以需要一行一行的判断这一行是时间还是字幕。

const lines = rawData.trim().split(/(?:\r\n|\n|\r)/); // 获取所有行
const data = [];

for (let i = 0, len = lines.length; i < len; i++) {
  let l = lines[i].trim();
  // eslint-disable-next-line eqeqeq
  if (!l || ~~l !== 0 || l == 0) continue; // 如果是空行或者是编号行则跳过

  if (/^(?:\d+:){2}\d+,\d+\s-->\s(?:\d+:){2}\d+[,.]\d+$/.test(l)) {
    data.push([l]); // 处理时间行
  } else if (/^\d+:\d+\.\d+\s-->\s\d+:\d+\.\d+$/.test(l)) {
    data.push([ // 处理 vtt 文件格式的时间行
      l
        .replace(/\./g, ',')
        .split(' --> ')
        .map(s => '00:' + s)
        .join(' --> ')
    ]);
  } else { // 处理字幕行
    l = l.replace(/^(?:\{\\\w.*\})+/, ''); // 去除特效代码
    let last = data[data.length - 1];
    if (last.length === 1) {
      last.push(l);
    } else {
      last[1] = last[1] + '\n' + l;
    }
  }
}

然后我们就使用谷歌翻译

const requests = [];
for (let i = 0, len = textArr.length; i <= len; i += size) {

// textArr 就是上面 data 的 data.map(d => d[1]),size 是上面命令行传入的参数,默认 50 行
// 因为翻译是 get 请求,一次性太多文字,谷歌服务器会报 413 错

    requests.push(
      translate(textArr.slice(i, i + size).join('\n\n'), {
        from,
        to
      })
    );
}

const res = await Promise.all(requests); // 并发的去翻译

最后把得到的翻译组合起来,然后写入到文件中就可以了。

const translate = res
      .map(r => r.text.split('\n\n'))
      .reduce((acc, val) => {
        acc.push(...val);
        return acc;
      }, []);
data
    .map(
      (d, i) =>
        `${i + 1}\n${d[0]}\n${translate[i]}${keep ? '\n' + d[1] : ''}`
    )
    .join('\n\n') + '\n\n'

源码

上面的代码只是这个小工具的核心部分,

完整的代码可以参考 github 仓库

发布 npm 包

npm 包分为 unscoped 和 scoped,unscoped 就是我们常见的 npm 包,scoped 就是包前面有一个 @ 符号的包比如 @vue/cli

scoped 包可以分为团体和个人。

scoped 的包默认是私有的,但需要付费。可修改 package.json 文件让它是公开的。

要发布包到 npm 我们首先要注册一个 npm 帐号。

然后登入账户

npm login

再发布包

npm publish

这样就可以了。但是有可能报错,比如仓库中已经有这个包名了,这时只有换一个名字,或者发布 scoped 包。

我们可以修改 package.json

{
    "name": "@npm账户名称/包名"
}

账户名可以通过

npm whoami

查询。

然后我们在发布公共包

npm publish --access public

迭代包

我们可以使用 npm version 命令递增版本号。

npm 版本号是 major.minor.patch 主版本.次版本.补丁版本。

npm version patch

我们去查看 package.json 就会发现 version 字段改变了。

然后再发布包

npm publish

废弃 删除 包

我们可以废弃一个包的版本或者整个包。

npm deprecate <pkg>[@<version>] <message>

npm 不建议删除包,因为包可能被别人引用。所以 npm 做了限制

  • 删除的版本 24 小时后方可重发
  • 包发布 72 小时之内才可删除
npm unpublish pkg --force