Greasy Fork

Greasy Fork is available in English.

抖音评论筛选器 | Douyin Comment Picker

筛选包含给定关键词的抖音评论 | Pick out the comments including the given keywords in Douyin.

当前为 2022-09-26 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         抖音评论筛选器 | Douyin Comment Picker
// @namespace    https://github.com/NewComer00
// @version      0.2.0
// @description  筛选包含给定关键词的抖音评论 | Pick out the comments including the given keywords in Douyin.
// @author       NewComer00
// @match        https://www.douyin.com/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=google.com
// @grant        none
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/js.cookie.min.js
// @license      GPLv2 AND Commercial use prohibited | GPLv2且禁止商用
// ==/UserScript==

(function () {
    'use strict';

    // ========================================================================
    // 使用说明
    //
    // v0.2.0
    // 将脚本复制添加到Tampermonkey。使能该脚本,然后访问抖音官网,按下F12进入Console即可。
    // 在页面上方依次填写“视频关键词”“评论筛选关键词(空格隔开)”和“最大浏览视频数量”,然后点击“开始”。
    // 脚本运行中如被打断,刷新即可继续运行。脚本的Cookie文件会保存一天时间。一天之内都可以再次从断点开始。
    // 如中途需要从头执行脚本,请先删除浏览器上抖音网站的Cookie,然后刷新抖音页面即可。
    // 执行完毕后,网页会弹出结果文件下载窗口。复制文件中所有内容,粘贴到Excel即可以表格方式查看。
    // 如需在脚本运行前排除先前Cookie的影响,可以点击“清除脚本缓存”按钮,然后刷新页面。
    // ========================================================================

    // ========================================================================
    // 脚本输入参数
    // ========================================================================

    // 网站域名。目前只适用于抖音,请不要更改
    const DOMAIN = 'www.douyin.com';

    // ========================================================================
    // 相关数据类型和函数
    // ========================================================================
    const strFormat = (str, ...args) => args.reduce((s, v) => s.replace('%s', v), str);
    const State = {
        Original: 'Original',
        One: 'One',
        Two: 'Two'
    }

    // 下载数据至本地文件
    // https://stackoverflow.com/a/30832210
    function download(data, filename, type) {
        var file = new Blob([data], { type: type });
        if (window.navigator.msSaveOrOpenBlob) // IE10+
            window.navigator.msSaveOrOpenBlob(file, filename);
        else { // Others
            var a = document.createElement("a"),
                url = URL.createObjectURL(file);
            a.href = url;
            a.download = filename;
            document.body.appendChild(a);
            a.click();
            setTimeout(function () {
                document.body.removeChild(a);
                window.URL.revokeObjectURL(url);
            }, 0);
        }
    }

    // ========================================================================
    // 分析视频页面的逻辑,可以自定义
    // ========================================================================
    function mainLogic(body, keywords) {
        // 获取HTML某节点下属所有的含文本节点
        // https://stackoverflow.com/a/10730777
        function textNodesUnder(el) {
            var n, a = [], walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null, false);
            while (n = walk.nextNode()) a.push(n);
            return a;
        }

        // 视频页面上,所有带用户名和评论的节点大概长这样
        let nodeList = body.getElementsByClassName(
            "comment-mainContent")[0].getElementsByClassName('notranslate new-pmd');

        // 拼接nodeList中所有含文本节点的文字,拼接为字符串后放入textList中
        // 正确获取的textList中,从0开始偶数下标存放用户名,奇数存放其评论
        let textList = [];
        for (let node of nodeList) {
            textList.push(textNodesUnder(node).map(_ => _.data).join(''));
        }
        console.assert(textList.length % 2 === 0,
            'textList长度是奇数。长度应当是偶数,评论获取可能出错');

        console.log('获取到所有用户名及其评论如下:');
        console.log(textList);

        // 分离用户名和评论
        let users = textList.filter((v, i) => (i % 2) === 0);
        let comments = textList.filter((v, i) => (i % 2) === 1);

        // result存放检测到符合关键词要求的信息,每行格式如下:
        // 检测到的关键词\t发评论的用户\t评论内容\t视频页面链接\n
        let result = '';
        let url = window.location.href;
        for (let i = 0; i < comments.length; i++) {
            // 第i条评论中包含了哪些关键词
            let keywordsInComment = keywords.filter(
                k => comments[i].toLowerCase().includes(k.toLowerCase()));
            if (keywordsInComment.length > 0) {
                result += strFormat(
                    '%s\t%s\t%s\t%s\n', keywordsInComment, users[i], comments[i], url);
            }
        }
        return result;
    }

    // ========================================================================
    // 状态机
    // ========================================================================
    // 初始化状态信息
    let curState = Cookies.get('State');
    if (typeof curState === 'undefined') {
        Cookies.set('State', State.Original, { domain: DOMAIN, expires: 1 });
        curState = State.Original;
    }

    // 检查视频关键词和评论关键词等Cookie是否存在
    let target = Cookies.get('target'); // 视频关键词
    let keywords = Cookies.get('keywords'); // 视频下的评论筛选关键词,由空格分开
    let maxVideoNum = Cookies.get('maxVideoNum'); // 只筛选前几个视频,应当是非负整数
    if (typeof target === 'undefined' || typeof keywords === 'undefined'
        || typeof maxVideoNum === 'undefined') {
        // 除非当前状态在初态,否则这些Cookie都应当存在。不存在则回到初态
        if (curState !== State.Original) {
            console.log('没有找到视频关键词和评论关键词等的Cookie缓存文件\n'
                + '脚本将重置进度,请重新输入这些信息');
            Cookies.set('State', State.Original, { domain: DOMAIN, expires: 1 });
            curState = State.Original;
        }
    } else {
        // 代码运行到此处,证明视频关键词等信息都以字符串的形式存在
        // 将关键信息提取出来变成相应类型的常量,状态机除初态之外的核心逻辑都使用这些常量
        var TARGET = target; // string
        var KEYWORDS = keywords.split(' '); // array of string
        var MAX_VIDEO_NUM = parseInt(maxVideoNum); // int
    }

    switch (curState) {
        // 初态。加载脚本的用户交互组件,获取用户输入
        case State.Original:
            console.log("请在页面上填写相关筛选信息...");

            // 文本框,输入视频关键词
            var inputTarget = document.createElement("input");
            inputTarget.setAttribute('name', "inputTarget");
            inputTarget.setAttribute('type', "text");
            inputTarget.setAttribute('placeholder', "视频关键词");
            if (typeof target !== 'undefined' && target.length !== 0) {
                inputTarget.setAttribute('value', target);
            } else {
                inputTarget.setAttribute('value', '孙一峰');
            }
            document.getElementById('douyin-header').appendChild(inputTarget);

            // 文本框,输入视频下的评论筛选关键词
            var inputKeywords = document.createElement("input");
            inputKeywords.setAttribute('name', "inputKeywords");
            inputKeywords.setAttribute('type', "text");
            inputKeywords.setAttribute('placeholder', "评论筛选关键词,空格分隔");
            if (typeof keywords !== 'undefined' && keywords.length !== 0) {
                inputKeywords.setAttribute('value', keywords);
            } else {
                inputKeywords.setAttribute('value', 'ToSsGirL 西湖 大哥 F91');
            }
            document.getElementById('douyin-header').appendChild(inputKeywords);

            // 文本框,输入最大浏览视频数量
            var inputMaxVideoNum = document.createElement("input");
            inputMaxVideoNum.setAttribute('name', "inputMaxVideoNum");
            inputMaxVideoNum.setAttribute('type', "number");
            inputMaxVideoNum.setAttribute('min', "0");
            inputMaxVideoNum.setAttribute('step', "1");
            inputMaxVideoNum.setAttribute('placeholder', "最大浏览视频数量,数字");
            if (typeof maxVideoNum !== 'undefined' && maxVideoNum.length !== 0) {
                inputMaxVideoNum.setAttribute('value', maxVideoNum);
            } else {
                inputMaxVideoNum.setAttribute('value', '10');
            }
            inputMaxVideoNum.addEventListener('mouseup', (e) => {
                e.stopPropagation();
            });
            document.getElementById('douyin-header').appendChild(inputMaxVideoNum);

            // 按钮,控制"开始筛选评论"行为,这是最主要的功能
            var btnStart = document.createElement("button");
            btnStart.innerHTML = "开始筛选评论";
            // 点击按钮后...
            btnStart.onclick = function () {
                // 保存用户输入至Cookie
                // TODO: 没有检测用户输入合法性
                target = inputTarget.value;
                Cookies.set('target', String(target), { domain: DOMAIN, expires: 1 });
                keywords = inputKeywords.value;
                Cookies.set('keywords', String(keywords), { domain: DOMAIN, expires: 1 });
                maxVideoNum = inputMaxVideoNum.value;
                Cookies.set('maxVideoNum', String(maxVideoNum), { domain: DOMAIN, expires: 1 });

                // 重定向至视频搜索结果页面,页面自动刷新后进入下一个状态
                console.log("正在根据关键词搜索视频...");
                Cookies.set('State', String(State.One), { domain: DOMAIN, expires: 1 });
                curState = State.One;
                var searchUrl = encodeURI(strFormat('https://%s/search/%s?&type=video', DOMAIN, target));
                window.location.href = searchUrl;
            };
            document.getElementById('douyin-header').appendChild(btnStart);

            // 按钮,手动删除和脚本相关的Cookie文件
            var btnRmCookie = document.createElement("button");
            btnRmCookie.innerHTML = "清除脚本缓存";
            // 点击按钮后...
            btnRmCookie.onclick = function () {
                console.log("正在清除脚本相关的Cookie文件...");
                Cookies.remove('target', { domain: DOMAIN });
                Cookies.remove('keywords', { domain: DOMAIN });
                Cookies.remove('maxVideoNum', { domain: DOMAIN });
                Cookies.remove('State', { domain: DOMAIN });
                Cookies.remove('videoIdArr', { domain: DOMAIN });
                Cookies.remove('videoCurIndex', { domain: DOMAIN });
                Cookies.remove('Result', { domain: DOMAIN });
                console.log("清除完成,用户刷新后将重新运行脚本");
            };
            document.getElementById('douyin-header').appendChild(btnRmCookie);
            break;

        // 状态一。获取关键词对应的所有视频编号
        case State.One:
            console.log("正在获取关键词对应的所有视频编号...");
            // setTimeout等待几秒,以确保网页真的已经完成加载
            // TODO: 为什么onload被触发时页面却没有加载完全?反爬虫机制?
            console.log("确保页面真的完全加载,请等待几秒...");
            window.onload = setTimeout(function () {
                const bodyText = document.getElementsByTagName("body")[0].innerHTML;
                const rgx = new RegExp(
                    strFormat(String.raw`href="\/\/%s\/video\/(\d+)" class`, DOMAIN), 'g');
                let videoIdArr = Array.from(bodyText.matchAll(rgx), m => m[1]);
                // 去除重复的视频编号
                videoIdArr = [...new Set(videoIdArr)];
                // 用户希望选择前多少个视频
                console.assert(MAX_VIDEO_NUM >= 0, '最大筛选视频数量应当是非负整数,否则可能会获取不到视频编号');
                videoIdArr = videoIdArr.slice(
                    0, Math.min(videoIdArr.length, Math.floor(MAX_VIDEO_NUM)));

                console.log(strFormat('已提取和“%s”相关的所有视频编号', TARGET));
                console.log(videoIdArr);
                if (videoIdArr.length > 0) {
                    Cookies.set('State', String(State.Two), { domain: DOMAIN, expires: 1 });
                    Cookies.set('videoIdArr', String(videoIdArr), { domain: DOMAIN, expires: 1 });
                    Cookies.set('videoCurIndex', String(0), { domain: DOMAIN, expires: 1 });
                    curState = State.Two;

                    // 重定向至第0号视频页面,下次脚本应当进入下一个状态
                    // TODO: 和下一种状态耦合
                    const videoUrl = strFormat('https://%s/video/%s', DOMAIN, videoIdArr[0]);
                    window.location.href = videoUrl;
                } else {
                    // 出错,下次用户刷新后返回初态
                    console.log('没有获取到任何有效的视频编号,用户刷新后将重新运行脚本');
                    Cookies.set('State', String(State.Original), { domain: DOMAIN, expires: 1 });
                    curState = State.Original;
                }
            }, 5000);
            break;

        // 状态二。处理每个编号的视频
        case State.Two:
            console.log("正在处理每个编号的视频...");
            var videoIdArr = Cookies.get('videoIdArr').split(",");
            var videoCurIndex = parseInt(Cookies.get('videoCurIndex'));
            if (typeof videoIdArr !== 'undefined' && !isNaN(videoCurIndex)) {
                // 从上一个状态进来后,应当默认位于第0号视频处
                // TODO: 和上一种状态耦合
                var videoId = videoIdArr[videoCurIndex];
                console.log('进入视频:' + videoId);
                window.onload = function () {

                    // 分析视频页面,核心处理逻辑
                    const body = document.getElementsByTagName("body")[0];
                    let result = mainLogic(body, KEYWORDS);
                    console.log('本视频页面分析完成,结果为:')
                    console.log(result);
                    // 添加结果至Cookie,若是从头开始运行则覆盖老的Cookie
                    let oldResult = Cookies.get('Result');
                    if (typeof oldResult !== 'undefined' && videoCurIndex !== 0) {
                        result = oldResult + result;
                    }
                    Cookies.set('Result', String(result), { domain: DOMAIN, expires: 1 });

                    if (videoCurIndex + 1 < videoIdArr.length) {
                        // 下一次重定向时,将处理下一个视频
                        videoCurIndex++;
                        Cookies.set('videoCurIndex', String(videoCurIndex), { domain: DOMAIN, expires: 1 });
                        Cookies.set('State', String(State.Two), { domain: DOMAIN, expires: 1 });
                        curState = State.Two;

                        // 重定向至下一个视频,下次脚本应当处理下一个视频
                        videoId = videoIdArr[videoCurIndex];
                        const videoUrl = strFormat('https://%s/video/%s', DOMAIN, videoId);
                        window.location.href = videoUrl;
                    } else {
                        // 执行完毕正常退出,下次用户刷新后返回初态
                        let finMsg = strFormat(
                            '【视频主题】\n%s\n【评论关键词】\n%s\n【最终筛选结果】\n%s\n',
                            TARGET, KEYWORDS, result);
                        console.log(finMsg);
                        download(result, 'Result', 'text/plain');

                        console.log('脚本运行完成,注意结果文件下载弹窗。用户刷新后将重新运行脚本');
                        Cookies.set('State', String(State.Original), { domain: DOMAIN, expires: 1 });
                        curState = State.Original;
                    }
                }
            } else {
                // 出错,下次用户刷新后返回初态
                console.log('没有找到视频编号的Cookie缓存文件,用户刷新后将重新运行脚本');
                Cookies.set('State', String(State.Original), { domain: DOMAIN, expires: 1 });
                curState = State.Original;
            }
            break;
    }

    console.log('除了填写信息的页面外,页面如果长时间没有自动跳转,脚本可能已经停止运行\n'
        + '可以尝试刷新页面,脚本可能恢复运行。仍不行请删除浏览器上该网站的Cookie,刷新后脚本将重置。');

})();