diff --git a/r2/r2/controllers/api.py b/r2/r2/controllers/api.py index 8b8be5b18..2d1ba3ba3 100755 --- a/r2/r2/controllers/api.py +++ b/r2/r2/controllers/api.py @@ -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): diff --git a/r2/r2/controllers/listingcontroller.py b/r2/r2/controllers/listingcontroller.py index e2e958020..af31f0411 100755 --- a/r2/r2/controllers/listingcontroller.py +++ b/r2/r2/controllers/listingcontroller.py @@ -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 diff --git a/r2/r2/lib/js.py b/r2/r2/lib/js.py index f942d3e8a..25e25ed85 100755 --- a/r2/r2/lib/js.py +++ b/r2/r2/lib/js.py @@ -294,6 +294,7 @@ module["reddit"] = LocalizedModule("reddit.js", "reddit.js", "apps.js", "gold.js", + "spotlight.js", ) module["mobile"] = LocalizedModule("mobile.js", diff --git a/r2/r2/lib/promote.py b/r2/r2/lib/promote.py index 08aa2c78d..1b2766a9e 100644 --- a/r2/r2/lib/promote.py +++ b/r2/r2/lib/promote.py @@ -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 diff --git a/r2/r2/models/builder.py b/r2/r2/models/builder.py index a3a583cd2..a2b54d9d6 100755 --- a/r2/r2/models/builder.py +++ b/r2/r2/models/builder.py @@ -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 diff --git a/r2/r2/models/listing.py b/r2/r2/models/listing.py index 136d3be5d..d1d0cdbe6 100644 --- a/r2/r2/models/listing.py +++ b/r2/r2/models/listing.py @@ -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) diff --git a/r2/r2/public/static/css/reddit.css b/r2/r2/public/static/css/reddit.css index c327af041..0466135fc 100755 --- a/r2/r2/public/static/css/reddit.css +++ b/r2/r2/public/static/css/reddit.css @@ -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 */ } diff --git a/r2/r2/public/static/js/reddit.js b/r2/r2/public/static/js/reddit.js index 8d1e6b87d..2a14345d9 100644 --- a/r2/r2/public/static/js/reddit.js +++ b/r2/r2/public/static/js/reddit.js @@ -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); } diff --git a/r2/r2/public/static/js/spotlight.js b/r2/r2/public/static/js/spotlight.js new file mode 100644 index 000000000..40cff3fa4 --- /dev/null +++ b/r2/r2/public/static/js/spotlight.js @@ -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!') +} diff --git a/r2/r2/public/static/prev_organic.png b/r2/r2/public/static/prev_organic.png deleted file mode 100644 index 5ad151275..000000000 Binary files a/r2/r2/public/static/prev_organic.png and /dev/null differ diff --git a/r2/r2/templates/spotlightlisting.html b/r2/r2/templates/spotlightlisting.html index 2906e05bb..199e2cd24 100644 --- a/r2/r2/templates/spotlightlisting.html +++ b/r2/r2/templates/spotlightlisting.html @@ -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 %>