携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情。
缘由
起源来自一次很无聊的掘金产品功能确认,临时起意说要评论区抽奖,像极了我们的产品经理。 详见 新系列文章内容有奖征集
然后,我想一周后随便写一个随机数,就能得出,谁得奖了,毕竟奖品只有一个,参与人数也没超过10个,猜拳选,都花不了几个时间。
看掘金酱抽奖,好像是用的一个在线抽奖软件。
不然我自己也搞一个玩玩咯。
用户侧使用
如果你并不关心如何实现,你只想用这个工具,你可以在你的命令行中键入 npx jjcj
。
按提示输入或者选择即可选出中奖者。
info - 欢迎试用掘金评论区抽奖系统,现在开始抽奖...
✔ 请输入关联文章? … 7127209370013663245
event - 获取掘金文章评论数据:0~50
event - 预计进度:10/10
info - 本次抽奖的关联文章是《7127209370013663245》 共有 10 条评论
✔ 是否排除重复评论数,即每个朋友仅有一次参与机会,多次评论无效? … 否 / 是
event - 过滤重复的评论数,删除同一个朋友发布的多条评论...
info - 抽奖评论数剩余 9 条
✔ 请输入本次抽奖设置档次,如仅需抽出 1 人,请输入 1
如有设置1等奖,2等奖等,请用英文字符逗号隔开
如 1,3,5 表示抽取 1档1人,2档3人,3档5人 … 1
ready - 正在抽奖中,请稍后...
info - 获得 1 档的人是:
红尘炼心 - https://juejin.cn/user/254742429175352
复制代码
初始思路
获取抽奖时刻的时间戳,对 1000 取商,得到当前秒数的时间戳,然后对参与人数取余,就得出了获奖选手。
const key = parseInt(new Date().getTime() / 1000,10) % data.length;
const c = data[key];
复制代码
好处就是不用动脑子,只要公布了抽奖时间,每个人都能得到一样的结果。比如上次文章发布时间是 2022年08月02日 17:32:39
一周后开奖,也是就是开奖时间是 2022年08月08日 17:32:39
,套上公式,可以得到。
const key = parseInt(new Date('2022-08-09T09:32:39.000Z').getTime() / 1000, 10) % 8;
// 7
复制代码
数一下评论区,去重之后,数一下就能得到本次获奖的朋友。
缺陷
说了上面的方法,最大的好处就是不用动脑子,但是就是不用动脑子,所以稍微动下脑子,就能看出一些问题。比如对参与人数求余,余数其实是从 0 开始计数的。另外这个唯一值就只能选出一个,如果说想选出三个,那就没法用了。
抽奖
抽奖函数
抽奖函数其实很简单,核心就是 m 选 n,比如10个人选2个,100个人选3个。
const luckDraw = (length: number, num: number) => {
const lucks: number[] = [];
while (num) {
const luck = Math.floor(Math.random() * length);
if (!lucks.includes(luck)) {
lucks.push(luck);
num--;
}
}
return lucks;
};
luckDraw(10,2); // [2,4]
luckDraw(100,3);// [66,43,71]
复制代码
小技巧,不少朋友在写类似的方法的时候,会先获取全部的数据,然后再从数据里面去抽取。但是,这里仅仅使用数据的角标抽奖,等选出中奖角标,再去匹配数据,取得中奖者的数据。通过小数据的操作,会让内存的使用降到最低。
多档奖项
多档奖项设置也是比较常见的抽奖需求,比如需要抽出 1 等奖 1 名,二等奖 3 名,三等奖 5 名等。
这里使用比较简单的用户交互,让用户输入想要抽取的奖项数,直接用英文字符的逗号连接即可。
如上述需求,可输入 1,3,5
完成。
修改抽奖函数,将第二参数设置成字符串。使用逗号连接最常见的问题就是中英文符号错误,且每个被连接的应该为一个数字,因此我们直接使用 try catch
包裹,当解析错误的时候,告知用户。主要会出现错误的是 parseInt
。
const luckDraw = (length: number, num: string, repeat: boolean = false) => {
try {
const numList = `${num}`.split(",");
const luckList: number[][] = [];
numList.forEach((item) => {
let n = parseInt(item, 10);
const lucks: number[] = [];
while (n) {
const luck = Math.floor(Math.random() * length);
if (!lucks.includes(luck) && checkRepeat(repeat, luck)) {
lucks.push(luck);
n--;
}
}
luckList.push(lucks);
});
return luckList;
} catch (error) {
logger.error("奖项设置错误,请重试!");
process.exit();
}
};
复制代码
这么处理将获得了一个新的 luckList
,是一个二维的字符数组。
luckDraw(10,'2,1', false); // [[2,4],[6]]
复制代码
处理异常数据
比较常见的异常是奖项设置错误,这里需要分允许重复中奖和不允许重复中奖两种情况。比如总的评论数只有 3 条,却要抽 10 个人。其实可以认定为 3 个人都中奖了,没必要抽,但是还是需要将中奖名单打印出来。
修改抽奖函数,注意奖项总数大于参与人数且不允许重复中奖的时候,会中断程序,因为这是一个预知的错误。而同一档的奖项大于参与人数,表示所有人都中奖了,后续依旧需要打印中奖者名单,所以不会中断程序。
const luckDraw = (length: number, num: string, repeat: boolean = false) => {
try {
const numList = `${num}`.split(",");
let sum = 0;
const luckList: number[][] = [];
numList.forEach((item) => {
let n = parseInt(item, 10);
sum = sum + n;
const lucks: number[] = [];
if (!repeat && sum > length) {
logger.error(
"奖项设置错误: 当前设置奖项总数大于参与人数,请允许重复中奖!"
);
process.exit();
}
if (n > length) {
logger.warn(
"奖项设置错误: 当前设置奖项总数大于参与人数,所有人都中奖了!"
);
n = length - 1;
while (n) {
lucks.push(n);
n--;
}
// 从 0 计数
lucks.push(n);
}
while (n) {
const luck = Math.floor(Math.random() * length);
if (!lucks.includes(luck) && checkRepeat(repeat, luck)) {
lucks.push(luck);
n--;
}
}
luckList.push(lucks);
});
return luckList;
} catch (error) {
logger.error("奖项设置错误,请重试!");
process.exit();
}
};
复制代码
数据
数据通过掘金评论接口获取,这里需要注意的是请求间隔和每次允许请求的数量。评论接口的 limit
最大有效值为 50
。这意味着我们要分页请求数据。
请求方法就不写了,很简单的 fetch 请求,处理好 cursor
和 has_more
就可以了。
这里分享一下等待函数。
const sleep = (t: number) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(t);
}, t);
});
};
// 先睡 1 秒
await sleep(1000);
复制代码
拿一个评论数比较多的文章做测试:
event - 获取掘金文章评论数据:0~50
event - 预计进度:50/1505
// ...
event - 获取掘金文章评论数据:1500~1550
event - 预计进度:1505/1505
info - 本次抽奖的关联文章是《7123120819437322247》 共有 1505 条评论
复制代码
制定抽奖流程
有了数据和核心的抽奖函数,接下来的事情就变得很简单了。只要建立一个 cli 工具,将数据和抽奖函数放进去就可以完成了。
工具
1、 logger 日志使用 umijs/utils
中的 logger
,好处就是方便,而且会生成对应的日志文件。
2、 chalk 控制台加点颜色,看起来更好看
3、 prompts 是一个控制台的交互系统,用于完善抽奖流程的交互行为,比如接受用户输入,让用户选择 是/否
等。
抽奖流程
简单的制定一下抽奖流程
1、欢迎试用掘金评论区抽奖系统,现在开始抽奖...
logger.info("欢迎试用掘金评论区抽奖系统,现在开始抽奖...");
复制代码
2、请输入关联文章?让用户输入关联的文章 id
const { itemId } = await prompts({
type: "text",
name: "itemId",
message: "请输入关联文章?",
});
复制代码
3、自动获取文章评论数据
let data: any = [];
try {
if (!itemId) {
throw new Error("请输入关联文章。");
}
const agent = new https.Agent({
rejectUnauthorized: false,
});
let cursor = "0";
let has_more = true;
while (has_more) {
const body = {
item_id: itemId,
// item_id: "7127209370013663245",
item_type: 2,
cursor,
// 掘金接口一次最多获取 50 条评论
limit: 50,
sort: 0,
client_type: 2608,
};
logger.event(
`获取掘金文章评论数据:${cursor}~${parseInt(cursor, 10) + 50}`
);
const response = await fetch(
"https://api.juejin.cn/interact_api/v1/comment/list",
{
method: "post",
body: JSON.stringify(body),
agent,
headers: { "Content-Type": "application/json" },
}
);
const res = await response.json();
if (res.err_no !== 0) {
has_more = false;
logger.error(res.err_msg);
} else {
data = data.concat(res.data);
cursor = res.cursor;
has_more = res.has_more;
}
logger.event(`预计进度:${cursor}/${res.count}`);
// 睡一会儿
await sleep(1000);
}
} catch (error) {
// eslint-disable-next-line no-console
logger.error("获取 juejin 文章数据出错:", error);
process.exit();
}
if (data.length === 0) {
logger.error("获取 juejin 文章数据出错或评论数为 0");
process.exit();
}
logger.info(`本次抽奖的关联文章是《${itemId}》 共有 ${data.length} 条评论`);
复制代码
4、是否排除重复评论数,即每个朋友仅有一次参与机会,多次评论无效
const { filter } = await prompts({
type: "toggle",
name: "filter",
message: "是否排除重复评论数,即每个朋友仅有一次参与机会,多次评论无效?",
initial: true,
active: "是",
inactive: "否",
});
if (filter) {
// 过滤重复的评论数
data = filterArray(data);
logger.event("过滤重复的评论数,删除同一个朋友发布的多条评论...");
logger.info(`抽奖评论数剩余 ${data.length} 条`);
}
复制代码
5、请输入本次抽奖设置档次?让用户输入抽奖的奖项设置,规则前面提到过,如 1,3,5
const { grade = "" } = await prompts({
type: "text",
name: "grade",
message:
"请输入本次抽奖设置档次,如仅需抽出 1 人,请输入 1\n如有设置1等奖,2等奖等,请用英文字符逗号隔开\n如 1,3,5 表示抽取 1档1人,2档3人,3档5人",
});
复制代码
6、如果用户设置了多档奖项,则需要指定是否允许重复中奖多个档位奖项
let isRepeat = false;
if (grade.includes(",")) {
const { repeat } = await prompts({
type: "toggle",
name: "repeat",
message:
"是否允许重复中奖多个档位奖项,如中过1等奖的人是否允许继续中2等奖?",
initial: false,
active: "是",
inactive: "否",
});
isRepeat = repeat;
}
复制代码
7、调用抽奖函数选出中奖者
logger.ready("正在抽奖中,请稍后...");
const luskList = luckDraw(data.length, grade, isRepeat);
复制代码
8、 打印中奖人名单
luskList.forEach((luck, i) => {
logger.info(`获得 ${i + 1} 档的人是:`);
luck.forEach((item: any) => {
const user = data[item];
console.log(
chalk.redBright(user?.user_info?.user_name),
"-",
chalk.blue(`https://juejin.cn/user/${user?.user_info.user_id}`)
);
});
});
复制代码
封装 CLI
这个内容在之前的手写框架系列中,有提到过,这里再简单的说明一下。
1、先查一下可用报名,比如掘金抽奖 - 直接用首字母 jjcj
访问一下 https://www.npmjs.com/package/jjcj
如果页面显示 404
则表示大概率这个报名可用。
2、新建一个空文件夹 jjcj
执行 npm init -y
,生成 package.json 文件。
3、安装使用到的模块
pnpm i @umijs/utils [email protected] prompts typescript
复制代码
需要注意的是 node-fetch 需要指定使用 2.x 的版本。因为新版本,好像需要 node 升级到 18,不会会有一个
require
,没细看原因。当然你也可以用其他的请求方式去请求接口。
4、配置执行命令
如果你期望安装完这个包,之后用 jjcj
调用命令,则做如下配置,这里其实相当于一个全局的 alias
,即 jjcj
相当于 node ./bin/jjcj.js
。
"bin": {
"jjcj": "bin/jjcj.js"
},
复制代码
5、新建 bin/jjcj.js
#!/usr/bin/env node
// setNodeTitle
process.title = '掘金抽奖';
require('../dist/cli')
.run()
.catch((e) => {
console.error(e);
process.exit(1);
});
复制代码
process.title = '掘金抽奖';
会在你执行命令的时候,修改命令行工具的标题。
6、新建 src/cli.ts
如果你喜欢写 es5 的代码,那你可以直接在 bin/jjcj.js
文件中编写相关逻辑,但是我现在比较喜欢用 ts 写 es6 的代码,所以还需要加一层编译。
7、使用 father@next 编译文件
新版本的 father 非常好用,只需要新建一个 .fathertc.ts
即可。可以说非常智能了。速度也非常快。
import { defineConfig } from 'father';
export default defineConfig({
cjs: {
output: 'dist',
},
});
复制代码
8、发布上线
$pnpm build
// father build
$npm publish
npm notice === Tarball Details ===
npm notice name: jjcj
npm notice version: 0.0.2
npm notice filename: jjcj-0.0.2.tgz
npm notice package size: 55.0 kB
npm notice unpacked size: 153.8 kB
npm notice shasum: e49ec83af8335ce69b182a331a06957b11a589cd
npm notice integrity: sha512-IAjh8+dLyUYmD[...]B1Y1abj+2ue7w==
npm notice total files: 9
npm notice
+ [email protected]
复制代码
感谢阅读,希望本文对你有用。