Google Play Review Rating Filter

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

当前为 2016-11-08 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Google Play Review Rating Filter
  3. // @version 1.0.0
  4. // @description Adds checkboxes to Google Play to filter app reviews based on their star rating out of five
  5. // @icon https://s2.googleusercontent.com/s2/favicons?domain=https%3A%2F%2Fplay.google.com
  6. // @namespace http://www.qzdesign.co.uk/userscripts/
  7. // @include https://play.google.com/*
  8. // @include http://play.google.com/*
  9. // @run-at document-end
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/1.12.4/jquery.min.js
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. //'use strict';
  15. /// false = no debug logging, true = some debug logging, 2 = extra debug logging
  16. var debug = false;
  17. try {
  18.  
  19. if (debug) console.log('GPRRF included');
  20.  
  21. // Avoid any possible jQuery conflict
  22. this.$ = this.jQuery = jQuery.noConflict(true);
  23.  
  24. /**
  25. * Set up prototypal inheritance.
  26. * @param sup `Function` that is the superclass's constructor
  27. * @param methods Object whose properties are methods to set in the prototype
  28. */
  29. Function.prototype.gprrfExtend = function(sup, methods) {
  30. this.prototype = Object.create(sup.prototype);
  31. this.prototype.constructor = this;
  32. $.extend(this.prototype, methods);
  33. };
  34.  
  35. /**
  36. * Class for hooking `XMLHttpRequest`
  37. */
  38. var XMLHttpRequestHook = (function() {
  39. /**
  40. * Constructor
  41. * @param initData Object whose properties will be initially copied to the
  42. * instance
  43. */
  44. function XMLHttpRequestHook(initData) {
  45. if (initData) {
  46. $.extend(this, initData);
  47. }
  48. activeHooks.push(this);
  49. }
  50.  
  51. /** Array of currently active `XMLHttpRequestHook` objects */
  52. var activeHooks = [];
  53. /** Stores the original `XMLHttpRequest.prototype.open` */
  54. var xhrOpen = XMLHttpRequest.prototype.open;
  55. /** Like `Object.getOwnPropertyDescriptor` but traverses prototype chain */
  56. var getPropertyDescriptor = function(obj, prop) {
  57. return Object.getOwnPropertyDescriptor(obj, prop) ||
  58. getPropertyDescriptor(Object.getPrototypeOf(obj), prop);
  59. };
  60.  
  61. /** Function to override `XMLHttpRequest.prototype.open` with */
  62. var xhrOpenHook = function() {
  63. if (debug > 1) console.log('XHR[%o] open(%o)', this, arguments);
  64. if (debug > 1) console.log('activeHooks: %o', activeHooks);
  65. // See if any active `XMLHttpRequestHook` will handle the request
  66. for (var i = 0, l = activeHooks.length; i < l; ++i) {
  67. var xhrHook = activeHooks[i];
  68. // Prepend the `XMLHttpRequest` object to the arguments
  69. if (xhrHook.open.bind(xhrHook, this).apply(void 0, arguments)) {
  70. return;
  71. }
  72. }
  73. // Otherwise call the original `XMLHttpRequest` method
  74. return xhrOpen.apply(this, arguments);
  75. };
  76.  
  77. // Assign the replacement `XMLHttpRequest.prototype.open` -
  78. // Handle running in a Sandbox (i.e. if included via Unified Script Injector)
  79. if (XMLHttpRequest.wrappedJSObject && typeof exportFunction !== 'undefined') {
  80. XMLHttpRequest.wrappedJSObject.prototype.open = exportFunction(
  81. xhrOpenHook,
  82. XMLHttpRequest.wrappedJSObject.prototype
  83. );
  84. } else {
  85. XMLHttpRequest.prototype.open = xhrOpenHook;
  86. }
  87.  
  88. // Define `XMLHttpRequestHook` prototype methods
  89. XMLHttpRequestHook.gprrfExtend(Object, {
  90. /**
  91. * This method is called in response to a call to
  92. * `XMLHttpRequest.prototype.open()`. This default implementation hooks the
  93. * request so that the `send()` and `done()` methods will be called
  94. * accordingly, makes the `responseText`, `status` and `readyState`
  95. * properties writable, calls the original
  96. * `XMLHttpRequest.prototype.open()`, and returns `true`.
  97. * Overridden implementations should return `false` and not call this
  98. * implementation if the request should not be hooked.
  99. *
  100. * @param xhr The `XMLHttpRequest` object.
  101. * The other parameters are as for `XMLHttpRequest.prototype.open`
  102. *
  103. * @return `true` if the request has been hooked and the original
  104. * `XMLHttpRequest.prototype.open` should not now be called, `false`
  105. * otherwise.
  106. */
  107. open: function(xhr, method, url, async, user, password) {
  108. try {
  109. if (debug > 1) console.log('XHRH open(%o)', arguments);
  110. var xhrHook = this;
  111. /** Holder for `XMLHttpRequest` properties that will be overridden */
  112. var xhrProps = {
  113. // Store any value (or lack of) already set for `onreadystatechange`
  114. onreadystatechange: xhr.onreadystatechange
  115. };
  116. // Store original `send` property (usually from the prototype)
  117. xhr.orgSend = xhr.send;
  118. // Replace `XMLHttpRequest.prototype.send` for this instance
  119. xhr.send = function() {
  120. if (debug > 1) console.log('XHR[%o] send(%o)', this, arguments);
  121. // Prepend the `XMLHttpRequest` object to the arguments
  122. return xhrHook.send.bind(xhrHook, this).apply(void 0, arguments);
  123. };
  124. // Set the `onreadystatechange` function before redefining the property
  125. xhr.onreadystatechange = function() {
  126. if (debug > 1) console.log('XHR[%o] orsc(%o)', this, arguments);
  127. if (this.readyState === XMLHttpRequest.DONE && this.status === 200) {
  128. // Prepend the `XMLHttpRequest` object to the arguments
  129. return xhrHook.done.bind(xhrHook, this).apply(void 0, arguments);
  130. }
  131. // `xhr.onreadystatechange` will be the redefined property here
  132. if (xhr.onreadystatechange) {
  133. return xhr.onreadystatechange.apply(this, arguments);
  134. }
  135. };
  136. // Redefine `onreadystatechange` to have the value in `xhrProps`.
  137. // Make `responseText`, `status` and `readyState` properties writable.
  138. var props = [
  139. 'onreadystatechange', 'responseText', 'status', 'readyState'
  140. ];
  141. props.forEach(function(prop) {
  142. var orgDescriptor = getPropertyDescriptor(xhr, prop);
  143. Object.defineProperty(xhr, prop, {
  144. get: function() {
  145. if (prop in xhrProps) {
  146. return xhrProps[prop];
  147. } else if (orgDescriptor.get) {
  148. return orgDescriptor.get.call(this);
  149. } else {
  150. return orgDescriptor.value;
  151. }
  152. },
  153. set: function(v) {
  154. if (debug > 1) console.log('set prop %s = %o', prop, v);
  155. xhrProps[prop] = v;
  156. },
  157. configurable: true,
  158. enumerable: true
  159. });
  160. });
  161. // Handle running in a Sandbox (i.e. in Unified Script Injector)
  162. if (xhr.wrappedJSObject && typeof exportFunction !== 'undefined') {
  163. xhr.wrappedJSObject.send = exportFunction(
  164. xhr.send,
  165. xhr.wrappedJSObject
  166. );
  167. props.forEach(function(prop) {
  168. var pd = getPropertyDescriptor(xhr, prop);
  169. pd.set = exportFunction(pd.set, xhr.wrappedJSObject);
  170. pd.get = exportFunction(pd.get, xhr.wrappedJSObject);
  171. Object.defineProperty(xhr.wrappedJSObject, prop, pd);
  172. });
  173. }
  174. // Call original `XMLHttpRequest.prototype.open` with first argument as
  175. // `this` instead of being an argument
  176. Function.prototype.call.apply(xhrOpen, arguments);
  177. // The request has been hooked
  178. return true;
  179. }
  180. catch (e) {
  181. if (console && console.error) console.error('%o\n%o', e, e.stack);
  182. return false;
  183. }
  184. },
  185.  
  186. /**
  187. * Called in response to a call to `send` on an `XMLHttpRequest` object
  188. * which was hooked by `XMLHttpRequestHook.prototype.open()`.
  189. *
  190. * @param xhr The `XMLHttpRequest` object.
  191. * The other parameters are as for `XMLHttpRequest.prototype.send`
  192. */
  193. send: function(xhr, data) {
  194. if (debug > 1) console.log('XHRH send(%o)', arguments);
  195. // Call original `xhr.send` with first argument as `this` instead of being
  196. // an argument
  197. return Function.prototype.call.apply(xhr.orgSend, arguments);
  198. },
  199.  
  200. /**
  201. * Called in response to a chrome call to `onreadystatechange` for an
  202. * `XMLHttpRequest` object which was hooked by
  203. * `XMLHttpRequestHook.prototype.open()` when the `readyState` is
  204. * `XMLHttpRequest.DONE` and `status` is 200.
  205. *
  206. * If a content code has set an `onreadystatechange` function, this method
  207. * calls it after setting the `readyState` to `DONE` and `status` to 200, so
  208. * it can be called explicitly to return a response after setting the
  209. * `responseText` property.
  210. *
  211. * @param xhr The `XMLHttpRequest` object.
  212. */
  213. done: function(xhr) {
  214. if (debug > 1) console.log('XHRH done(%o)', arguments);
  215. xhr.status = 200;
  216. xhr.readyState = XMLHttpRequest.DONE;
  217. if (xhr.onreadystatechange) {
  218. // Call original `xhr.onreadystatechange` with first argument as `this`
  219. // instead of being an argument
  220. return Function.prototype.call.apply(xhr.onreadystatechange, arguments);
  221. }
  222. },
  223.  
  224. /** Disable this hook */
  225. destruct: function() {
  226. activeHooks = activeHooks.filter(function(e) { return e !== this; });
  227. }
  228. });
  229.  
  230. return XMLHttpRequestHook;
  231. })();
  232.  
  233. $(function() {
  234. /**
  235. * Constructs a query string from an object's properties. Like
  236. * `jQuery.param()` but does not use `encodeURIComponent`, instead assuming
  237. * properties and values are already suitably URI-encoded. Google does not
  238. * expect spaces encoded as plus signs which is what it gets when
  239. * `jQuery.post()` is called with `data` as an object.
  240. */
  241. function param(obj) {
  242. var params = [];
  243. for (var k in obj) {
  244. if (obj.hasOwnProperty(k)) {
  245. params.push(k + '=' + obj[k]);
  246. }
  247. }
  248. return params.join('&');
  249. }
  250. /**
  251. * Perform the reverse of `param()`, i.e. construct object from query string
  252. */
  253. function deparam(queryString) {
  254. var obj = {};
  255. queryString.split('&').forEach(function(param) {
  256. var parts = param.split('=');
  257. var k = parts.shift();
  258. obj[k] = parts.join('='); // join in case of stray '='
  259. });
  260. return obj;
  261. }
  262.  
  263. /**
  264. * Identity function. Useful for `Array.prototype.some` and
  265. * `Array.prototype.every` when used on an array of booleans.
  266. */
  267. function identityFunction(v) { return v; }
  268.  
  269. /**
  270. * Add the checkboxes to the UI for selecting which review ratings to filter.
  271. * Grab the total review count from the page.
  272. */
  273. function initialize() {
  274. totalNumReviews = +$('span.reviews-num').text().replace(/[,.]/g, '');
  275. if (debug) console.log('Total number of reviews: %d', totalNumReviews);
  276. if ($ratingsFilters) {
  277. $ratingsFilters.remove();
  278. }
  279. $ratingsFilters = $('<div class="review-filter"/>')
  280. .appendTo('div.review-filters')
  281. .change(function() {
  282. // Trigger a click on the currently selected sort order item to get
  283. // Google's script to fetch reviews afresh
  284. $('.id-review-sort-filter .dropdown-child.selected').click();
  285. });
  286. for (var i = 1; i <= 5; ++i) {
  287. $('<label/>')
  288. .text(i + '*')
  289. .prepend($('<input/>').attr({
  290. type: 'checkbox',
  291. id: 'review-filter-' + i + '-star',
  292. checked: ''
  293. }))
  294. .appendTo($ratingsFilters);
  295. }
  296. }
  297.  
  298. /**
  299. * Extend `XMLHttpRequestHook` to only hook POST requests in which the path
  300. * part of the URL matches a specific string
  301. *
  302. * @param urlPath string which the path part of the URL must match
  303. * @param initData instance data and methods to set as properties
  304. */
  305. function GooglePlayXMLHttpRequestHook(urlPath, initData) {
  306. XMLHttpRequestHook.call(this, initData);
  307. this.urlPath = urlPath;
  308. }
  309.  
  310. // Define `GooglePlayXMLHttpRequestHook` prototype methods
  311. GooglePlayXMLHttpRequestHook.gprrfExtend(XMLHttpRequestHook, {
  312. /**
  313. * @return `true` if the request should be hooked.
  314. * Parameters as for `open`.
  315. */
  316. hook: function(xhr, method, url) {
  317. return method === 'POST' && url &&
  318. this.urlPath ===
  319. url.replace(/^(?:https?:)?\/\/play\.google\.com/, '').split('?', 1)[0]
  320. ;
  321. },
  322. open: function(xhr, method, url) {
  323. return this.hook.apply(this, arguments) &&
  324. XMLHttpRequestHook.prototype.open.apply(this, arguments)
  325. ;
  326. }
  327. });
  328.  
  329. /** The total number of reviews */
  330. var totalNumReviews;
  331. /** jQuery object for element containing rating filter checkboxes */
  332. var $ratingsFilters;
  333.  
  334. /** The main `XMLHttpRequestHook` for marshalling review data */
  335. var getReviewsHook = new GooglePlayXMLHttpRequestHook('/store/getreviews', {
  336. /**
  337. * Array of Objects containing data about each pending request. Each object
  338. * has the following properties:
  339. * - `pageNum`: The requested page number of the paged data;
  340. * - `postParams`: The parameters that are sent in the POST request body to
  341. * Google Play, but without the `pageNum` parameter;
  342. * - `filters`: Array of booleans indexed by 'star rating' indicating
  343. * whether reviews with this rating should be included in the results;
  344. * - `xhr`: The `XMLHttpRequest` object for the request;
  345. * - `fullDataKey`: Property key for `data` where data from unfiltered
  346. * results obtained from Google is stored per-page;
  347. * - `filteredDataKey`: Property key for `filteredData` where data about the
  348. * results obtained from Google, with the selected filters applied, is
  349. * stored per-page.
  350. */
  351. pendingRequests: [],
  352. /**
  353. * Object whose property keys are a serialized representation of request
  354. * parameters without the page number or any rating filter selection, and
  355. * whose values are an array of objects, indexed by page number, containing
  356. * review data retrieved via AJAX.
  357. */
  358. data: {},
  359. /**
  360. * Object whose property keys are a serialized representation of request
  361. * parameters, including rating filter selections, but without the page
  362. * number, and whose values are an array of objects, indexed by page number,
  363. * containing review data retrieved via AJAX filtered according to rating.
  364. */
  365. filteredData: {},
  366.  
  367. /**
  368. * Grab the current set of rating filters selected in the UI.
  369. * Don't bother hooking the request if all ratings selected.
  370. * Store the URL to use for internal AJAX requests.
  371. */
  372. hook: function(xhr, method, url) {
  373. if (GooglePlayXMLHttpRequestHook.prototype.hook.apply(this, arguments)) {
  374. /** URL to use for internal AJAX requests */
  375. this.requestURL = url;
  376. // Which filters are selected?
  377. var filters = this.filters = [];
  378. $ratingsFilters.find('input').each(function() {
  379. filters[+(/^review-filter-(\d)-star$/.exec(this.id)[1])] =
  380. this.checked;
  381. });
  382. return !filters.every(identityFunction);
  383. } else {
  384. return false;
  385. }
  386. },
  387.  
  388. /**
  389. * If the request is for review data, and is not an internal request made by
  390. * this script, create a pending request and then process pending requests
  391. * instead of calling `XMLHttpRequest.prototype.send()`.
  392. */
  393. send: function(xhr, data) {
  394. try {
  395. if (data) {
  396. var postParams = deparam(data);
  397. if (postParams.gprrfInternal) {
  398. // Internal request by this script
  399. delete postParams.gprrfInternal;
  400. return GooglePlayXMLHttpRequestHook.prototype.send.call(
  401. this,
  402. xhr,
  403. param(postParams)
  404. );
  405. } else {
  406. // Create a new request
  407. var rq = {
  408. pageNum: +postParams.pageNum,
  409. postParams: postParams,
  410. filters: this.filters,
  411. xhr: xhr
  412. };
  413. // Choose data keys unique to the set of parameters
  414. delete postParams.pageNum;
  415. var dataKeyParts = [];
  416. Object.keys(postParams).sort().forEach(function (key) {
  417. dataKeyParts.push(key + '=' + postParams[key]);
  418. });
  419. rq.fullDataKey = this.lastDataKey = dataKeyParts.join('&');
  420. rq.filteredDataKey =
  421. rq.fullDataKey + '&' + JSON.stringify(rq.filters);
  422. this.data[rq.fullDataKey] = this.data[rq.fullDataKey] || [];
  423. this.filteredData[rq.filteredDataKey] =
  424. this.filteredData[rq.filteredDataKey] || [];
  425. // Add request as pending and process, but do not call super:
  426. // `done()` will be called directly when the data is ready.
  427. this.pendingRequests.push(rq);
  428. if (debug) console.log('New request: %o', rq);
  429. return this.processPendingRequests();
  430. }
  431. }
  432. // Call super
  433. return GooglePlayXMLHttpRequestHook.prototype.send.apply(
  434. this,
  435. arguments
  436. );
  437. } catch (e) {
  438. if (console && console.error) console.error('%o\n%o', e, e.stack);
  439. }
  440. },
  441.  
  442. /**
  443. * Process all pending requests. When there are no pending requests,
  444. * possibly reclaim memory by removing obsolete data.
  445. */
  446. processPendingRequests: function() {
  447. this.pendingRequests = this.pendingRequests.filter(
  448. this.processRequest,
  449. this
  450. );
  451. if (!this.pendingRequests.length) {
  452. // Reclaim memory by discarding data obtained with different parameters
  453. [this.data, this.filteredData].forEach(function(d) {
  454. for (var k in d) {
  455. if (k.substr(0, this.lastDataKey.length) !== this.lastDataKey) {
  456. if (debug) console.log('Removing data with key %s', k);
  457. delete d[k];
  458. }
  459. }
  460. }, this);
  461. }
  462. },
  463.  
  464. /**
  465. * Process a request. If there is enough data to satisfy the request,
  466. * `done()` will be called with responseText set to the result, otherwise
  467. * more data will be requested via AJAX.
  468. *
  469. * @param rq Object containing data about the request
  470. *
  471. * @return `true` if the request is still pending, `false` if it is complete
  472. * or has failed (so the method can be used with `Array.prototype.filter`).
  473. */
  474. processRequest: function(rq) {
  475. function debugInfo() {
  476. return 'spn=' + srcPageNum + ' dpn=' + destPageNum +
  477. ' dprc=' + destPageReviewCount + ' dppc=' + destPagePageCount;
  478. }
  479. try {
  480. // Iterate through source pages working out how many are needed to get
  481. // enough reviews on each destination page. Request source page data as
  482. // required. When there is enough data to return for the requested
  483. // destination page, return it. This involves iterating at least to the
  484. // next page to determine if the requested page would be the last.
  485. // This is slightly inefficient because after data is requested, the
  486. // iteration will begin at the start again when it is received.
  487. var srcPageNum = 0;
  488. var destPageNum = 0;
  489. var destPageReviewCount = 0;
  490. var destPagePageCount = 0;
  491. var nodesToInclude = [];
  492. for (;;) {
  493. var prevSrcPageData = srcPageData;
  494. var srcPageData = this.getFilteredSrcPageData(rq, srcPageNum);
  495. if (!srcPageData) {
  496. if (debug) console.log('requested more data: %s', debugInfo());
  497. return true; // Data has been requested, come back when it arrives
  498. }
  499. // Update counts for current destination page
  500. destPageReviewCount += srcPageData.numReviews;
  501. ++destPagePageCount;
  502. if (debug > 1) console.log('in loop: %s', debugInfo());
  503. // Include filtered nodes if destination page is requested page
  504. if (destPageNum === rq.pageNum) {
  505. Array.prototype.push.apply(nodesToInclude, srcPageData.nodes);
  506. }
  507. // Advance to next destination page if enough reviews found for
  508. // current:
  509. // - Try to get at least 3 reviews;
  510. // - On the first page, aim for more than will fit on the screen to
  511. // avoid a Google bug where the next page is immediately requested
  512. // but the requested page number is one after the last page that
  513. // was requested for the previous result set;
  514. // - If there is at least one review, don't request more than 5 pages
  515. // worth of data looking for more (too many rapid requests may
  516. // trigger a temporary IP ban by Google);
  517. // - Bail after 10 pages of data have not yielded any reviews, for the
  518. // same reason;
  519. if (
  520. destPageReviewCount >= (destPageNum === 0 ? 7 : 3) ||
  521. destPageReviewCount && destPagePageCount >= 5 ||
  522. destPagePageCount >= 10
  523. ) {
  524. if (debug) console.log('next dest page: %s', debugInfo());
  525. ++destPageNum;
  526. destPageReviewCount = 0;
  527. destPagePageCount = 0;
  528. }
  529. // Done?
  530. var anyFilter = anyFilter || rq.filters.some(identityFunction);
  531. if (
  532. // End if there are no more source pages
  533. !srcPageData.fullData.numReviews ||
  534. srcPageData.fullData.responseCode == 2 ||
  535. // End if on or past a non-empty destination page after the one
  536. // requested
  537. destPageNum > rq.pageNum + 1 ||
  538. destPageNum > rq.pageNum && destPageReviewCount ||
  539. // End if all filters unchecked.
  540. // Wait until now so that there is a JSON data template to use.
  541. !anyFilter
  542. ) {
  543. break;
  544. }
  545. // Advance to next source page
  546. if (rq.postParams.reviewSortOrder == 1 && !srcPageData.numReviews) {
  547. // No reviews on this page, and sorting by rating.
  548. // Binary search to find next page with reviews.
  549. // What rating to look for next?
  550. var ratingSought = 0;
  551. for (var i = 1; !srcPageData.fullData.hasRating[i] && i <= 5; ++i) {
  552. if (rq.filters[i]) {
  553. ratingSought = i;
  554. }
  555. }
  556. var pageBefore = 0;
  557. var pageAfter = Math.floor(
  558. (totalNumReviews - 1) /
  559. this.data[rq.fullDataKey][0].numReviews + 1
  560. );
  561. var pageOutOfRange = false;
  562. while (pageBefore + 1 < pageAfter) {
  563. // Try to get there more quickly if last page out of range
  564. // - there could be millions of reviews but only a few in the set
  565. var midPage = pageOutOfRange && pageBefore + 3 < pageAfter
  566. ? Math.floor((pageAfter + pageBefore * 3) / 4)
  567. : Math.floor((pageAfter + pageBefore) / 2);
  568. var midPageData = this.getFilteredSrcPageData(rq, midPage);
  569. if (!midPageData) {
  570. return true; // Data has been requested, come back later
  571. }
  572. pageOutOfRange = !midPageData.fullData.numReviews;
  573. if (pageOutOfRange) {
  574. pageAfter = midPage;
  575. } else {
  576. // If there are ratings equal or below the desired rating,
  577. // the mid page is after the sought page
  578. for (var i = 1; i <= ratingSought; ++i) {
  579. if (midPageData.fullData.hasRating[i]) {
  580. pageAfter = midPage;
  581. break;
  582. }
  583. }
  584. if (pageAfter !== midPage) {
  585. pageBefore = midPage;
  586. }
  587. }
  588. }
  589. if (debug) console.log('spn: %d -> %d', srcPageNum, pageAfter);
  590. if (debug) console.assert(pageAfter > srcPageNum, 'Error!');
  591. srcPageNum = pageAfter;
  592. } else {
  593. ++srcPageNum;
  594. }
  595. }
  596. // Send the response to the content code...
  597. // Need to indicate if this is the last page.
  598. var isLastPage = destPageNum <= rq.pageNum ||
  599. destPageNum <= rq.pageNum + 1 && !destPageReviewCount;
  600. // Use some data obtained from Google as a template, so the response is
  601. // as consistent as possible with what is expected.
  602. var templateSrcPageData = (
  603. isLastPage && srcPageData.responseText || !prevSrcPageData
  604. ? srcPageData : prevSrcPageData
  605. ).fullData;
  606. var jsonData = [[
  607. templateSrcPageData.responseText, ///< "ecr"
  608. // Ensure 2 is returned for the last page
  609. isLastPage && templateSrcPageData.responseCode != 2
  610. ? 2 : templateSrcPageData.responseCode,
  611. // Ensure something is returned if there are no reviews, otherwise
  612. // the UI doesn't get updated
  613. $('<body/>').append(
  614. nodesToInclude.length || rq.pageNum
  615. ? $(nodesToInclude).clone()
  616. : $('<p/>').text(
  617. rq.postParams.reviewSortOrder == 1
  618. ? 'No matching reviews could be obtained'
  619. : 'No reviews found'
  620. )
  621. ).html(),
  622. rq.pageNum
  623. ]];
  624. if (debug) console.log('done: %s ilp=%o', debugInfo(), isLastPage);
  625. if (debug) console.log(
  626. 'template=%o, nodes=%o, response=%o, this=%o',
  627. templateSrcPageData, nodesToInclude, jsonData, this
  628. );
  629. // Inlcude the preceding junk ")]}'\n\n" in the response
  630. rq.xhr.responseText =
  631. templateSrcPageData.junkBefore + JSON.stringify(jsonData);
  632. // Use `setTimeout` in case of immediate result in `send()`
  633. setTimeout(this.done.bind(this, rq.xhr), 0);
  634. // Request has been processed, remove it from 'pending' list
  635. return false;
  636. }
  637. catch (e) {
  638. if (console && console.error) console.error('%o\n%o', e, e.stack);
  639. return false;
  640. }
  641. },
  642.  
  643. /**
  644. * Obtain and cache data for a page of reviews with a particular set of
  645. * parameters, filtered according to the requested ratings. If data is not
  646. * yet available, it is requested via AJAX.
  647. *
  648. * @param rq An object containing parameters and details of the request
  649. * @param pageNum The page index of the source data to return
  650. *
  651. * @return If the data is not yet available, the return value is undefined.
  652. * Otherwise, an object with the following properties is returned:
  653. * - `nodes`: An array of `HTMLElement` objects which are either reviews
  654. * which pass the filters or developer replies thereof;
  655. * - `numReviews`: The number of reviews in the filtered page data;
  656. * - `fullData`: The unfiltered data, an object with these properties:
  657. * - `$domFragment`: A jQuery object which contains one HTML element whose
  658. * children are the reviews and developer replies;
  659. * - `numReviews`: The number of reviews on this page;
  660. * - `hasRating`: An array indexed by rating whose values are `true` if
  661. * there are any reviews on the page with this rating; this information
  662. * is used when binary-searching for the next page containing reviews
  663. * that will pass filtering when reviews are sorted by rating;
  664. * - `responseCode`: 1 = ok, 2 = last page, 3 = error, undefined if HTTP
  665. * error or response not as expected;
  666. * - `responseText`: Always seems to be "ecr";
  667. * - `junkBefore`: All responses seem to be preceded with junk ")]}'\n\n".
  668. */
  669. getFilteredSrcPageData: function(rq, pageNum) {
  670. var data = this.data[rq.fullDataKey][pageNum];
  671. if (data) {
  672. var filteredData = this.filteredData[rq.filteredDataKey][pageNum];
  673. if (!filteredData) {
  674. filteredData = {
  675. fullData: data,
  676. nodes: [],
  677. numReviews: 0
  678. };
  679. data.hasRating = data.hasRating || [];
  680. data.$domFragment.find('div.current-rating').each(function() {
  681. var $this = $(this);
  682. var rating = Math.round(parseInt($this.css('width')) / 20);
  683. data.hasRating[rating] = true;
  684. if (rq.filters[rating]) {
  685. // Include this review, and any developer reply
  686. if (debug > 1) console.log('%d* review included', rating);
  687. var $review = $this.closest('div.single-review');
  688. Array.prototype.push.apply(
  689. filteredData.nodes,
  690. $review.add($review.next('div.developer-reply')).get()
  691. );
  692. ++filteredData.numReviews;
  693. }
  694. });
  695. if (debug) console.log(
  696. 'filtered data for page %d: %o',
  697. pageNum, filteredData
  698. );
  699. this.filteredData[rq.filteredDataKey][pageNum] = filteredData;
  700. }
  701. return filteredData;
  702. } else {
  703. if (debug > 1) console.log('Doing POST for source page %d', pageNum);
  704. $.post({
  705. url: this.requestURL,
  706. data: param($.extend({}, rq.postParams, {
  707. pageNum: pageNum,
  708. gprrfInternal: true ///< Flag not to hook this request
  709. })),
  710. dataType: 'text'
  711. }).always(this.receiveData.bind(this, rq.fullDataKey, pageNum));
  712. }
  713. },
  714.  
  715. /**
  716. * Store data received via AJAX, then reprocess pending requests
  717. *
  718. * @param dataKey Serialized representation of the request parameters
  719. * to use as the property key of data to set
  720. * @param pageNum The source page number of the data
  721. * @param data String containing the data received on success; on failure
  722. * this will be a `jqXHR` object
  723. */
  724. receiveData: function(dataKey, pageNum, data) {
  725. var dataObj = {};
  726. if (typeof data === 'string') {
  727. var skipJunk = data.indexOf('[');
  728. try {
  729. var jsonData = JSON.parse(skipJunk ? data.substr(skipJunk) : data);
  730. if (
  731. jsonData.length > 0 &&
  732. jsonData[0].length > 3 &&
  733. jsonData[0][3] == pageNum
  734. ) {
  735. dataObj.$domFragment = $('<body/>').html(jsonData[0][2]);
  736. dataObj.responseText = jsonData[0][0]; // "ecr"
  737. dataObj.responseCode = jsonData[0][1]; // 1 ok, 2 last page, 3 error
  738. }
  739. } catch (e) {
  740. if (debug) console.error(e);
  741. }
  742. dataObj.junkBefore = data.substr(0, skipJunk); // ")]}'\n\n"
  743. }
  744. dataObj.$domFragment = dataObj.$domFragment || $([]);
  745. dataObj.numReviews =
  746. dataObj.$domFragment.find('div.single-review').length;
  747. if (debug) console.log(
  748. 'received page %d, key %s: %o',
  749. pageNum, dataKey, dataObj
  750. );
  751. this.data[dataKey][pageNum] = dataObj;
  752. this.processPendingRequests();
  753. }
  754. });
  755.  
  756. /**
  757. * Reinitialize when a new App page is loaded via AJAX
  758. */
  759. var loadPageHook = new GooglePlayXMLHttpRequestHook('/store/apps/details', {
  760. /**
  761. * Hook if the request contains a URL parameter `psid` with value 2, which
  762. * indicates a request for new App review content.
  763. */
  764. hook: function(xhr, method, url) {
  765. if (GooglePlayXMLHttpRequestHook.prototype.hook.apply(this, arguments)) {
  766. var urlParts = url.split('?', 2);
  767. if (debug) console.log(urlParts);
  768. return urlParts.length > 1 && deparam(urlParts[1]).psid == 2;
  769. } else {
  770. return false;
  771. }
  772. },
  773. done: function(xhr) {
  774. if (debug) console.log(xhr.responseURL);
  775. if (debug > 1) console.log(xhr.responseText);
  776. GooglePlayXMLHttpRequestHook.prototype.done.apply(this, arguments);
  777. initialize();
  778. }
  779. });
  780.  
  781. // Initialize UI and review count for the page when first loaded
  782. initialize();
  783. });
  784.  
  785. } catch (e) {
  786. if (console && console.error) console.error(
  787. 'Error at line %d:\n%o\n%o', e.lineNumber, e, e.stack
  788. );
  789. }