Client side shuffling spotlight content.

This commit is contained in:
bsimpson63
2013-01-22 19:40:23 -05:00
parent beffa52966
commit f7f346afa7
11 changed files with 326 additions and 148 deletions

View File

@@ -3095,6 +3095,26 @@ class ApiController(RedditController, OAuth2ResourceController):
Trophy.by_account(recipient, _update=True)
Trophy.by_award(award, _update=True)
@validatedForm(link=nop('link'),
campaign=nop('campaign'),
show=VBoolean('show'))
def GET_fetch_promo(self, form, jquery, link, campaign, show):
promo_tuples = [promote.PromoTuple(link, 1., campaign)]
builder = CampaignBuilder(promo_tuples,
wrap=default_thing_wrapper(),
keep_fn=promote.is_promoted)
promoted_links = builder.get_items()[0]
listing = SpotlightListing(organic_links=[],
promoted_links=promoted_links,
interestbar=None).listing()
jquery(".content").replace_things(listing)
if show:
jquery('.organic-listing .thing:visible').hide()
jquery('.organic-listing .id-%s' % link).show()
@validatedForm(links = VByName('links', thing_cls = Link, multiple = True),
show = VByName('show', thing_cls = Link, multiple = False))
def POST_fetch_links(self, form, jquery, links, show):

View File

@@ -225,6 +225,29 @@ class HotController(FixListing, ListingController):
extra_page_classes = ListingController.extra_page_classes + ['hot-page']
def spotlight(self):
"""Build the Spotlight or a single promoted link.
The frontpage gets a Spotlight box that contains promoted and organic
links from the user's subscribed subreddits and promoted links targeted
to the frontpage. Other subreddits get a single promoted link. In either
case if the user has disabled ads promoted links will not be shown.
The content of the Spotlight box is a bit tricky because a single
version of the frontpage is cached and displayed to all logged out
users. Because of the caching we must include many promoted links and
select one to display on the client side. Otherwise, each logged out
user would see the same promoted link and we would not get the desired
distribution of promoted link views. Most of the promoted links are
included as stubs to reduce the size of the page. When a promoted link
stub is selected by the lottery the full link is fetched and displayed.
There are only ~1000 cache resets per day so it is necessary to use
a large subset of the eligible promoted links when choosing stubs for
the Spotlight box. Using 100 stubs works great when there are fewer than
100 possible promoted links and allows room for growth.
"""
campaigns_by_link = {}
if (self.requested_ad or
not isinstance(c.site, DefaultSR) and c.user.pref_show_sponsors):
@@ -273,47 +296,53 @@ class HotController(FixListing, ListingController):
and (not c.user_is_loggedin
or (c.user_is_loggedin and c.user.pref_organic))):
spotlight_links = organic.organic_links(c.user)
random.shuffle(spotlight_links)
organic_fullnames = organic.organic_links(c.user)
promoted_links = []
# If prefs allow it, mix in promoted links and sr discovery content
if c.user.pref_show_sponsors or not c.user.gold:
if g.live_config['sr_discovery_links']:
spotlight_links.extend(g.live_config['sr_discovery_links'])
random.shuffle(spotlight_links)
spotlight_links, campaigns_by_link = promote.insert_promoted(spotlight_links)
organic_fullnames.extend(g.live_config['sr_discovery_links'])
if not spotlight_links:
n_promoted = 100
n_build = 10
promo_tuples = promote.get_promoted_links(c.user, c.site,
n_promoted)
promo_tuples = sorted(promo_tuples,
key=lambda p: p.weight,
reverse=True)
promo_build = promo_tuples[:n_build]
promo_stub = promo_tuples[n_build:]
b = CampaignBuilder(promo_build,
wrap=self.builder_wrapper,
keep_fn=promote.is_promoted)
promoted_links = b.get_items()[0]
promoted_links.extend(promo_stub)
if not (organic_fullnames or promoted_links):
return None
disp_links = spotlight_links[-2:] + spotlight_links[:8]
b = IDBuilder(disp_links,
random.shuffle(organic_fullnames)
organic_fullnames = organic_fullnames[:10]
b = IDBuilder(organic_fullnames,
wrap = self.builder_wrapper,
keep_fn = organic.keep_fresh_links,
skip = True)
vislink = spotlight_links[0]
s = SpotlightListing(b, spotlight_items = spotlight_links,
visible_item = vislink,
max_num = self.listing_obj.max_num,
max_score = self.listing_obj.max_score).listing()
organic_links = b.get_items()[0]
has_subscribed = c.user.has_subscribed
promo_visible = promote.is_promo(s.lookup[vislink])
if not promo_visible:
prob = g.live_config['spotlight_interest_sub_p'
if has_subscribed else
'spotlight_interest_nosub_p']
if random.random() < prob:
bar = InterestBar(has_subscribed)
s.spotlight_items.insert(0, bar)
s.visible_item = bar
interestbar_prob = g.live_config['spotlight_interest_sub_p'
if has_subscribed else
'spotlight_interest_nosub_p']
interestbar = InterestBar(has_subscribed)
if len(s.things) > 0:
# add campaign id to promoted links for tracking
for thing in s.things:
thing.campaign = campaigns_by_link.get(thing._fullname, None)
return s
s = SpotlightListing(organic_links=organic_links,
promoted_links=promoted_links,
interestbar=interestbar,
interestbar_prob=interestbar_prob,
max_num = self.listing_obj.max_num,
max_score = self.listing_obj.max_score).listing()
return s
def query(self):
#no need to worry when working from the cache

View File

@@ -294,6 +294,7 @@ module["reddit"] = LocalizedModule("reddit.js",
"reddit.js",
"apps.js",
"gold.js",
"spotlight.js",
)
module["mobile"] = LocalizedModule("mobile.js",

View File

@@ -22,7 +22,7 @@
from __future__ import with_statement
from collections import OrderedDict
from collections import OrderedDict, namedtuple
from datetime import datetime, timedelta
import json
import math
@@ -836,6 +836,23 @@ def randomized_promotion_list(user, site):
return [(l, cid) for l, w, cid in promos]
PromoTuple = namedtuple('PromoTuple', ['link', 'weight', 'campaign'])
def get_promoted_links(user, site, n=10):
"""Return a random selection of promoted links.
Does not factor weights, as that will be done client side.
"""
promos = get_promotion_list(user, site)
if n >= len(promos):
return [PromoTuple(*p) for p in promos]
else:
return [PromoTuple(*p) for p in random.sample(promos, n)]
def insert_promoted(link_names, promoted_every_n=6):
"""
Inserts promoted links into an existing organic list. Destructive

View File

@@ -39,6 +39,7 @@ from r2.lib import utils
from r2.lib.db import operators, tdb_cassandra
from r2.lib.filters import _force_unicode
from copy import deepcopy
from r2.lib.utils import Storage
from r2.models.wiki import WIKI_RECENT_DAYS
@@ -485,6 +486,43 @@ class IDBuilder(QueryBuilder):
return done, new_items
class CampaignBuilder(IDBuilder):
"""Build on a list of PromoTuples."""
def __init__(self, query, wrap=Wrapped, keep_fn=None, prewrap_fn=None):
Builder.__init__(self, wrap=wrap, keep_fn=keep_fn)
self.query = query
self.skip = False
self.num = None
self.start_count = 0
self.after = None
self.reverse = False
self.prewrap_fn = prewrap_fn
def thing_lookup(self, tuples):
links = Link._by_fullname([t.link for t in tuples], data=True,
return_dict=True, stale=self.stale)
return [Storage({'thing': links[t.link],
'_id': links[t.link]._id,
'weight': t.weight,
'campaign': t.campaign}) for t in tuples]
def wrap_items(self, items):
links = [i.thing for i in items]
wrapped = IDBuilder.wrap_items(self, links)
by_link = {w._fullname: w for w in wrapped}
ret = []
for i in items:
w = by_link[i.thing._fullname]
w.campaign = i.campaign
w.weight = i.weight
ret.append(w)
return ret
class SimpleBuilder(IDBuilder):
def thing_lookup(self, names):
return names

View File

@@ -31,6 +31,7 @@ from r2.lib import utils
from r2.lib.db import operators
from r2.lib.cache import sgm
from collections import namedtuple
from copy import deepcopy, copy
class Listing(object):
@@ -124,32 +125,66 @@ class NestedListing(Listing):
#make into a tree thing
return Wrapped(self)
SpotlightTuple = namedtuple('SpotlightTuple',
['link', 'is_promo', 'campaign', 'weight'])
class SpotlightListing(Listing):
# class used in Javascript to manage these objects
_js_cls = "OrganicListing"
def __init__(self, *a, **kw):
kw['vote_hash_type'] = kw.get('vote_hash_type', 'organic')
Listing.__init__(self, *a, **kw)
self.vote_hash_type = kw.get('vote_hash_type', 'organic')
self.nextprev = False
self.show_nums = True
self._parent_max_num = kw.get('max_num', 0)
self._parent_max_score = kw.get('max_score', 0)
self.spotlight_items = kw.get('spotlight_items', [])
self.visible_item = kw.get('visible_item', '')
self.interestbar = kw.get('interestbar')
self.interestbar_prob = kw.get('interestbar_prob', 0.)
self.promotion_prob = kw.get('promotion_prob', 0.5)
@property
def max_score(self):
return self._parent_max_score
promoted_links = kw.get('promoted_links', [])
organic_links = kw.get('organic_links', [])
@property
def max_num(self):
return self._parent_max_num
self.links = []
for l in organic_links:
self.links.append(
SpotlightTuple(
link=l._fullname,
is_promo=False,
campaign=None,
weight=None,
)
)
total = sum(float(l.weight) for l in promoted_links)
for l in promoted_links:
link = l._fullname if isinstance(l, Wrapped) else l.link
self.links.append(
SpotlightTuple(
link=link,
is_promo=True,
campaign=l.campaign,
weight=l.weight / total,
)
)
self.things = organic_links
self.things.extend(l for l in promoted_links
if isinstance(l, Wrapped))
def get_items(self):
from r2.lib.template_helpers import replace_render
things = self.things
for t in things:
if not hasattr(t, "render_replaced"):
t.render = replace_render(self, t, t.render)
t.render_replaced = True
return things, None, None, 0, 0
def listing(self):
res = Listing.listing(self)
# suppress item numbering
for t in res.things:
t.num = ""
self.lookup = {t._fullname : t for t in self.things}
return res
self.lookup = {t._fullname: t for t in res.things}
return Wrapped(self)

View File

@@ -986,9 +986,6 @@ a.author { margin-right: 0.5em; }
/* This is a really weird value, but it needs to be low to hide text without affecting layout in IE. */
text-indent: 50px;
}
.organic-listing .nextprev .arrow.prev {
background-image: url(../prev_organic.png); /* SPRITE */
}
.organic-listing .nextprev .arrow.next {
background-image: url(../next_organic.png); /* SPRITE */
}

View File

@@ -284,79 +284,6 @@ function cancelToggleForm(elem, form_class, button_class, on_hide) {
return false;
};
/* organic listing */
function get_organic(elem, next) {
var listing = $(elem).parents(".organic-listing");
var thing = listing.find(".thing:visible");
if(listing.find(":animated").length)
return false;
/* note we are looking for .thing.link while empty entries (if the
* loader isn't working) will be .thing.stub -> no visual
* glitches */
var next_thing;
if (next) {
next_thing = thing.nextAll(".thing:not(.stub)").filter(":first");
if (next_thing.length == 0)
next_thing = thing.siblings(".thing:not(.stub)").filter(":first");
}
else {
next_thing = thing.prevAll(".thing:not(.stub)").filter(":first");
if (next_thing.length == 0)
next_thing = thing.siblings(".thing:not(.stub)").filter(":last");
}
organic_help(listing, next_thing)
thing.fadeOut('fast', function() {
if(next_thing.length)
next_thing.fadeIn('fast', function() {
/* make sure the next n are loaded */
var n = 5;
var t = thing;
var to_fetch = [];
for(var i = 0; i < 2*n; i++) {
t = (next) ? t.nextAll(".thing:first") :
t.prevAll(".thing:first");
if(t.length == 0)
t = t.end().parent()
.children( (next) ? ".thing:first" :
".thing:last");
if(t.filter(".stub").length)
to_fetch.push(t.thing_id());
if(i >= n && to_fetch.length == 0)
break;
}
if(to_fetch.length) {
$.request("fetch_links",
{links: to_fetch.join(','),
listing: listing.attr("id")});
}
})
});
};
function organic_help(listing, thing) {
listing = listing || $('.organic-listing')
thing = thing || listing.find('.thing:visible')
var help = $('#spotlight-help')
if (!help.length) {
return
}
help.data('HelpBubble').hide(function() {
help.find('.help-section').hide()
if (thing.hasClass('promoted')) {
help.find('.help-promoted').show()
} else if (thing.hasClass('interestbar')) {
help.find('.help-interestbar').show()
} else {
help.find('.help-organic').show()
}
})
}
/* links */
@@ -598,25 +525,20 @@ function updateEventHandlers(thing) {
.click(function() {
var a = $(this).get(0);
change_state(a, 'hide',
function() { get_organic(a, 1); });
function() { r.spotlight.shuffle() });
});
thing.find(".del-button a.yes")
.click(function() {
var a = $(this).get(0);
change_state(a, 'del', function() { get_organic(a, 1); });
change_state(a, 'del',
function() { r.spotlight.shuffle() });
});
thing.find(".report-button a.yes")
.click(function() {
var a = $(this).get(0);
change_state(a, 'report',
function() { get_organic(a, 1); });
function() { r.spotlight.shuffle() });
});
/*thing.find(".arrow.down")
.one("click", function() {
var a = $(this).get(0);
get_organic($(this).get(0), 1);
}); */
}
};
@@ -1287,7 +1209,7 @@ $(function() {
$(this).select();
});
organic_help()
r.spotlight.shuffle();
/* ajax ynbutton */
function toggleThis() { return toggle(this); }

View File

@@ -0,0 +1,114 @@
r.spotlight = {}
r.spotlight.link_by_camp = {}
r.spotlight.weights = {}
r.spotlight.organics = []
r.spotlight.interest_prob = 0
r.spotlight.promotion_prob = 1
r.spotlight.init = function(links, interest_prob, promotion_prob) {
var link_by_camp = {},
weights = {},
organics = []
for (var index in links) {
var link = links[index][0],
is_promo = links[index][1],
campaign = links[index][2],
weight = links[index][3]
if (is_promo) {
link_by_camp[campaign] = link
weights[campaign] = weight
} else {
organics.push(link)
}
}
_.extend(r.spotlight.link_by_camp, link_by_camp)
_.extend(r.spotlight.weights, weights)
_.extend(r.spotlight.organics, organics)
r.spotlight.interest_prob = interest_prob
r.spotlight.promotion_prob = promotion_prob
}
r.spotlight.shuffle = function() {
var listing = $('.organic-listing'),
visible = listing.find(".thing:visible")
if (listing.length == 0) {
$.debug('exiting, no organic listing')
return
}
if(Math.random() < r.spotlight.promotion_prob) {
$.debug('showing promoted link')
var campaign_name = r.spotlight.weighted_lottery(r.spotlight.weights),
link_name = r.spotlight.link_by_camp[campaign_name],
thing = listing.find(".id-" + link_name)
$.debug('showing ' + campaign_name)
if (thing.hasClass('stub')) {
$.debug('fetching')
$.request("fetch_promo", {
link: link_name,
campaign: campaign_name,
show: true,
listing: listing.attr("id")
},
null, null, null, true
)
} else {
$.debug('no need to fetch')
$.debug('setting cid')
thing.data('cid', campaign_name)
}
r.spotlight.help('promoted')
} else if (Math.random() < r.spotlight.interest_prob) {
$.debug('showing interest bar')
var thing = listing.find(".interestbar")
r.spotlight.help('interestbar')
} else {
$.debug('showing organic link')
var name = r.spotlight.organics[Math.floor(Math.random() * r.spotlight.organics.length)],
thing = listing.find(".id-" + name)
r.spotlight.help('organic')
}
visible.hide()
thing.show()
}
r.spotlight.help = function(type) {
var help = $('#spotlight-help')
if (!help.length) {
return
}
help.data('HelpBubble').hide(function() {
help.find('.help-section').hide()
if (type == 'promoted') {
help.find('.help-promoted').show()
} else if (type == 'interestbar') {
help.find('.help-interestbar').show()
} else {
help.find('.help-organic').show()
}
})
}
r.spotlight.weighted_lottery = function(weights) {
var seed_rand = Math.random(),
t = 0
$.debug('random: ' + seed_rand)
for (var name in weights) {
weight = weights[name]
t += weight
$.debug(name + ': ' + weight)
if (t > seed_rand) {
$.debug('picked ' + name)
return name
}
}
$.debug('whoops, fell through!')
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 B

View File

@@ -22,33 +22,31 @@
<%namespace file="printablebuttons.html" import="ynbutton"/>
<%namespace file="utils.html" import="tags, text_with_links"/>
<%
<%
import json
from r2.lib.template_helpers import static
from r2.lib.wrapped import Templated
%>
<div id="siteTable_organic" class="organic-listing">
<%
seen = set([])
%>
%for name in thing.spotlight_items:
%if isinstance(name, Templated):
<div class="thing ${type(name).__name__.lower()}" ${tags(style="display:none" if thing.visible_item != name else None)}>
${unsafe(name.render())}
</div>
%elif name in seen:
<% pass %>
%elif name in thing.lookup:
<% seen.add(name) %>
${unsafe(thing.lookup[name].render(display = (thing.visible_item == name)))}
%else:
<div class="thing id-${name} stub" style="display:none"></div>
%endif
%endfor
%if thing.links:
%for tup in thing.links:
%if tup.link in thing.lookup:
${unsafe(thing.lookup[tup.link].render(display=False))}
%else:
<div class="thing id-${tup.link} stub" style="display:none"></div>
%endif
%endfor
%endif
%if thing.interestbar:
<div class="thing interestbar" style="display:none">
${unsafe(thing.interestbar.render())}
</div>
%endif
<div class="nextprev">
<button class="arrow prev" onclick="get_organic(this, false)">prev</button>
<button class="arrow next" onclick="get_organic(this, true)">next</button>
<button class="arrow next" onclick="r.spotlight.shuffle()">next</button>
</div>
<div class="help help-hoverable">
@@ -93,5 +91,12 @@
</div>
</div>
</div>
<script>
r.spotlight.init(
${unsafe(json.dumps(thing.links))},
${unsafe(json.dumps(thing.interestbar_prob))},
${unsafe(json.dumps(thing.promotion_prob))}
)
</script>
</div>