Skip to content

Instantly share code, notes, and snippets.

@vyznev
Last active April 11, 2021 13:33
Show Gist options
  • Save vyznev/955f6aac3d05a88f6e1dd3817b5b5cd4 to your computer and use it in GitHub Desktop.
Save vyznev/955f6aac3d05a88f6e1dd3817b5b5cd4 to your computer and use it in GitHub Desktop.
Stack Exchange Bounty Bar user script (prototype)
// ==UserScript==
// @name Stack Exchange Bounty Bar
// @namespace https://github.com/vyznev/
// @description Shows a randomized selection of bountied questions in the Stack Exchange sidebar
// @author Ilmari Karonen
// @version 0.0.19
// @copyright 2018, Ilmari Karonen
// @license ISC; https://opensource.org/licenses/ISC
// @match *://*.stackexchange.com/*
// @match *://*.stackoverflow.com/*
// @match *://superuser.com/*
// @match *://serverfault.com/*
// @match *://stackapps.com/*
// @match *://mathoverflow.net/*
// @match *://askubuntu.com/*
// @exclude *://stackexchange.com/*
// @exclude *://data.stackexchange.com/*
// @exclude *://area51.stackexchange.com/*
// @exclude *://openid.stackexchange.com/*
// @exclude *://chat.*/*
// @exclude *://blog.*/*
// @exclude *://meta.stackoverflow.com/*
// @exclude *://*.meta.stackoverflow.com/*
// @exclude /^https?://[a-z0-9\-]+\.meta\.stackexchange\.com//
// @grant none
// @noframes
// ==/UserScript==
var inject = function () {
"use strict";
var maxQuestions = 4;
var cacheTimeMillis = 6 * 60 * 60 * 1000;
var cacheKey = 'userscript-vyznev-bountybar-' + location.hostname;
var cacheVersion = 3; // increment on compatibility-breaking changes
var userId = StackExchange.options.user.userId;
// per-site metas have no bounties
if ( StackExchange.options.site.isChildMeta ) return;
// calculate question score multiplier for a tag with additive smoothing
// (effectively adds one virtual answer with +1 score to each tag)
function scoreTag ( tag ) {
var score = Math.max(tag.answer_score, 0) + 1;
score *= tag.answer_count + 1;
return Math.pow(score, 1/4);
}
// turn a list of tag names (with possible wildcards) into a regexp matching any of them
function makeTagRegExp ( tagNames ) {
if ( ! tagNames || ! tagNames.length ) return null;
var quotedNames = tagNames.map( s => s.replace( /([^\-0-9A-Za-z*])/g, '\\$1') ).map( s => s.replace(/\*/g, '.*') );
return new RegExp ( "^(?:" + quotedNames.join("|") + ")$" );
}
// return the relative likelihood of this question being chosen
function questionWeight ( question, tagScores, defaultScore, faveRe, ignoreRe, faveScale ) {
// start with bounty + 5 * score
var weight = question.bounty_amount + 5 * Math.max( 0, question.score );
// multiply by the geometric average of user's tag answer scores (including one "virtual tag" with the default score)
var tagScore = defaultScore;
question.tags.forEach( name => tagScore *= tagScores[name] || defaultScore );
weight *= Math.pow( tagScore, 1 / (question.tags.length + 1));
// adjust score if the question has any favorite or ignored tags
if ( faveRe && question.tags.filter( tag => faveRe.test(tag) ).length ) weight *= faveScale;
if ( ignoreRe && question.tags.filter( tag => ignoreRe.test(tag) ).length ) weight /= 2 * faveScale;
// divide by two if there is already an accepted answer
if ( question.accepted_answer_id ) weight /= 2;
// return a zero weight if anything went wrong (e.g. missing bounty amount on mod-cancelled bounties)
return isNaN(weight) ? 0 : weight;
}
// select random questions weighted by the bounty amount
// also semi-randomly shuffles the questions as a side effect
function selectQuestions ( questions, userTags, userPrefs ) {
if ( ! questions || questions.length < 1 ) return [];
var tagScores = {};
if ( userTags ) userTags.forEach( tag => tagScores[tag.tag_name] = scoreTag(tag) );
var defaultScore = scoreTag( { answer_score: 0, answer_count: 0 } );
var faveRe = userPrefs && makeTagRegExp( userPrefs.favorites );
var ignoreRe = userPrefs && makeTagRegExp( userPrefs.ignored );
var faveScale = Math.sqrt( questions.length );
var weights = questions.map( q => questionWeight( q, tagScores, defaultScore, faveRe, ignoreRe, faveScale ) );
var total = weights.reduce( (a, b) => a + b );
var selected = [];
for ( var i = 0; i < maxQuestions && total > 0; i++ ) {
if ( i + 1 == maxQuestions ) {
// kluge: ignore tag scores for last question in list
// TODO: smoothly scale down the tag weighting as a function of i?
for ( var k = 0; k < questions.length; k++ ) {
if ( weights[k] == 0 ) continue;
weights[k] = questionWeight( questions[k], {}, 1, null, null, 1 );
}
total = weights.reduce( (a, b) => a + b );
}
var sample = Math.random() * total, j = 0;
while ( j < weights.length && sample >= weights[j] ) sample -= weights[j++];
if ( j >= weights.length ) continue; // rounding error?
console.log( 'BountyBar selecting question ' + i + ' with weight ' + weights[j] + ':', questions[j].title );
selected.push( questions[j] );
total -= weights[j]; weights[j] = 0; // don't select this question again
}
console.log( 'BountyBar selected', selected.length, 'questions out of', questions.length );
return selected;
}
// source: https://stackoverflow.com/a/34064434
var entityParser = new DOMParser();
function decodeHtmlEntities ( input ) {
var doc = entityParser.parseFromString( input, "text/html" );
return doc.documentElement.textContent;
}
function showBountyBar ( data ) {
var questions = selectQuestions( data.featured, data.userTags, data.userPrefs );
console.log( 'BountyBar showing', questions.length, 'featured questions out of', data.featured.length );
if ( questions.length < 1 ) return; // nothing to show? :(
$('<style type="text/css">').text(
"#bountybar ul { margin: 0 }\n" +
"#bountybar li { list-style-type: none; display: flex; margin: 0 0 10px 0 }\n" +
"#bountybar .bountybar-indicator { vertical-align: top; flex: 0 0 38px; text-align: center; text-decoration: none; margin: 0 10px 0 0 }\n" +
".bountybar-indicator .bounty-indicator { float: none; display: block; margin: 0; transform: translateY(0px) }\n" +
".bountybar-question { vertical-align: top; white-space: normal }\n" +
".bountybar-question .question-hyperlink { font-size: 12px; font-weight: normal }\n" +
"#bountybar .bountybar-question .post-tag { font-size: 8px; margin-bottom: 0 }\n" +
".bountybar-more { font-size: 12px }\n"
).appendTo( document.head );
var bountyBar = $('<div id="bountybar" class="module sidebar-bountybar"><h4><a href="/questions?sort=featured" class="s-link s-link__inherit">Featured Questions</a></h4><ul></ul></div>');
var linkWrapper = $('<li><a class="bountybar-indicator"><div class="bounty-indicator"></div></a><div class="bountybar-question"><a class="question-hyperlink"></a><br></div></li>');
var tagWrapper = $('<a class="post-tag" rel="tag"></a>');
var moderatorTagRegExp = makeTagRegExp( data.moderatorTags );
var requiredTagRegExp = makeTagRegExp( data.requiredTags );
questions.forEach( question => {
var link = linkWrapper.clone().appendTo( bountyBar.find('ul') );
link.find('a').attr( 'href', question.link );
link.find('.question-hyperlink').text( decodeHtmlEntities( question.title ) );
link.find('.bounty-indicator').text( '+' + question.bounty_amount ).attr(
'title', 'this question has an open bounty worth ' + question.bounty_amount + ' reputation'
);
question.tags.forEach( tagname => {
var tag = tagWrapper.clone().appendTo( link.find('.bountybar-question') );
tag.text(tagname).attr( {
href: '/questions/tagged/' + encodeURIComponent(tagname) + '?sort=featured',
title: "show featured questions tagged '" + tagname + "'"
} );
if ( moderatorTagRegExp && moderatorTagRegExp.test( tagname ) ) tag.addClass('moderator-tag');
if ( requiredTagRegExp && requiredTagRegExp.test( tagname ) ) tag.addClass('required-tag');
} );
} );
if ( questions.length < data.featured.length ) {
bountyBar.append( '<a href="/questions?sort=featured" class="bountybar-more">...and ' + (data.featured.length - questions.length) + ' more</a>' );
}
bountyBar.insertBefore( $('#sidebar #hot-network-questions, #sidebar #feed-link').first() ); // no HNQ on MO!
if ( window.MathJax ) MathJax.Hub.Queue( [ 'Typeset', MathJax.Hub, 'bountybar' ] );
}
// extract all tag links from a DOM element
function extractTags ( parent ) {
var tagLinks = parent.querySelectorAll( 'a.post-tag' );
// console.log( 'BountyBar found', tagLinks.length, 'tag links in', parent );
var tagNames = [];
for ( var i = 0; i < tagLinks.length; i++ ) {
var m = /^\/questions\/tagged\/([^\/?#]+)/.exec( tagLinks[i].getAttribute( 'href' ) );
if ( m ) tagNames.push( m[1] );
}
return tagNames;
}
// extract favorite and ignored tags from a DOM document
function parseTagPrefs ( doc ) {
var favorites = doc.getElementById( 'interestingTags' );
var ignored = doc.getElementById( 'ignoredTags' );
if ( ! favorites || ! ignored ) return null;
favorites = extractTags( favorites );
ignored = extractTags( ignored );
console.log( 'BountyBar found', favorites.length, 'favorite and', ignored.length, 'ignored tags' );
return { favorites: favorites, ignored: ignored };
}
// load a page and extract the user's tag preferences from it
function loadTagPrefs ( url ) {
console.log( 'BountyBar loading tag preferences from', url );
return $.ajax( { method: 'GET', url: url, dataType: 'html' } )
.then( html => new DOMParser().parseFromString( html, 'text/html' ) )
.then( doc => parseTagPrefs( doc ) );
}
// simple promisified wrapper for setTimeout()
function wait ( seconds ) {
if ( ! seconds ) return $.when();
console.log( 'BountyBar: waiting', seconds, 'seconds before next SE API request' );
var deferred = $.Deferred();
setTimeout( () => deferred.resolve(), 1000 * seconds );
return deferred.promise();
}
// load data from SE API, handle errors, pagination and backoffs
// TODO: return error codes to caller for caching?
function apiRequest ( path, filter, page ) {
if ( ! page ) page = 1;
console.log( 'BountyBar loading API', path, 'page', page );
return $.ajax( {
url: 'https://api.stackexchange.com/2.2/' + path,
data: {
pagesize: 100,
page: page,
site: location.hostname,
filter: filter
},
dataType: 'json'
} ).then( response => {
// console.log( 'BountyBar received', response );
if ( response.error_id ) {
console.log( 'BountyBar: SE API error', response.error_id, response.error_name, response.error_message, 'for', path, 'page', page );
return response.items; // probably nothing, but hey, we'll take what we get
}
else if ( response.quota_remaining <= 0 ) {
console.log( 'BountyBar: SE API quota exhausted:', response.quota_remaining, '/', response.quota_max, 'for', path, 'page', page );
return response.items;
}
else if ( response.has_more ) {
return wait( response.backoff )
.then( () => apiRequest( path, filter, page + 1 ) )
.then( more => response.items.concat( more || [] ) );
}
else return response.items;
} );
}
// timestamp a JSON-like object and save it to LocalStorage
function saveToCache ( data ) {
data.timestamp = Date.now();
data.version = cacheVersion;
console.log( 'BountyBar saving API response to cache on', new Date( data.timestamp ) );
localStorage.setItem( cacheKey, JSON.stringify( data ) );
return data;
}
// load timestamped data from cache, check timestamp and version
function loadFromCache () {
var json = localStorage.getItem( cacheKey );
if ( ! json ) return null;
var data = JSON.parse( json );
if ( data.timestamp < Date.now() - cacheTimeMillis || data.version !== cacheVersion ) return null;
console.log( 'BountyBar using cached API data from', new Date ( data.timestamp ) );
return data;
}
// get featured questions and user tag preferences / stats from cache or from SE API
// TODO: auto-update cache when user favorites or ignores a tag
function getQuestionsAndTags () {
// try the cache first
var data = loadFromCache();
if ( data ) return data;
// else use the API to load featured questions...
data = {};
var loader = apiRequest( 'questions/featured', '!4(Yr(zc.w-fYYIDYq' ).then( items => data.featured = items );
// ...and the user's favorite, ignored and top answer tags... (TODO: auto-update when edited?)
if ( userId ) loader = loader
.then( () => apiRequest( 'users/' + userId + '/top-answer-tags', '!--LRPlyJUQrL' ) ).then( items => data.userTags = items )
.then( () => parseTagPrefs( document ) || loadTagPrefs( '/questions/tagged/nosuchtag' ) ).then( prefs => data.userPrefs = prefs );
// ...and any special tags for styling... (TODO: move to separate cache with longer lifetime?)
if ( StackExchange.options.site.isMetaSite || location.hostname == 'stackapps.com' ) loader = loader
.then( () => apiRequest( 'tags/moderator-only', '!-.G.68gzI8DP' ) ).then( items => data.moderatorTags = items.map(x => x.name) )
.then( () => apiRequest( 'tags/required', '!-.G.68gzI8DP' ) ).then( items => data.requiredTags = items.map(x => x.name) );
// ...and finally save them in the cache
return loader.then( () => saveToCache( data ) );
}
$.when( getQuestionsAndTags() ).then( data => showBountyBar(data) );
};
var script = document.createElement( 'script' );
script.textContent = 'window.StackExchange && StackExchange.ready && StackExchange.ready( ' + inject + ' );';
document.body.appendChild( script );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment