Last active
April 11, 2021 13:33
-
-
Save vyznev/955f6aac3d05a88f6e1dd3817b5b5cd4 to your computer and use it in GitHub Desktop.
Stack Exchange Bounty Bar user script (prototype)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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