Google Play Review Rating Filter

Adds checkboxes to Google Play to filter app reviews based on their star rating out of five

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Google Play Review Rating Filter
// @version     1.1.1
// @description Adds checkboxes to Google Play to filter app reviews based on their star rating out of five
// @icon        https://s2.googleusercontent.com/s2/favicons?domain=https%3A%2F%2Fplay.google.com
// @namespace   http://www.qzdesign.co.uk/userscripts/
// @include     https://play.google.com/*
// @include     http://play.google.com/*
// @run-at      document-end
// @require     https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.min.js
// @grant       none
// ==/UserScript==

//'use strict';
/// false = no debug logging, true = some debug logging, 2 = extra debug logging
var debug = false;
try {

if (debug) console.log('GPRRF included');

// Avoid any possible jQuery conflict
if (typeof this !== 'undefined') {
  this.$ = this.jQuery = jQuery.noConflict(true);
}

/**
 * Set up prototypal inheritance.
 * @param sup `Function` that is the superclass's constructor
 * @param methods Object whose properties are methods to set in the prototype
 */
Function.prototype.gprrfExtend = function(sup, methods) {
  this.prototype = Object.create(sup.prototype);
  this.prototype.constructor = this;
  $.extend(this.prototype, methods);
};

/**
 * Class for hooking `XMLHttpRequest`
 */
var XMLHttpRequestHook = (function() {
  /**
   * Constructor
   * @param initData Object whose properties will be initially copied to the
   *        instance
   */
  function XMLHttpRequestHook(initData) {
    if (initData) {
      $.extend(this, initData);
    }
    activeHooks.push(this);
  }

  /** Array of currently active `XMLHttpRequestHook` objects */
  var activeHooks = [];
  /** Stores the original `XMLHttpRequest.prototype.open` */
  var xhrOpen = XMLHttpRequest.prototype.open;
  /** Like `Object.getOwnPropertyDescriptor` but traverses prototype chain */
  var getPropertyDescriptor = function(obj, prop) {
    return Object.getOwnPropertyDescriptor(obj, prop) ||
      getPropertyDescriptor(Object.getPrototypeOf(obj), prop);
  };

  /** Function to override `XMLHttpRequest.prototype.open` with */
  var xhrOpenHook = function() {
    if (debug > 1) console.log('XHR[%o] open(%o)', this, arguments);
    if (debug > 1) console.log('activeHooks: %o', activeHooks);
    // See if any active `XMLHttpRequestHook` will handle the request
    for (var i = 0, l = activeHooks.length; i < l; ++i) {
      var xhrHook = activeHooks[i];
      // Prepend the `XMLHttpRequest` object to the arguments
      if (xhrHook.open.bind(xhrHook, this).apply(void 0, arguments)) {
        if (debug > 1) console.log('XHR hooked for this request');
        return;
      }
    }
    // Otherwise call the original `XMLHttpRequest` method
    if (debug > 1) console.log('Passing on XHR request not hooked');
    return xhrOpen.apply(this, arguments);
  };

  // Assign the replacement `XMLHttpRequest.prototype.open`...
  // Handle running in a sandbox
  var pluginWrappedXMLHttpRequest = typeof exportFunction !== 'undefined' && (
    // Unified Script Injector
    XMLHttpRequest.wrappedJSObject ||
    // GreaseMonkey 4.x+
    unsafeWindow && unsafeWindow !== window && unsafeWindow.XMLHttpRequest
  );
  if (pluginWrappedXMLHttpRequest) {
    if (debug > 1) console.log('XMLHttpRequest wrapped by browser plugin');
    pluginWrappedXMLHttpRequest.prototype.open = exportFunction(
      xhrOpenHook,
      pluginWrappedXMLHttpRequest.prototype
    );
  } else {
    if (debug > 1) console.log('Modifying XMLHttpRequest directly (no sandbox)');
    XMLHttpRequest.prototype.open = xhrOpenHook;
  }

  // Define `XMLHttpRequestHook` prototype methods
  XMLHttpRequestHook.gprrfExtend(Object, {
    /**
     * This method is called in response to a call to
     * `XMLHttpRequest.prototype.open()`.  This default implementation hooks the
     * request so that the `send()` and `done()` methods will be called
     * accordingly, makes the `responseText`, `status` and `readyState`
     * properties writable, calls the original
     * `XMLHttpRequest.prototype.open()`, and returns `true`.
     * Overridden implementations should return `false` and not call this
     * implementation if the request should not be hooked.
     *
     * @param xhr The `XMLHttpRequest` object.
     * The other parameters are as for `XMLHttpRequest.prototype.open`
     *
     * @return `true` if the request has been hooked and the original
     * `XMLHttpRequest.prototype.open` should not now be called, `false`
     * otherwise.
     */
    open: function(xhr, method, url, async, user, password) {
      try {
        if (debug > 1) console.log('XHRH open(%o)', arguments);
        var xhrHook = this;
        /** Holder for `XMLHttpRequest` properties that will be overridden */
        var xhrProps = {
          // Store any value (or lack of) already set for `onreadystatechange`
          onreadystatechange: xhr.onreadystatechange
        };
        // Store original `send` property (usually from the prototype)
        xhr.orgSend = xhr.send;
        // Replace `XMLHttpRequest.prototype.send` for this instance
        xhr.send = function() {
          if (debug > 1) console.log('XHR[%o] send(%o)', this, arguments);
          // Prepend the `XMLHttpRequest` object to the arguments
          return xhrHook.send.bind(xhrHook, this).apply(void 0, arguments);
        };
        // Set the `onreadystatechange` function before redefining the property
        xhr.onreadystatechange = function() {
          if (debug > 1) console.log('XHR[%o] orsc(%o)', this, arguments);
          if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
            // Prepend the `XMLHttpRequest` object to the arguments
            return xhrHook.done.bind(xhrHook, this).apply(void 0, arguments);
          }
          // `xhr.onreadystatechange` will be the redefined property here
          if (xhr.onreadystatechange) {
            return xhr.onreadystatechange.apply(this, arguments);
          }
        };
        // Redefine `onreadystatechange` to have the value in `xhrProps`.
        // Make `responseText`, `status` and `readyState` properties writable.
        var props = [
          'onreadystatechange', 'responseText', 'status', 'readyState'
        ];
        props.forEach(function(prop) {
          var orgDescriptor = getPropertyDescriptor(xhr, prop);
          Object.defineProperty(xhr, prop, {
            get: function() {
              if (prop in xhrProps) {
                return xhrProps[prop];
              } else if (orgDescriptor.get) {
                return orgDescriptor.get.call(this);
              } else {
                return orgDescriptor.value;
              }
            },
            set: function(v) {
              if (debug > 1) console.log('set prop %s = %o', prop, v);
              xhrProps[prop] = v;
            },
            configurable: true,
            enumerable: true
          });
        });
        // Handle running in a Sandbox (i.e. in Unified Script Injector)
        if (xhr.wrappedJSObject && typeof exportFunction !== 'undefined') {
          if (debug > 1) console.log('XHR object wrapped by browser plugin');
          xhr.wrappedJSObject.send = exportFunction(
            xhr.send,
            xhr.wrappedJSObject
          );
          props.forEach(function(prop) {
            var pd = getPropertyDescriptor(xhr, prop);
            pd.set = exportFunction(pd.set, xhr.wrappedJSObject);
            pd.get = exportFunction(pd.get, xhr.wrappedJSObject);
            Object.defineProperty(xhr.wrappedJSObject, prop, pd);
          });
        }
        // Call original `XMLHttpRequest.prototype.open` with first argument as
        // `this` instead of being an argument
        Function.prototype.call.apply(xhrOpen, arguments);
        // The request has been hooked
        return true;
      }
      catch (e) {
        if (console && console.error) console.error('%o\n%o', e, e.stack);
        return false;
      }
    },

    /**
     * Called in response to a call to `send` on an `XMLHttpRequest` object
     * which was hooked by `XMLHttpRequestHook.prototype.open()`.
     *
     * @param xhr The `XMLHttpRequest` object.
     * The other parameters are as for `XMLHttpRequest.prototype.send`
     */
    send: function(xhr, data) {
      if (debug > 1) console.log('XHRH send(%o)', arguments);
      // Call original `xhr.send` with first argument as `this` instead of being
      // an argument
      return Function.prototype.call.apply(xhr.orgSend, arguments);
    },

    /**
     * Called in response to a chrome call to `onreadystatechange` for an
     * `XMLHttpRequest` object which was hooked by
     * `XMLHttpRequestHook.prototype.open()` when the `readyState` is
     * `XMLHttpRequest.DONE` and `status` is 200.
     *
     * If a content code has set an `onreadystatechange` function, this method
     * calls it after setting the `readyState` to `DONE` and `status` to 200, so
     * it can be called explicitly to return a response after setting the
     * `responseText` property.
     *
     * @param xhr The `XMLHttpRequest` object.
     */
    done: function(xhr) {
      if (debug > 1) console.log('XHRH done(%o)', arguments);
      xhr.status = 200;
      xhr.readyState = XMLHttpRequest.DONE;
      if (xhr.onreadystatechange) {
        // Call original `xhr.onreadystatechange` with first argument as `this`
        // instead of being an argument
        return Function.prototype.call.apply(xhr.onreadystatechange, arguments);
      }
    },

    /** Disable this hook */
    destruct: function() {
      activeHooks = activeHooks.filter(function(e) { return e !== this; });
    }
  });

  return XMLHttpRequestHook;
})();

$(function() {
  /**
   * Constructs a query string from an object's properties.  Like
   * `jQuery.param()` but does not use `encodeURIComponent`, instead assuming
   * properties and values are already suitably URI-encoded.  Google does not
   * expect spaces encoded as plus signs which is what it gets when
   * `jQuery.post()` is called with `data` as an object.
   */
  function param(obj) {
    var params = [];
    for (var k in obj) {
      if (obj.hasOwnProperty(k)) {
        params.push(k + '=' + obj[k]);
      }
    }
    return params.join('&');
  }
  /**
   * Perform the reverse of `param()`, i.e. construct object from query string
   */
  function deparam(queryString) {
    var obj = {};
    queryString.split('&').forEach(function(param) {
      var parts = param.split('=');
      var k = parts.shift();
      obj[k] = parts.join('='); // join in case of stray '='
    });
    return obj;
  }

  /**
   * Identity function.  Useful for `Array.prototype.some` and
   * `Array.prototype.every` when used on an array of booleans.
   */
  function identityFunction(v) { return v; }

  /**
   * Add the checkboxes to the UI for selecting which review ratings to filter.
   * Grab the total review count from the page.
   */
  function initialize() {
    totalNumReviews = +$('span.reviews-num').text().replace(/[,.]/g, '');
    if (debug) console.log('Total number of reviews: %d', totalNumReviews);
    if ($ratingsFilters) {
      $ratingsFilters.remove();
    }
    $ratingsFilters = $('<div class="review-filter"/>')
      .appendTo('div.review-filters')
      .change(function() {
        // Trigger a click on the currently selected sort order item to get
        // Google's script to fetch reviews afresh
        $('.id-review-sort-filter .dropdown-child.selected').click();
      });
    for (var i = 1; i <= 5; ++i) {
      $('<label/>')
        .text(i + '*')
        .prepend($('<input/>').attr({
          type: 'checkbox',
          id: 'review-filter-' + i + '-star',
          checked: ''
        }))
        .appendTo($ratingsFilters);
    }
  }

  /**
   * Extend `XMLHttpRequestHook` to only hook POST requests in which the path
   * part of the URL matches a specific string
   *
   * @param urlPath string which the path part of the URL must match
   * @param initData instance data and methods to set as properties
   */
  function GooglePlayXMLHttpRequestHook(urlPath, initData) {
    XMLHttpRequestHook.call(this, initData);
    this.urlPath = urlPath;
  }

  // Define `GooglePlayXMLHttpRequestHook` prototype methods
  GooglePlayXMLHttpRequestHook.gprrfExtend(XMLHttpRequestHook, {
    /**
     * @return `true` if the request should be hooked.
     * Parameters as for `open`.
     */
    hook: function(xhr, method, url) {
      return method === 'POST' && url &&
        this.urlPath ===
          url.replace(/^(?:https?:)?\/\/play\.google\.com/, '').split('?', 1)[0]
      ;
    },
    open: function(xhr, method, url) {
      return this.hook.apply(this, arguments) &&
        XMLHttpRequestHook.prototype.open.apply(this, arguments)
      ;
    }
  });

  /** The total number of reviews */
  var totalNumReviews;
  /** jQuery object for element containing rating filter checkboxes */
  var $ratingsFilters;

  /** The main `XMLHttpRequestHook` for marshalling review data */
  var getReviewsHook = new GooglePlayXMLHttpRequestHook('/store/getreviews', {
    /**
     * Array of Objects containing data about each pending request.  Each object
     * has the following properties:
     * - `pageNum`: The requested page number of the paged data;
     * - `postParams`: The parameters that are sent in the POST request body to
     *   Google Play, but without the `pageNum` parameter;
     * - `filters`: Array of booleans indexed by 'star rating' indicating
     *   whether reviews with this rating should be included in the results;
     * - `xhr`: The `XMLHttpRequest` object for the request;
     * - `fullDataKey`: Property key for `data` where data from unfiltered
     *   results obtained from Google is stored per-page;
     * - `filteredDataKey`: Property key for `filteredData` where data about the
     *   results obtained from Google, with the selected filters applied, is
     *   stored per-page.
     */
    pendingRequests: [],
    /**
     * Object whose property keys are a serialized representation of request
     * parameters without the page number or any rating filter selection, and
     * whose values are an array of objects, indexed by page number, containing
     * review data retrieved via AJAX.
     */
    data: {},
    /**
     * Object whose property keys are a serialized representation of request
     * parameters, including rating filter selections, but without the page
     * number, and whose values are an array of objects, indexed by page number,
     * containing review data retrieved via AJAX filtered according to rating.
     */
    filteredData: {},

    /**
     * Grab the current set of rating filters selected in the UI.
     * Don't bother hooking the request if all ratings selected.
     * Store the URL to use for internal AJAX requests.
     */
    hook: function(xhr, method, url) {
      if (GooglePlayXMLHttpRequestHook.prototype.hook.apply(this, arguments)) {
        if (/^([a-z]+:)?\/\//.test(url)) {
          /** URL to use for internal AJAX requests */
          this.requestURL = url;
        } else {
          // `url` does not include host
          var origin = location.protocol + '//' + location.hostname +
            (location.port ? ':' + location.port : '');
          if (url[0] === '/') {
            this.requestURL = origin + url;
          } else {
            this.requestURL =
              origin + location.pathname.replace(/[^\/]+$/, '') + url;
          }
        }
        // Which filters are selected?
        var filters = this.filters = [];
        $ratingsFilters.find('input').each(function() {
          filters[+(/^review-filter-(\d)-star$/.exec(this.id)[1])] =
            this.checked;
        });
        return !filters.every(identityFunction);
      } else {
        return false;
      }
    },

    /**
     * If the request is for review data, and is not an internal request made by
     * this script, create a pending request and then process pending requests
     * instead of calling `XMLHttpRequest.prototype.send()`.
     */
    send: function(xhr, data) {
      try {
        if (data) {
          var postParams = deparam(data);
          if (postParams.gprrfInternal) {
            // Internal request by this script
            if (debug > 1) console.log('Not hooking internal XHR request');
            delete postParams.gprrfInternal;
            return GooglePlayXMLHttpRequestHook.prototype.send.call(
              this,
              xhr,
              param(postParams)
            );
          } else {
            // Create a new request
            var rq = {
              pageNum: +postParams.pageNum,
              postParams: postParams,
              filters: this.filters,
              xhr: xhr
            };
            // Choose data keys unique to the set of parameters
            delete postParams.pageNum;
            var dataKeyParts = [];
            Object.keys(postParams).sort().forEach(function (key) {
              dataKeyParts.push(key + '=' + postParams[key]);
            });
            rq.fullDataKey = this.lastDataKey = dataKeyParts.join('&');
            rq.filteredDataKey =
              rq.fullDataKey + '&' + JSON.stringify(rq.filters);
            this.data[rq.fullDataKey] = this.data[rq.fullDataKey] || [];
            this.filteredData[rq.filteredDataKey] =
              this.filteredData[rq.filteredDataKey] || [];
            // Add request as pending and process, but do not call super:
            // `done()` will be called directly when the data is ready.
            this.pendingRequests.push(rq);
            if (debug) console.log('New request: %o', rq);
            return this.processPendingRequests();
          }
        }
        // Call super
        return GooglePlayXMLHttpRequestHook.prototype.send.apply(
          this,
          arguments
        );
      } catch (e) {
        if (console && console.error) console.error('%o\n%o', e, e.stack);
      }
    },

    /**
     * Process all pending requests.  When there are no pending requests,
     * possibly reclaim memory by removing obsolete data.
     */
    processPendingRequests: function() {
      this.pendingRequests = this.pendingRequests.filter(
        this.processRequest,
        this
      );
      if (!this.pendingRequests.length) {
        // Reclaim memory by discarding data obtained with different parameters
        [this.data, this.filteredData].forEach(function(d) {
          for (var k in d) {
            if (k.substr(0, this.lastDataKey.length) !== this.lastDataKey) {
              if (debug) console.log('Removing data with key %s', k);
              delete d[k];
            }
          }
        }, this);
      }
    },

    /**
     * Process a request.  If there is enough data to satisfy the request,
     * `done()` will be called with responseText set to the result, otherwise
     * more data will be requested via AJAX.
     *
     * @param rq Object containing data about the request
     *
     * @return `true` if the request is still pending, `false` if it is complete
     * or has failed (so the method can be used with `Array.prototype.filter`).
     */
    processRequest: function(rq) {
      function debugInfo() {
        return 'spn=' + srcPageNum + ' dpn=' + destPageNum +
          ' dprc=' + destPageReviewCount + ' dppc=' + destPagePageCount;
      }
      try {
        // Iterate through source pages working out how many are needed to get
        // enough reviews on each destination page.  Request source page data as
        // required.  When there is enough data to return for the requested
        // destination page, return it.  This involves iterating at least to the
        // next page to determine if the requested page would be the last.
        // This is slightly inefficient because after data is requested, the
        // iteration will begin at the start again when it is received.
        var srcPageNum = 0;
        var destPageNum = 0;
        var destPageReviewCount = 0;
        var destPagePageCount = 0;
        var nodesToInclude = [];
        for (;;) {
          var prevSrcPageData = srcPageData;
          var srcPageData = this.getFilteredSrcPageData(rq, srcPageNum);
          if (!srcPageData) {
            if (debug) console.log('requested more data: %s', debugInfo());
            return true; // Data has been requested, come back when it arrives
          }
          // Update counts for current destination page
          destPageReviewCount += srcPageData.numReviews;
          ++destPagePageCount;
          if (debug > 1) console.log('in loop: %s', debugInfo());
          // Include filtered nodes if destination page is requested page
          if (destPageNum === rq.pageNum) {
            Array.prototype.push.apply(nodesToInclude, srcPageData.nodes);
          }
          // Advance to next destination page if enough reviews found for
          // current:
          // - Try to get at least 3 reviews;
          // - On the first page, aim for more than will fit on the screen to
          //   avoid a Google bug where the next page is immediately requested
          //   but the requested page number is one after the last page that
          //   was requested for the previous result set;
          // - If there is at least one review, don't request more than 5 pages
          //   worth of data looking for more (too many rapid requests may
          //   trigger a temporary IP ban by Google);
          // - Bail after 10 pages of data have not yielded any reviews, for the
          //   same reason;
          if (
            destPageReviewCount >= (destPageNum === 0 ? 7 : 3) ||
            destPageReviewCount && destPagePageCount >= 5 ||
            destPagePageCount >= 10
          ) {
            if (debug) console.log('next dest page: %s', debugInfo());
            ++destPageNum;
            destPageReviewCount = 0;
            destPagePageCount = 0;
          }
          // Done?
          var anyFilter = anyFilter || rq.filters.some(identityFunction);
          if (
            // End if there are no more source pages
            !srcPageData.fullData.numReviews ||
            srcPageData.fullData.responseCode == 2 ||
            // End if on or past a non-empty destination page after the one
            // requested
            destPageNum > rq.pageNum + 1 ||
            destPageNum > rq.pageNum && destPageReviewCount ||
            // End if all filters unchecked.
            // Wait until now so that there is a JSON data template to use.
            !anyFilter
          ) {
            break;
          }
          // Advance to next source page
          if (rq.postParams.reviewSortOrder == 1 && !srcPageData.numReviews) {
            // No reviews on this page, and sorting by rating.
            // Binary search to find next page with reviews.
            // What rating to look for next?
            var ratingSought = 0;
            for (var i = 1; !srcPageData.fullData.hasRating[i] && i <= 5; ++i) {
              if (rq.filters[i]) {
                ratingSought = i;
              }
            }
            var pageBefore = 0;
            var pageAfter = Math.floor(
              (totalNumReviews - 1) /
              this.data[rq.fullDataKey][0].numReviews + 1
            );
            var pageOutOfRange = false;
            while (pageBefore + 1 < pageAfter) {
              // Try to get there more quickly if last page out of range
              // - there could be millions of reviews but only a few in the set
              var midPage = pageOutOfRange && pageBefore + 3 < pageAfter
                ? Math.floor((pageAfter + pageBefore * 3) / 4)
                : Math.floor((pageAfter + pageBefore) / 2);
              var midPageData = this.getFilteredSrcPageData(rq, midPage);
              if (!midPageData) {
                return true; // Data has been requested, come back later
              }
              pageOutOfRange = !midPageData.fullData.numReviews;
              if (pageOutOfRange) {
                pageAfter = midPage;
              } else {
                // If there are ratings equal or below the desired rating,
                // the mid page is after the sought page
                for (var i = 1; i <= ratingSought; ++i) {
                  if (midPageData.fullData.hasRating[i]) {
                    pageAfter = midPage;
                    break;
                  }
                }
                if (pageAfter !== midPage) {
                  pageBefore = midPage;
                }
              }
            }
            if (debug) console.log('spn: %d -> %d', srcPageNum, pageAfter);
            if (debug) console.assert(pageAfter > srcPageNum, 'Error!');
            srcPageNum = pageAfter;
          } else {
            ++srcPageNum;
          }
        }
        // Send the response to the content code...
        if (debug > 1) console.log('Delivering response to web page...');
        // Need to indicate if this is the last page.
        var isLastPage = destPageNum <= rq.pageNum ||
          destPageNum <= rq.pageNum + 1 && !destPageReviewCount;
        // Use some data obtained from Google as a template, so the response is
        // as consistent as possible with what is expected.
        var templateSrcPageData = (
          isLastPage && srcPageData.responseText || !prevSrcPageData
            ? srcPageData : prevSrcPageData
        ).fullData;
        var jsonData = [[
          templateSrcPageData.responseText, ///< "ecr"
          // Ensure 2 is returned for the last page
          isLastPage && templateSrcPageData.responseCode != 2
            ? 2 : templateSrcPageData.responseCode,
          // Ensure something is returned if there are no reviews, otherwise
          // the UI doesn't get updated
          $('<body/>').append(
            nodesToInclude.length || rq.pageNum
              ? $(nodesToInclude).clone()
              : $('<p/>').text(
                rq.postParams.reviewSortOrder == 1
                  ? 'No matching reviews could be obtained'
                  : 'No reviews found'
              )
          ).html(),
          rq.pageNum
        ]];
        if (debug) console.log('done: %s ilp=%o', debugInfo(), isLastPage);
        if (debug) console.log(
          'template=%o, nodes=%o, response=%o, this=%o',
          templateSrcPageData, nodesToInclude, jsonData, this
        );
        // Inlcude the preceding junk ")]}'\n\n" in the response
        rq.xhr.responseText =
          templateSrcPageData.junkBefore + JSON.stringify(jsonData);
        // Use `setTimeout` in case of immediate result in `send()`
        setTimeout(this.done.bind(this, rq.xhr), 0);
        // Request has been processed, remove it from 'pending' list
        return false;
      }
      catch (e) {
        if (console && console.error) console.error('%o\n%o', e, e.stack);
        return false;
      }
    },

    /**
     * Obtain and cache data for a page of reviews with a particular set of
     * parameters, filtered according to the requested ratings.  If data is not
     * yet available, it is requested via AJAX.
     *
     * @param rq An object containing parameters and details of the request
     * @param pageNum The page index of the source data to return
     *
     * @return If the data is not yet available, the return value is undefined.
     * Otherwise, an object with the following properties is returned:
     * - `nodes`: An array of `HTMLElement` objects which are either reviews
     *   which pass the filters or developer replies thereof;
     * - `numReviews`: The number of reviews in the filtered page data;
     * - `fullData`: The unfiltered data, an object with these properties:
     *   - `$domFragment`: A jQuery object which contains one HTML element whose
     *     children are the reviews and developer replies;
     *   - `numReviews`: The number of reviews on this page;
     *   - `hasRating`: An array indexed by rating whose values are `true` if
     *     there are any reviews on the page with this rating; this information
     *     is used when binary-searching for the next page containing reviews
     *     that will pass filtering when reviews are sorted by rating;
     *   - `responseCode`: 1 = ok, 2 = last page, 3 = error, undefined if HTTP
     *     error or response not as expected;
     *   - `responseText`: Always seems to be "ecr";
     *   - `junkBefore`: All responses seem to be preceded with junk ")]}'\n\n".
     */
    getFilteredSrcPageData: function(rq, pageNum) {
      var data = this.data[rq.fullDataKey][pageNum];
      if (data) {
        var filteredData = this.filteredData[rq.filteredDataKey][pageNum];
        if (!filteredData) {
          filteredData = {
            fullData: data,
            nodes: [],
            numReviews: 0
          };
          data.hasRating = data.hasRating || [];
          data.$domFragment.find('div.current-rating').each(function() {
            var $this = $(this);
            var rating = Math.round(parseInt($this.css('width')) / 20);
            data.hasRating[rating] = true;
            if (rq.filters[rating]) {
              // Include this review, and any developer reply
              if (debug > 1) console.log('%d* review included', rating);
              var $review = $this.closest('div.single-review');
              Array.prototype.push.apply(
                filteredData.nodes,
                $review.add($review.next('div.developer-reply')).get()
              );
              ++filteredData.numReviews;
            }
          });
          if (debug) console.log(
            'filtered data for page %d: %o',
            pageNum, filteredData
          );
          this.filteredData[rq.filteredDataKey][pageNum] = filteredData;
        }
        return filteredData;
      } else {
        var jqPostParam = {
          url: this.requestURL,
          data: param($.extend({}, rq.postParams, {
            pageNum: pageNum,
            gprrfInternal: true ///< Flag not to hook this request
          })),
          dataType: 'text'
        };
        if (debug > 1) console.log(
          'Doing POST for source page %d: %o', pageNum, jqPostParam
        );
        var jqXhr = $.post(jqPostParam);
        if (debug > 1) {
          jqXhr.done(function() { console.log('$.post succeeded'); })
            .fail(function() { console.log('$.post failed'); });
        }
        jqXhr.always(this.receiveData.bind(this, rq.fullDataKey, pageNum));
      }
    },

    /**
     * Store data received via AJAX, then reprocess pending requests
     *
     * @param dataKey Serialized representation of the request parameters
     *   to use as the property key of data to set
     * @param pageNum The source page number of the data
     * @param data String containing the data received on success; on failure
     *   this will be a `jqXHR` object
     */
    receiveData: function(dataKey, pageNum, data) {
      var dataObj = {
        // defaults in case of error
        responseText: 'ecr',
        responseCode: 3,
        junkBefore: ''
      };
      if (debug > 1) console.log('receivedData: %o', data);
      if (typeof data === 'string') {
        var skipJunk = data.indexOf('[');
        try {
          var jsonData = JSON.parse(skipJunk ? data.substr(skipJunk) : data);
          if (
            jsonData.length > 0 &&
            jsonData[0].length > 3 &&
            jsonData[0][3] == pageNum
          ) {
            dataObj.$domFragment = $('<body/>').html(jsonData[0][2]);
            dataObj.responseText = jsonData[0][0]; // "ecr"
            dataObj.responseCode = jsonData[0][1]; // 1 ok, 2 last page, 3 error
          }
        } catch (e) {
          if (debug) console.error(e);
        }
        dataObj.junkBefore = data.substr(0, skipJunk); // ")]}'\n\n"
      }
      dataObj.$domFragment = dataObj.$domFragment || $([]);
      dataObj.numReviews =
        dataObj.$domFragment.find('div.single-review').length;
      if (debug) console.log(
        'received page %d, key %s: %o',
        pageNum, dataKey, dataObj
      );
      this.data[dataKey][pageNum] = dataObj;
      this.processPendingRequests();
    }
  });

  /**
   * Reinitialize when a new App page is loaded via AJAX
   */
  var loadPageHook = new GooglePlayXMLHttpRequestHook('/store/apps/details', {
    /**
     * Hook if the request contains a URL parameter `psid` with value 2, which
     * indicates a request for new App review content.
     */
    hook: function(xhr, method, url) {
      if (GooglePlayXMLHttpRequestHook.prototype.hook.apply(this, arguments)) {
        var urlParts = url.split('?', 2);
        if (debug) console.log(urlParts);
        return urlParts.length > 1 && deparam(urlParts[1]).psid == 2;
      } else {
        return false;
      }
    },
    done: function(xhr) {
      if (debug) console.log(xhr.responseURL);
      if (debug > 1) console.log(xhr.responseText);
      GooglePlayXMLHttpRequestHook.prototype.done.apply(this, arguments);
      initialize();
    }
  });

  // Initialize UI and review count for the page when first loaded
  initialize();
});

} catch (e) {
  if (console && console.error) console.error(
    'Error at line %d:\n%o\n%o', e.lineNumber, e, e.stack
  );
}