Google Play Review Rating Filter

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

当前为 2018-05-15 提交的版本,查看 最新版本

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