3
\$\begingroup\$

This script is a userscript designed for giving a notification when the bot SmokeDetector posts a message in the Charcoal HQ or any other room were he is active.

The script has 3 modes of operating, which can be selected at the bottom of the screen

  • chat only (default)

    It only gives a notification when a spam related message is posted in chat, the definition of this is when it matches the pattern /spam|\/\/git.io\/vgx7b|offensive|abusive/i and contains a URL to a Stack Exchange site (line 328).

    The notifications are made using a sound notification (triggered by calling play() on a html 5 audio element and a request using the desktop notification api.

  • on

    In addition to the above code, it also opens a websocket connection to the Stack Exchange realtime feed and listens for new posts coming from this location, it uses multiple spam patterns defined at the top of the code for this detection (and checks them at line 186), including the url patterns from the Smoke Detector bot itself.

    When a post is detected, it is reported in the same way, and added to a list that is placed in the place of the rooms description, for a quick overview of the reported spam. This list can be quickly cleared using the "clear" button, by opening the post in question, or clicking the background to dismiss it directly.

  • off

    The userscript is turned off

This userscript also needs some CSS rules. These rules are injected at the start of the page, and they are used to style the place where the notifications appear in the "on" mode of the script.

This script is created as a port of the fork of the orginal code for Spam Tracker, because the orginal code was designed to be a Google Chrome extension, but it was removed by the orginal author for unknown reasons.

// ==UserScript==
// @name         Spamtracker
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  Alerts you when new smokedetector messages show up
// @author       NormalHuman
// @author       Ferrybig
// @match        *://chat.meta.stackexchange.com/*
// @match        *://chat.stackexchange.com/*
// @match        *://chat.stackoverflow.com/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';


    var sumRules = [/^\S*$/i, /\bcolon.*clean/i, /cleans/i, /\b(phone|support).*number\b/i, /\bwow\sgold\b/i, /\bessays?\b/i, /\bbaba\s?ji\b/i,
                    /\+91[\s\-\(]/i, /professional.*writ/i, /kickstarter/i, /natural.*ingredient/i, /\baffiliate\b/i, /\baging\b/i, /\bfifa\b/i, /\bbajotz\b/i,
                    /\bbagprada\b/i, /\bbabyliss/i, /\bblack magic\b/i, /vashikaran/i, /advantage.*price/i,  /natural(ly)?\b/i, /pure\sbody/i, /fuck/i, /\bshit/i, /bitch/i, /\bsuck/i,
                    /brain.*(boost|power)/i, /facts?\sabout/i, /\b100%\b/i, /live\sstream/i, /make\smoney/i, /sale/i, /\bhack/i, /cheat/i, /\bwow\sgold\b/i, /runescape/i,
                    /\bfifa.*coin/i, /\bcheap/i, /\bskin/i, /\bweight\b/i, /\bacne\b/i, /\bage\b/i, /\bbody.*build/i, /\bsupplements?\b/i, /\bhealth/i, /\bpenis\b/i,
                    /\bnutrition/i, /\bfat\b/i, /\bwrinkl/i, /\bdiet/i, /muscle/i, /\bbrain\b/i, /\bbaba\b/i, /clash ?of ?clans/i, /\bmale\b/i, /testo/i,
                    /\blover?\b/i, /\bloans?/i, /serum/i, /overcome/i, /workout/i, /fitness/i, /\bAlpha\b/, /\bultra\b/i, /\bPro\b/, /beauty/i ];
    var titleRules = sumRules.concat([/(\d)\1{2}/, /care\b/i, /\bwatch\b/i, /\bsell/i, /\bcleans/i, /\bloss\b/i, /\blose\b/i, /\bhelpline\b/i, /\bbuy\b/i, /\blose\b/i,
                                      /\b(phone|support).*number\b/i, /\bimprove/i, /\bonline\b/i, /\byou\scan\b/i, /\bfree\b/i, /\bwholesale\b/i, /\bmarriage\b/i, /\blove\b/i,
                                      /\bpurchas/i, /\bfull\shd\b/i, /\bcraigslist\b/i, /\bbenefits?\b/i, /beneficial/i, /advice/i, /perfect/i ]);
    var urlRules = []; // Will be loaded from the SmokeDetector github

    $.ajax("https://cdn.rawgit.com/Charcoal-SE/SmokeDetector/master/blacklisted_websites.txt",{
        cache: true,
        success: function(e)
        {
            urlRules = e
                .filter(function(i){return i;})
                .map(function(i){try{return new RegExp(i, "i");}catch(e){}})
                .filter(function(i){return !!i;});
        },
        dataType: "text",
        dataFilter: function(i){return i.split("\n");}
    });

    var style= "#priorityList + #roomtitle + #roomdesc, #priorityList + #roomtitle + #roomdesc + #room-tags {display: none;}";
    style += ".q-block {padding: 5px 0px;font-size: 11px;line-height: 14px;}";
    style += ".q-title {color: #333;font-weight: bold;margin: 2px;display: block;}";
    style += "a.q-title:visited {color: #888 !important;}";
    style += "#priorityList {background-color: #ffffff;}";
    style += "#clearchat, #clearside {margin-left: 2px;}";


    var prioritySites = ['academia', 'android', 'beer', 'boardgames', 'chess', 'coffee', 'computergraphics', 'cooking', 'datascience',
                         'drupal', 'ebooks', 'engineering', 'expatriates', 'freelancing', 'hsm', 'law', 'mechanics', 'money', 'mythology', 'opensource', 'patents',
                         'poker', 'productivity', 'quant', 'ru', 'startups', 'travel', 'webapps', 'webmasters', 'writers'];

    var timeSensitiveSites = ['drupal', 'superuser', 'askubuntu', 'meta'];

    var ignoredSites = ['biology', 'fitness', 'health', 'ja', 'pt', 'es', 'islam'];

    var insertRef, ws, clearchat, clearside, priorityList, savingData, wsVolume=0;

    var box = document.getElementById('input');
    var chat = document.getElementById('chat');
    var room = window.location.href.match(/chat[^/]*\/rooms\/\d+/)[0];
    var keepGoing = true;
    var currentStatus = 'chat only';
    var notifications = {};

    window.setInterval(checkForSpam, 500);

    if (box && chat && room) {
        insertRef = document.getElementById('footer-legal');
        var separator = document.createTextNode(' | ');
        insertRef.insertBefore(separator, insertRef.firstChild);

        var onoff = newElem('a', 'on-off', '', 'spamtracker: '+currentStatus);
        onoff.title = 'toggle spam tracking';
        onoff.onclick = toggleTracking;
        onoff.style.cursor = 'pointer';
        insertRef.insertBefore(onoff, insertRef.firstChild);

        var metabeep = new Audio('//cdn-chat.sstatic.net/chat/meta2.mp3');

        var apiKey = '1gtS)lKgyVceC11VlgjyQw((';
        var stored = {maxQ: {}, maxU: {}};
        var inserted = [], time = 0;

        clearchat = newElem('a', 'clearchat', 'button', 'clear chat');
        clearchat.title = 'remove all chat messages';
        clearchat.onclick = clearChat;
        insertRef = document.querySelector('#chat-buttons');
        insertRef.appendChild(clearchat, insertRef);

        addStyleString(style);

        //chrome.storage.sync.get(stored, function(items) {
        //    var room = window.location.href.match(/chat[^/]*\/rooms\/\d+/)[0];
        //    stored = items;
        //});
    }

    function checkForSpam() {
        if (currentStatus == 'off') {
            return;
        }
        var messageList = document.getElementsByClassName('message');
        var message = messageList[messageList.length-1];
        if (message && !message.classList.contains('checkedForSpam')) {
            message.classList.add('checkedForSpam');
            if (message.children[1] && !message.parentNode.parentNode.classList.contains('mine') && !message.querySelector('.onebox')) {
                processChatMessage(message);
            }
        }
    }

    function switchOn() {
        var prot = (window.location.protocol === 'https:' ? 'wss' : 'ws');
        ws = new WebSocket(prot+'://qa.sockets.stackexchange.com/');
        ws.onmessage = function(e) {
            wsVolume = wsVolume + e.data.length;
            processQuestion(JSON.parse(JSON.parse(e.data).data));
        };
        ws.onopen = function() { ws.send('155-questions-active'); };
        ws.onclose = function() {if (keepGoing) {window.setTimeout(switchOn, 10000);} };

        clearside = newElem('a', 'clearside', 'button', 'clear');
        clearside.title = 'dismiss all reports';
        clearside.onclick = clearSide;
        insertRef = document.querySelector('.fl');
        insertRef.appendChild(clearside);

        insertRef = document.getElementById('roomtitle');
        priorityList = newElem('div','priorityList','question-list','');
        insertRef.parentNode.insertBefore(priorityList, insertRef);

        currentStatus = 'on';
        savingData = window.setInterval(function() {/*chrome.storage.sync.set(stored);*/}, 120000);
        keepGoing = true;

        console.log('Max post Id');
        console.log(stored.maxQ);
        console.log('Max user Id');
        console.log(stored.maxU);
    }


    function pauseST() {
        keepGoing = false;
        ws.close();
        clearside.remove();
        priorityList.remove();
        currentStatus = 'chat only';
        //chrome.storage.sync.set(stored);
        window.clearInterval(savingData);
    }


    function switchOff() {
        currentStatus = 'off';
    }


    function processQuestion(q) {
        var i, data, url, site, shortSite, qId, uId, title, user, summary, qblock, insert, consider, hh, report, reg;
        var msgId, msgTitle;
        time = Math.max(time, q.lastActivityDate);
        title = q.titleEncodedFancy;
        site = q.siteBaseHostAddress;
        shortSite = site.split('.')[0];
        qId = q.id;
        url = '//'+site+'/q/'+qId;
        user = q.ownerDisplayName;
        summary = q.bodySummary;
        uId = (q.ownerUrl ? parseInt(q.ownerUrl.split('/')[4],10) : 0);
        if (!stored.maxQ[site]) {
            stored.maxQ[site] = 1;
        }
        if (!stored.maxU[site]) {
            stored.maxU[site] = 1;
        }
        insert = false;
        consider = (uId > stored.maxU[site]-3 && uId < 1.01*stored.maxU[site]) ;
        consider = consider && (qId > stored.maxQ[site] && qId < 1.01*stored.maxQ[site]);
        consider = consider && (ignoredSites.indexOf(shortSite) == -1);
        report = 'http://'+site+'/q/'+qId+' ';
        if (consider) {
            if (summary.length < 100) {
                insert = true;
                report = report + 'short summary: ' + summary + '\n';
            }
            hh = new Date().getUTCHours();
            if (timeSensitiveSites.indexOf(shortSite)!=-1 && hh>=4 && hh<= 12) {
                insert = true;
                report = report + 'peak spam time\n';
            }
            reg = bad(title, titleRules);
            if (reg) {
                insert = true;
                report = report + 'title matched ' + reg + '\n';
            }
            reg = bad(summary, sumRules);
            if (reg) {
                insert = true;
                report = report + 'summary matched ' + reg + '\n';
            }
            reg = bad(summary, urlRules);
            if (reg) {
                insert = true;
                report = report + 'smokedetector pattern matched ' + reg + '\n';
            }
            if (insert) {
                reportIt(report, site, qId, 'Q', title, url, q.ownerUrl, user, summary);
            }
        }
        if (!insert && prioritySites.indexOf(shortSite)!=-1) {
            window.setTimeout(fetchBody, 60000, shortSite);
        }
        if (qId>stored.maxQ[site]) {
            stored.maxQ[site] = qId;
        }
        if (uId>stored.maxU[site]) {
            stored.maxU[site] = uId;
        }
    }


    function reportIt(report, site, qId, type, title, url, ownerURL, ownerName, summary) {
        var qblock, elem, msgId, shortSite = site.split('.')[0], ueTitle, ueSummary;
        if (inserted.indexOf(shortSite+qId) == -1) {
            report = report + 'At ' + new Date().toString().split(" ")[4] + '\nWebsockets volume: '+wsVolume+'\n';
            console.log(report);
            inserted.push(shortSite+qId);
            msgId = room+'-'+site+'-'+qId+'-'+Date.now();
            elem = newElem('span', '', '', '');
            elem.innerHTML = title;
            ueTitle = elem.textContent;
            elem.innerHTML = summary;
            ueSummary = elem.textContent;
            notifyMe(msgId, shortSite + ' ' + type + ': ' + ueTitle, ueSummary);

            qblock = newElem('div',msgId,'q-block','');
            qblock.innerHTML = '<a class="q-title" target="_blank" href="'+url+'">' + shortSite + ' ' + type+': '+title + '</a><a target="_blank" href="'+ownerURL+'">' + ownerName + '</a>';
            qblock.onclick = removeBlock;
            elem.innerHTML = ': '+summary;
            qblock.appendChild(elem);
            priorityList.insertBefore(qblock, priorityList.firstElementChild);
        }
    }


    function fetchBody(shortSite) {
        var request = '//api.stackexchange.com/2.2/posts?pagesize=1&order=desc&sort=creation&site='+(shortSite=='ru'?'ru.stackoverflow':shortSite)+'&filter=!5RBFam4sA56hQ2Q5G3*uvo3fl&key='+apiKey;
        getStuff(request, 'json', function(e) {
            var q=e.currentTarget.response.items[0], url, site, body, elem, report, qId, insert, reg;
            url = q.share_link;
            site = url.split('/')[2];
            qId = q.post_id;
            elem = document.createElement('span');
            elem.innerHTML = q.body;
            body = elem.textContent;
            report = url+' ';
            insert = false;
            if (q.owner && q.owner.reputation == 1) {
                if (q.post_type == 'answer') {
                    insert = true;
                    report = report+' answer by a rep 1 user\n';
                }
                if (body.length < 100) {
                    insert = true;
                    report = report + 'short post: ' + body + '\n';
                }
                if (q.post_type == 'question') {
                    reg = bad(body, sumRules);
                }
                if (reg) {
                    insert = true;
                    report = report + 'body matched ' + reg + '\n';
                }
            }
            if (insert) {
                reportIt(report, site, qId, (q.post_type=='question' ? 'Q' : 'A'), q.title, url, q.owner.link, q.owner.display_name, body.slice(0,150));
                report = 'Quota remaining: '+e.currentTarget.response.quota_remaining;
                console.log(report);
            }
        });
    }


    function notifyMe(id,title,message) {
        // var msg = {};
        // msg.id = id;
        // msg.title = title;
        // msg.message = message;
        // notifications[id] = msg;
        var notification = new Notification(title, { body: message, icon: "//i.imgur.com/kS4QNIv.png" });
        notification.onshow = function() {
            setTimeout(notification.close, 15000);
        };
        notification.onclick = function() {
            window.focus();
        };
        notifications[id] = notification;
    }


    function dismissNotification(id) {
        notifications[id].close();
    }


    function removeBlock(e) {
        killBlock(e.target);
    }


    function killBlock(elem) {
        var list = elem.parentNode;
        if (list.classList.contains('question-list')) {
            dismissNotification(elem.id);
            list.removeChild(elem);
        }
        else {
            killBlock(list);
        }
    }


    function processChatMessage(message) {
        var smoke = /spam|vgx7b|offensive|abusive/i;
        var content = message.children[1].innerHTML;
        var i, msg = {}, parts, ch, path, hash, site = '', qId = '', sq;
        if (smoke.test(content) && /\/\/[a-z]*.stackexchange.com|stackoverflow.com|superuser.com|serverfault.com|askubuntu.com|stackapps.com|mathoverflow.net/i.test(content)) {
            ch = message.children[1].children;
            for (i=ch.length-1; i>=0; i--) {
                if (ch[i].tagName == 'A') {
                    hash = ch[i].href.split('#');
                    path = ch[i].href.split('/');
                    if (path[3] == 'questions' && hash.length>1) {
                        site = path[2];
                        qId = hash[1];
                    }
                    else if (/^[qa]/.test(path[3])) {
                        site = path[2];
                        qId = path[4];
                    }
                }
            }
            if (site && qId) {
                metabeep.play();
                sq = site.split('.')[0] + qId;
                if (inserted.indexOf(sq) != -1) {
                    return;
                }
                else {
                    inserted.push();
                }
            }
            msg.id = room+'-'+site+'-'+qId+'-'+Date.now();
            parts = message.children[1].textContent.split(': ');
            if (parts.length > 1) {
                msg.title = parts[0];
                msg.message = parts[1];
            }
            else {
                msg.title = 'Flag Request';
                msg.message = message.children[1].textContent;
                return; // Not used for now...
            }
            msg.type = 'chat';
            notifyMe(msg.id, msg.title, msg.message);
        }
    }


    function clearChat() {
        var chat = document.getElementById('chat');
        while (chat.firstElementChild) {
            chat.removeChild(chat.firstElementChild);
        }
        var starred = document.querySelector('#starred-posts ul');
        while (starred.firstElementChild) {
            starred.removeChild(starred.firstElementChild);
        }
    }


    function clearSide() {
        while (priorityList.firstElementChild) {
            priorityList.removeChild(priorityList.firstElementChild);
        }
    }


    function toggleTracking() {
        switch (currentStatus) {
            case "off":
                switchOn();
                break;
            case "on":
                pauseST();
                break;
            case "chat only":
                switchOff();
        }
        onoff.textContent = 'spamtracker: '+currentStatus;
    }


    function bad(text, rules) {
        for (var i=0; i<rules.length; i++) {
            if (rules[i].test(text)) {
                return rules[i];
            }
        }
        return false;
    }


    function newElem(eType,eId,eClass,eText) {
        var e = document.createElement(eType);
        if (eId.length>0) {e.id = eId;}
        if (eClass.length>0) {e.classList.add(eClass);}
        if (eText.length>0) {e.textContent = eText;}
        return e;
    }


    function getStuff(theUrl, type, listener) {
        var req = new XMLHttpRequest();
        req.responseType = type;
        req.onload = listener;
        req.open("GET", theUrl, true);
        req.send();
    }


    function htmlDecode(input){
        var e = document.createElement('div');
        e.innerHTML = input;
        return e.textContent;
    }

    function addStyleString(str) {
        var node = document.createElement('style');
        node.innerHTML = str;
        document.body.appendChild(node);
    }
})();
\$\endgroup\$
  • \$\begingroup\$ Did you mean to post this to stackapps.com, or are you actually interested in a review? ;-) \$\endgroup\$ – John Dvorak Nov 13 '16 at 18:00
  • \$\begingroup\$ Yes I will post it on stackapps \$\endgroup\$ – Ferrybig Nov 13 '16 at 18:04
3
\$\begingroup\$

A quote from another answer of mine:

You don't use quotes or apostrophes for string literals consistently. Decide if you want to write string using either apostrophes or double quotes and use it consistently in the whole code. Personally, I prefer apostrophes, because they require to press only one key, whereas to insert a double quote character you have to hold Shift too.

And for example if you choose apostrophes and you want to make a string which contains apostrophes, feel free to use double quotes in this specific situation, so you don't have to escape the apostrophes with a backslash.


And from yet another:

You're not using the const keyword anywhere. It's a good practice to use const instead of var always when possible, that is, when you don't intend to reassign the variable. It can prevent you from accidentally reassigning some other value to the variable, because it throws an error if you try to (at least in strict mode).


And from yet another:

Oh come on, it's 21. century! Why do you still use .onevent properties? You should totally drop that and try jQuery .addEventListener(). See addEventListener vs onclick.


Always use three-equals operator (===) for equality comparisons. See Does it matter which equals operator (== vs ===) I use in JavaScript comparisons?.


Instead of using the concat() method you could use the spread syntax:

const titleRules = [...sumRules, /(\d)\1{2}/, /care\b/i, /\bwatch\b/i];

You could replace most of your regular functions with arrow functions.


var urlRules = []; // Will be loaded from the SmokeDetector github

$.ajax("https://cdn.rawgit.com/Charcoal-SE/SmokeDetector/master/blacklisted_websites.txt",{
    cache: true,
    success: function(e)
    {
        urlRules = e
            .filter(function(i){return i;})
            .map(function(i){try{return new RegExp(i, "i");}catch(e){}})
            .filter(function(i){return !!i;});
    },
    dataType: "text",
    dataFilter: function(i){return i.split("\n");}
});

A few notes about this code:

  • You could change the IIFE to use an async function (i.e. change the (function() { at the top to (async function() {) and use the await operator here. This way the rest of the code would wait before this request completes, which would prevent bugs when the request would be taking a long time.
  • The cache option is true by default, so there's no need to set it to true.
  • dataType: 'text' is also redundant, because the resource returns a correct Content-Type header.
  • If the purpose of .filter(function(i){return i;}) is to filter out empty lines, you should be more explicit about that. You could do it like that: .filter(x => x !== '').
  • What's the point of the try...catch statement? The RegExp constructor shouldn't throw any errors.
  • Again, .filter(function(i){return !!i;}) is rather ambiguous. It filters out elements which return false when converted to boolean. Since all the elements are RegExp instances, this should never happen.

I would rewrite this code fragment like that:

const urlRules = (await $.get('https://cdn.rawgit.com/Charcoal-SE/SmokeDetector/master/blacklisted_websites.txt'))
  .split('\n')
  .filter(x => x !== '')
  .map(x => new RegExp(x, 'i'));

If you want to create multiline strings, you can use template literals.


Why are you using native DOM methods like document.getElementById() when you already have jQuery?


var message = messageList[messageList.length-1];

It would more readable if you inserted spaces before and after the minus, like that:

const message = messageList[messageList.length - 1];

wsVolume = wsVolume + e.data.length;

You could use the += operator here.


There are probably many other issues with this code, but I think it's enough for one time.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.