Node.js开发狗屁不通文章生成器

214 阅读3分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天

前言

本文将介绍如何使用原生Node.js开发一个狗屁不通文章生成器,采用命令行的方式运行,最终以文件的形式保存到本地目录中。

1. 项目目录结构

首先创建项目目录如下:

├── corpus //存放语料库
│   └── data.json  //预料文件
├── index.js //项目入口
├── lib  // js文件
├── output  // 存放生成的文章
└── package.json  

语料库就是用来生成文章的原料,有名人名言和一些废话等等:

image.png

由于是json格式文件,无法直接import引入。可以通过fs模块的读文件方法加载进来。

import { readFileSync } from 'fs'
import { fileURLToPath } from 'url'

const __dirname =  dirname(fileURLToPath(import.meta.url)) //当前文件所处文件夹路径

function loadCorpus(src) {
  const path = resolve(__dirname,src) //拼接路径
  const data = readFileSync(path,{encoding:'utf-8'}) //读取文件
  return JSON.parse(data)
}

语料加载进来后,要生成文章,我们需要随机组合这些废话。可以看到,有些废话中,有花括号括起来的占位符。所以,还需要通过正则匹配的方式进行替换。

2. 随机模块

既然要随机组合,我们先来实现一个随机选取句子的模块:

//随机生成整数,用于数组下标
const randomInt = function(min,max) {
  const p = Math.random()
  return Math.floor(min * (1 - p) + max * p) // 线性插值取值
}
// 随机获取废话
function createRandomPicker(arr) { 
  arr = [...arr] //拷贝数组,避免污染
  function randomPick() {
    const len = arr.length - 1
    const index = randomInt(0,len)
    const picked = arr[index]; //注意这个分号必须加
    [arr[index],arr[len]]  = [arr[len],arr[index]] // 将获取到的废话和数组末尾互换
    return picked
  }
  randomPick() //丢弃第一次选取结果
  return randomPick
}
export {randomInt,createRandomPicker}

通过线性插值的方式,保证随机数一定在[min,max)范围内。为什么这样算出来?这涉及到线性插值的知识,感兴趣的自行了解。这里只做简单验证:

因为p的范围为 [0,1). 假设p = 0, 那么得到 min ; 假设 p = 1 (实际上取不到1), 那么 得到的是max. 因为min * (1 - p) + max * p 是个一次函数且单调递增,所以得到的值范围一定是[min,max).

随机选取废话时,将获取到的废话和数组末尾互换。因为随机数的范围是[0,arr.length - 1),那么最后的句子一定取不到,所以将每次取到的句子放在最后,可以避免出现连续两次选取相同句子的情况。而之所以丢弃第一次选取结果,是让最开始位于最后的句子也有被选到的机会,保证了随机性。

createRandomPicker是一个高阶函数,可以将传入的arr保存在闭包中。这样,当我们需要随机生成多个句子时,也只需要传一次:

const getSentence = createRandomPicker(corpus.famous)
for (let i = 0; i < 10; i++) {
    getSentence()
}

3. 生成文章

句子组成段落,段落再组成文章。所以要生成文章,实现的功能顺序应该是:句子-->段落-->文章。

3.1. 生成句子

语料库中有很多不同类型的句子或短语,将选取每种类型的句子封装成一个个函数,方便后面使用。

 const { famous, bosh_before, bosh, said, conclude } = corpus
 const [pickFamous, pickBoshBefore, pickBosh, pickSaid, pickConclude] = [famous, bosh_before, bosh, said, conclude].map(item => {
    return createRandomPicker(item)
 })

3.2 将句子组成段落

段落由句子组成。其中,名人名言占比20%,带前置从句(如“即热如此,”,“一般来说,”)的废话占50%,废话占30%。

//通过正则匹配句子中的占位符,替换成给定的文字。
function createSentence(pick,replacer) {
  let ret = pick(); // 返回一个句子文本
  for(const key in replacer) { // replacer是一个对象,存放替换占位符的规则
    // 如果 replacer[key] 是一个 pick 函数,那么执行它随机取一条替换占位符,否则将它直接替换占位符
    ret = ret.replace(new RegExp(`{{${key}}}`, 'g'),
      typeof replacer[key] === 'function' ? replacer[key]() : replacer[key]);
  }
  return ret;
}
//生成段落
const title = '随便先设一个' //后续通过用户输入
const sectionLength = randomInt(200, 500)
while (section.length < sectionLength) {
  const n = randomInt(0, 100)
  if (n < 20) {
    //名人名言
    section += createSentence(pickFamous{said:pickSaid,conclude:pickConclude})
  } else if (n < 50) {
    //带前置从句废话
    section += createSentence(pickBoshBefore,{title}) + createSentence(pickBosh, {title})
  } else {
    //废话
    section += createSentence(pickBosh, {title})
  }
}

3.3 将段落组成文章

文章由多个段落组成,段落的个数使用随机数。在上面生成段落的while循环上,再套一层循环,并封装成函数导出。

export function generateArticle(title, { corpus, min = 6000, max = 10000 } = {}) {
  const articleLength = randomInt(min, max)
  let totalLength = 0
  let article = []
  while (totalLength < articleLength) {
    let section = ''
    const sectionLength = randomInt(200, 500)
    //生成单个段落部分
    while (section.length < sectionLength ) {
        //...
    }
    totalLength += section.length
    article.push(section)
  }
  return article
}

4. 交互方式

4.1 普通命令行生成文章

在前面编写代码的过程中,文章的标题title是暂时写死的。实际使用时,可能是希望由用户确定文章标题。另外,文章的字数最好也由用户提供。这些数据可以通过命令行参数的方式提供:

node index.js --title 中午吃什么 --min 1000 --max 2000

这里需要用到nodejs内置的process模块.通过它来获得node进程相关的信息,比如运行node程序时的命令行参数。

function parseOptions(options = {}) {

  const argv = process.argv //获取命令行参数
  for(let i = 2; i < argv.length; i++) {
    const cmd = argv[i ]
    const value = argv[i + 1]
    if(cmd === '--title') {
      options.title = value
    } else if (cmd === '--main') {
      options.min = Number(value)
    } else if (cmd === '--max') {
      options.max = Number(value)
    }
  }
  return options
}

4.2 对话式命令行生成文章

前面的普通命令行方式虽然可以实现功能,但是用户体验不佳。用户可能并不知道要提供什么信息,所以可以用对话的形式,引导用户提供。

可以有两种方式可以获取用户输入内容。

第一种:利用process模块提供stdin标准输入流对象,采用监听readable事件的方式。

export function interact(questions) {
  // questions 是一个数组,内容如 {text:"请输入文字标题", value:"缺省值"}
  process.stdin.setEncoding('utf8');
  return new Promise((resolve) => {
    const answers = [];
    let i = 0;
    let {text, value} = questions[i++];
    process.stdout.write(`${text}(${value})`) //通过标准输出流接口向控制台输出问题和缺省值
    process.stdin.on('readable', () => {
      const chunk = process.stdin.read().slice(0, -1);//截取掉换行符
      answers.push(chunk || value); // 保存用户的输入,如果用户输入为空,则使用缺省值
      const nextQuestion = questions[i++];//继续下一个问题
      if(nextQuestion) { //如果还有问题,继续监听用户输入
        process.stdin.read();
        text = nextQuestion.text;
        value = nextQuestion.value;
        process.stdout.write(`${text}(${value})`)
      } else { // 如果没有,resolve掉结束监听
        resolve(answers);
      }
    });
  });
}

第二种:采用readline模块的方式

可以通过readline创建一个可交互的命令行对象,这个命令行对象的question方法可以接受一个字符串(用于输出)和一个回调函数(用于接收输入)。将它封装成一个promise对象,向控制台输出问题后等待用户输入。

import readline from 'readline';

function question(rl, {text, value}) {
  const q = `${text}(${value})\n`;
  return new Promise((resolve) => {
    rl.question(q, (answer) => {
      resolve(answer || value);
    });
  });
}

export async function interact(questions) {
  const rl = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
  });
  const answers = [];
  for(let i = 0; i < questions.length; i++) {
    const q = questions[i];
    const answer = await question(rl, q); // 等待问题的输入
    answers.push(answer);
  }
  rl.close();
  return answers;
}

对话效果:

image.png

5. 保存文章到本地

最后我们将生成的文章写入文件,保存到本地目录。

通过fs模块的写方法将文章写入到文件中:

import { existsSync, mkdirSync,  writeFileSync } from 'fs'

function saveToFile(title,article) {
  const outputDir = resolve(__dirname,'output')
  const time = dayjs().format('|YYYY-MM-DD|HH:mm:ss')
  const outputFile = resolve(outputDir,`${title}${time}.txt`)
  if(!existsSync(outputDir)){
    mkdirSync(outputDir)
  }
  const text = `${title} \n\n ${article.join('\n    ')}`
  writeFileSync(outputFile,text)
  return outputFile
}

总结

  1. 采用线性插值的方式获取某一范围内的随机数;
  2. 随机选取句子时,需要避免连续两次取到相同句子的情况;
  3. 通过process.argv获取命令行参数,实现带参命令;
  4. 通过标准输入输出流和readline模块实现了对话式命令行;