掘金社区在 9 月 23 日开启了「破圈行动」,活动内容是在 9 月 23 日 - 9 月 30 日期间,每天破解 3 个圈子(破解方式:在圈子中发布沸点),根据达成天数赢取周边奖品。
在参与活动的第一天发现了一丢丢痛点,任务达标需要注意如下要点:
- 有 6 个圈子不参与此次活动,这 6 个圈子要牢记于心多加小心
- 共有 30+ 圈子,如何避免重复破解
有痛点的地方就有妙招,有同学通过表格规划好每日破解的三个圈子,利用现有工具轻巧地解决了上述问题。
我想要在参与活动时有一定的视觉反馈和激励,于是有了写一个“活动辅助工具”的想法。跟进掘金目前进行的活动,提供状态追踪。听起来是个不错的大饼,从 “破圈行动” 开干。
油猴脚本开发
油猴脚本安装门槛低,是最合适的载体。
主流浏览器市场都能下载到油猴插件,唯一存在的阻碍是 Chrome 市场的访问问题,目前看来可以使用第三方安装源(不那么安全),或者,转移到 Edge 阵地 🧐
理想的开发流程是在 VSCode 编辑,保存后自动生效。
油猴提供了 @require
字段来加载外部资源,例如加载并运行 jquery
// @require https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js
可以利用这个特性运行本地 js 文件,配置时需要注意:
@require
后填写文件完整路径,以file://
协议开头- 在插件设置页勾选
Allow access to file URLs
源码用于上传分享,脚本则是开发时起作用的部分。因此两者 UserScript 部分的重要信息需要保持一致,来避免开发版和分享版存在运行差异。同时可以利用这个特点做一个环境区分,这样切换环境时就不用覆盖脚本了。
分享版设置下载和更新链接
// ==UserScript==
// @name Juejin Activities Enhancer
// @name:zh-CN 掘金活动辅助工具
// @namespace https://github.com/curly210102/UserScripts
// @version 0.1.6.3
// @description Enhances Juejin activities
// @author curly brackets
// @match https://juejin.cn/*
// @license MIT License
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-end
// @connect juejin.cn
// @supportURL https://github.com/curly210102/UserScripts/issues
// @updateURL https://github.com/curly210102/UserScripts/raw/main/Juejin_Enhancer/Juejin_activities.user.js
// @downloadURL https://github.com/curly210102/UserScripts/raw/main/Juejin_Enhancer/Juejin_activities.user.js
// ==/UserScript==
开发版 @name
跟一个 Dev
// ==UserScript==
// @name Juejin Activities Enhancer Dev
// @name:zh-CN 掘金活动辅助工具
// @namespace https://github.com/curly210102/UserScripts
// @version 0.1.6.3
// @description Enhances Juejin activities
// @author curly brackets
// @match https://juejin.cn/*
// @license MIT License
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-end
// @connect juejin.cn
// @require file:///Users/curly/Workspace/opensource/UserScripts/Juejin_Enhancer/Juejin_activities.user.js
// ==/UserScript==
再给 VSCode 加上一点 API 提示和补全,安装 VisualStudio Marketplace - Tampermonkey Snippets 插件。
这样一来开发就顺手多了。
规则梳理
从官方提供的规则里提取要点。
破圈需要满足:
- 不在 6 个被排除的圈子中
- 之前几天没有破解过
- 沸点内容通过审核
一天达标需要满足:
- 一天中破解的圈子数大于等于 3
最终奖励根据达标天数和发布沸点数对应奖项
效果设计
明确规则之后,开始设想一下辅助工具的效果。
活动统计
在个人主页和沸点页显示活动统计,追踪已达成的进度和对应奖项;
根据一到三等奖的奇数特征,奖项对应关系可以描述为
["幸运奖", "三等奖", "二等奖", "一等奖", "全勤奖"][
efficientDays >= 8 ? 4 : Math.floor((efficientDays - 1) / 2)
] ?? (efficientTopicCount > 1 ? "幸运奖" : "无")
活动统计依赖的数据有:达标天数 efficientDays
、破解圈子数 efficientTopicCount
、今天破解的圈子 todayEfficientTopicTitles
。
圈子选择菜单
在圈子选择菜单标注不参加活动的圈子,显示圈子的破解状态和审核状态。
自上到下,分别是未破解,已破解,不参加三种状态
由于发布和审核之间存在延迟,需要区分一下已创建但还未审核完成的圈子。
已发布未审核,灰色圈圈打底
圈子选择菜单依赖的数据有:不参加活动的圈子 blockTopics
、已破解的圈子 efficientTopics
、破解的圈子已发沸点数、圈子审核状态。
根据上述分析设计一个数据结构
interface IStates {
todayEfficientTopicTitles: string[], // 今日破解的圈子
efficientDays: number, // 达标天数
efficientTopics: { // 破解圈子:已发费点数、审核状态(已通过还是在等待审核)
[title: string]: {
count: number,
verify: boolean
}
},
}
const BLOCKTOPICS = [
"树洞一下",
"掘金相亲角",
"反馈 & 建议",
"沸点福利",
"掘金官方",
"上班摸鱼"
];
数据获取和处理
首先来收集 9 月 23 日 - 9 月 30 日区间内发布的沸点,做一些基本的筛选和分组处理。
使用沸点列表查询接口,每次请求 24 条(8 x 3),按时间从近到远排列。
遍历数据,当沸点的创建时间早于 9 月 23 时跳出;当最后一条沸点的创建时间晚于 9 月 23 时设置 cursor
继续请求,这里为了避免嵌套传递回调函数,采用 Promise。
收集到数据后,做一下基础有效性验证,根据时间范围和 BLOCKTOPICS
进行筛选。
为了方便计算达标天数,在这里就按活动天数分组,利用数组索引存放。最终得到的数据集合长这样:
type IDailyTopics = Array<{
title: string,
verified: boolean
}>
完整代码实现
const startTimeStamp = 1632326400000; // 9/23 00:00
const endTimeStamp = 1633017600000; // 10/01 00:00
function requestShortMsgTopic (cursor = "0", dailyTopics = []) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: "https://api.juejin.cn/content_api/v1/short_msg/query_list",
data: JSON.stringify({
sort_type: 4,
cursor: cursor,
limit: 24,
user_id: userId,
}),
headers: {
"User-agent": window.navigator.userAgent,
"content-type": "application/json",
},
onload: function ({ status, response }) {
if (status !== 200) {
return reject();
}
const responseData = JSON.parse(response);
const { data, cursor, has_more } = responseData;
let lastPublishTime = Infinity;
for (const msg of data) {
// ctime 创建时间,mtime 修改时间,rtime 人工复审时间
const publishTime = msg_Info.ctime * 1000;
lastPublishTime = publishTime;
if (publishTime < startTimeStamp) {
break;
}
// 按活动天数分组
if (
publishTime > startTimeStamp &&
publishTime < endTimeStamp &&
!BLOCKTOPICS.includes(topic.title)
) {
const day = Math.floor(
(publishTime - startTimeStamp) / 86400000
);
if (!dailyTopics[day]) {
dailyTopics[day] = [];
}
// 沸点审核流程是机器审核再人工复审,过审的严格逻辑是 `msg_Info.status === 2 && msg_Info.verify_status === 1`。
// 审核有三种状态:等待、通过、失败
dailyTopics[day].push({
title: topic.title,
// wait: 0, pass: 1, fail: 2
verified:
msg_Info.status === 1 ||
msg_Info.verify_status === 0
? 0
: msg_Info.status === 2 &&
msg_Info.verify_status === 1
? 1
: 2,
});
}
}
// 还有数据,继续请求
if (lastPublishTime > startTimeStamp && has_more) {
resolve(requestShortMsgTopic(cursor, dailyTopics));
} else {
resolve(dailyTopics);
}
}
})
})
}
拿到分组数据之后,进行更深入的验证和达标天数计算,组装出最终的数据结构。
由于要展示正在等待审核的圈子,这里把破圈的限制放宽,已通过审核和正在等待审核的都包含在内。在渲染展示层进行区分。
设定一个 Set 在存放已破解的圈子,做重复验证(代码中的 allEfficientTopicTitles
)。沸点数量和审核情况另开一个集合记录,代码中的 topicCountAndVerified
。
function filterEfficientData (dailyTopics) {
const allEfficientTopicTitles = new Set();
const topicCountAndVerified = {};
const todayIndex = Math.floor(
(new Date().valueOf() - startTimeStamp) / 86400000
);
const todayEfficientTopicTitles = [];
let efficientDays = 0;
// 按天处理
dailyTopics.forEach((topics, index) => {
// 获取一天破解的圈子
const dailyEfficientTopicTitles = new Set(
topics
.filter(({ title, verified }) => {
// 破圈:未被破解 + 已通过审核或正在等待审核
return !allEfficientTopicTitles.has(title) && verified !== 2;
})
.map(({ title }) => title)
);
// 更新达标天数
if (dailyEfficientTopicTitles.size >= 3) {
efficientDays++;
}
// 记录今日破圈数据
if (index === todayIndex) {
todayEfficientTopicTitles.push(...dailyEfficientTopicTitles);
}
// 更新已破圈集合
dailyEfficientTopicTitles.forEach((t) => allEfficientTopicTitles.add(t));
// 记录已破圈发帖数
topics.map(({ title, verified }) => {
if (!topicCountAndVerified[title]) {
topicCountAndVerified[title] = {
count: 1,
verified,
};
} else {
topicCountAndVerified[title]["count"]++;
topicCountAndVerified[title]["verified"] ||= (verified === 1);
}
});
});
// 组装数据
setStates({
todayEfficientTopicTitles,
efficientDays,
efficientTopics: Object.fromEntries(
[...allEfficientTopicTitles].map((title) => {
return [title, topicCountAndVerified[title]];
})
),
});
}
渲染
油猴脚本在 onDOMContentLoaded
时执行,这时站点还没有完成注水渲染无法获取到目标节点。页面初始阶段的渲染可以使用 setTimeout
延时来解决(粗糙但实用)。
但运行时的 DOM 更新就得引入监听机制了。React/Vue 构建的应用中,数据更新可能触发 DOM 销毁、更新、重建,插件无法入侵应用内只能在应用之外对 DOM 变化进行监听。使用 MutationObserver
来实现。
MutationObserver
是 DOM3 Events 规范设定一个接口,用来观察 DOM 树发生的更改,观察内容可以包括属性更改(可指定属性列表)、子节点增加或删除、文本内容更改。目前已经没有兼容性问题。
回到脚本,脚本中有两处需要进行 DOM 监听,分别是:圈子选择列表进行搜索或切换 Tab 触发 DOM 更改,右上角入口触发的沸点发布弹窗。以 Tab 切换来举例 MutationObserver
的使用
// 1. 获取目标节点
const topicPanel = containerEl.querySelector(
".topicwrapper .new_topic_picker"
);
if (!topicPanel) {
return;
}
// 2. 构建一个 observer,设置处理逻辑
const observer = new MutationObserver(function (mutations) {
mutations.forEach(({ type, addedNodes }) => {
// 监听到 DOM 树中新增子节点
if (type === "childList" && addedNodes.length) {
// 检查子节点
addedNodes.forEach((itemEl) => {
if (!itemEl) {
return;
}
if (itemEl?.classList?.contains("contents")) {
// 如果子节点包含 .contents 样式类,说明替换了整个 Panel,需要整体重渲染
renderWholeContent(itemEl);
} else {
// Panel 内增加了新的节点,渲染单项
renderItem(itemEl);
}
});
}
});
});
// 3. 开始观察目标节点,指定观察的内容
// childList: 观察子节点插入或移除
// subtree: 设定观察范围,是否深入到后代节点
observer.observe(topicPanel, {
childList: true,
subtree: true,
});
优化
基本功能完成之后,来考虑细节优化。
运行时机
油猴脚本只在页面加载后运行,但掘金主站基于 Nuxt 用到单页路由,路由切换时并不会触发脚本重运行,需要做一下额外的路由监听进行驱动。
popstate
事件只在浏览器回退时触发,这里通过覆盖 history.pushState
和 history.replaceState
实现路由全监听。initByRouter
作为逻辑启动入口。
const _historyPushState = history.pushState;
const _historyReplaceState = history.replaceState;
history.pushState = function () {
_historyPushState.apply(history, arguments);
initByRouter();
};
history.replaceState = function () {
_historyReplaceState.apply(history, arguments);
initByRouter();
};
window.addEventListener("popstate", function () {
initByRouter();
});
数据请求的时机
其实没有必要在每次进入掘金页面时请求 API,只在需要的时机请求:
- 沸点首页
- 个人主页
- 唤出发布沸点弹窗
initByRouter
中做一下路由控制,进入沸点页和个人主页时走渲染逻辑,在页面内部跳转时不必重复请求。
let currentRouterPathname = "";
function initByRouter () {
const prevRouterPathname = currentRouterPathname;
currentRouterPathname = document.location.pathname;
const pagePinsRegexp = /^\/pins(?:\/|$)/;
if (pagePinsRegexp.test(currentRouterPathname) && !pagePinsRegexp.test(prevRouterPathname)) {
renderInPagePins();
return;
}
const pageProfileRegexp = new RegExp(`^\\/user\\/${userId}(?:\\/|$)`);
if (
pageProfileRegexp.test(
currentRouterPathname
) && !pageProfileRegexp.test(prevRouterPathname)
) {
renderInPageProfile();
return;
}
}
到此,终于实现了较为完整的一个小工具。
后记
写这篇文章的过程中,重新梳理了一遍逻辑,发现一些可改进的点,也是意料之外的收获吧。
虽然说 JavaScript 可以灵活快速地实现,但在写代码的过程中能非常明显地感受到 Babel、ESLint、TypeScript 作为辅助的必要性。
活动辅助工具这个饼啃了一丢丢,还可以努力啃一啃。如果你也有兴趣,欢迎一起参与。