【开发记录】掘金 “破圈行动” 辅助脚本

1,576 阅读9分钟

掘金社区在 9 月 23 日开启了「破圈行动」,活动内容是在 9 月 23 日 - 9 月 30 日期间,每天破解 3 个圈子(破解方式:在圈子中发布沸点),根据达成天数赢取周边奖品。

在参与活动的第一天发现了一丢丢痛点,任务达标需要注意如下要点:

  1. 有 6 个圈子不参与此次活动,这 6 个圈子要牢记于心多加小心
  2. 共有 30+ 圈子,如何避免重复破解

有痛点的地方就有妙招,有同学通过表格规划好每日破解的三个圈子,利用现有工具轻巧地解决了上述问题。

我想要在参与活动时有一定的视觉反馈和激励,于是有了写一个“活动辅助工具”的想法。跟进掘金目前进行的活动,提供状态追踪。听起来是个不错的大饼,从 “破圈行动” 开干。

油猴脚本开发

油猴脚本安装门槛低,是最合适的载体。

主流浏览器市场都能下载到油猴插件,唯一存在的阻碍是 Chrome 市场的访问问题,目前看来可以使用第三方安装源(不那么安全),或者,转移到 Edge 阵地 🧐

理想的开发流程是在 VSCode 编辑,保存后自动生效。

油猴提供了 @require 字段来加载外部资源,例如加载并运行 jquery

// @require    https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.min.js

可以利用这个特性运行本地 js 文件,配置时需要注意:

  1. @require 后填写文件完整路径,以 file:// 协议开头
  2. 在插件设置页勾选 Allow access to file URLs

66a6e231cd8ee515d76b64b8f1f89bb232ddf5a29c75192bc713e587957f415d.png

源码用于上传分享,脚本则是开发时起作用的部分。因此两者 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 插件。

这样一来开发就顺手多了。

规则梳理

从官方提供的规则里提取要点。

破圈需要满足:

  1. 不在 6 个被排除的圈子中
  2. 之前几天没有破解过
  3. 沸点内容通过审核

一天达标需要满足:

  • 一天中破解的圈子数大于等于 3

最终奖励根据达标天数和发布沸点数对应奖项

效果设计

明确规则之后,开始设想一下辅助工具的效果。

活动统计

在个人主页和沸点页显示活动统计,追踪已达成的进度和对应奖项;

活动统计

根据一到三等奖的奇数特征,奖项对应关系可以描述为

["幸运奖", "三等奖", "二等奖", "一等奖", "全勤奖"][
    efficientDays >= 8 ? 4 : Math.floor((efficientDays - 1) / 2)
] ?? (efficientTopicCount > 1 ? "幸运奖" : "无")

活动统计依赖的数据有:达标天数 efficientDays 、破解圈子数 efficientTopicCount、今天破解的圈子 todayEfficientTopicTitles

圈子选择菜单

在圈子选择菜单标注不参加活动的圈子,显示圈子的破解状态和审核状态。

未破解,已破解,不参加

自上到下,分别是未破解,已破解,不参加三种状态

由于发布和审核之间存在延迟,需要区分一下已创建但还未审核完成的圈子。

3e01fdb5a4fad585b5658ab29769f60b30886c1a05eb877cfb27999cd6215947.png

已发布未审核,灰色圈圈打底

圈子选择菜单依赖的数据有:不参加活动的圈子 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 树发生的更改,观察内容可以包括属性更改(可指定属性列表)、子节点增加或删除、文本内容更改。目前已经没有兼容性问题。

8292a797d9e2e93f72fa7ad38ed468dfcb515b7bb035e5a47777ef51fe6792f4.png

回到脚本,脚本中有两处需要进行 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.pushStatehistory.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,只在需要的时机请求:

  1. 沸点首页
  2. 个人主页
  3. 唤出发布沸点弹窗

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 作为辅助的必要性。

活动辅助工具这个饼啃了一丢丢,还可以努力啃一啃。如果你也有兴趣,欢迎一起参与。

沸点讨论区

脚本安装地址 - Gitee

源码地址 - Gitee

脚本安装地址 - GitHub

源码地址 - GitHub