开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 1 天
前言
本文将介绍如何使用原生Node.js开发一个狗屁不通文章生成器,采用命令行的方式运行,最终以文件的形式保存到本地目录中。
1. 项目目录结构
首先创建项目目录如下:
├── corpus //存放语料库
│ └── data.json //预料文件
├── index.js //项目入口
├── lib // js文件
├── output // 存放生成的文章
└── package.json
语料库就是用来生成文章的原料,有名人名言和一些废话等等:
由于是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;
}
对话效果:
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
}
总结
- 采用线性插值的方式获取某一范围内的随机数;
- 随机选取句子时,需要避免连续两次取到相同句子的情况;
- 通过process.argv获取命令行参数,实现带参命令;
- 通过标准输入输出流和readline模块实现了对话式命令行;