您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Adds checkboxes to Google Play to filter app reviews based on their star rating out of five
当前为
// ==UserScript== // @name Google Play Review Rating Filter // @version 1.0.0 // @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 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)) { return; } } // Otherwise call the original `XMLHttpRequest` method return xhrOpen.apply(this, arguments); }; // Assign the replacement `XMLHttpRequest.prototype.open` - // Handle running in a Sandbox (i.e. if included via Unified Script Injector) if (XMLHttpRequest.wrappedJSObject && typeof exportFunction !== 'undefined') { XMLHttpRequest.wrappedJSObject.prototype.open = exportFunction( xhrOpenHook, XMLHttpRequest.wrappedJSObject.prototype ); } else { 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') { 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)) { /** URL to use for internal AJAX requests */ this.requestURL = 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 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... // 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 { if (debug > 1) console.log('Doing POST for source page %d', pageNum); $.post({ url: this.requestURL, data: param($.extend({}, rq.postParams, { pageNum: pageNum, gprrfInternal: true ///< Flag not to hook this request })), dataType: 'text' }).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 = {}; 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 ); }