/*===========================================================================*\
| The Amazon Review Tabulator - TART |
| (c) 2016 by Another Floyd |
| From your "Public Reviews Written by You" page on Amazon, this script |
| collects and tabulates vote tallies and related information, from all of |
| your Amazon reviews. Click the "Tabulate" link in the "Your Profile" |
| panel. See item on script host menu, on browser toolbar, to toggle |
| Display Mode, between using the Amazon tab (default), or showing in a |
| new tab, but with fewer features. |
\*===========================================================================*/
// ==UserScript==
// @name The Amazon Review Tabulator - TART
// @namespace floyd.scripts
// @version 1.3.3
// @author Another Floyd at Amazon.com
// @description Lists all of your reviews with vote and comment tallies, with updates highlighted
// @include https://*amazon.com/gp/cdp/member-reviews*
// @include https://*amazon.co.uk/gp/cdp/member-reviews*
// @include https://*amazon.ca/gp/cdp/member-reviews*
// @include https://*amazon.com.au/gp/cdp/member-reviews*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @grant GM_log
// @grant GM_openInTab
// @grant GM_registerMenuCommand
// @require https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js
// @require https://gf.qytechs.cn/scripts/20744-sortable/code/sortable.js?version=132520
// @require https://openuserjs.org/src/libs/sizzle/GM_config.js
// ==/UserScript==
// Start
(function() {
var showUpdatesOnly = false;
var primaryDisplayBuffer = "";
var updateDisplayBuffer = "";
var userID = "";
var reviewCount = 0;
var reviewerRanking = "";
var helpfulVotes = 0;
var oldStoreItemIDs = [];
var oldStoreUpvotes = [];
var oldStoreDownvotes = [];
var oldStoreComments = [];
var newStoreItemIDs = "";
var newStoreUpvotes = "";
var newStoreDownvotes = "";
var newStoreComments = "";
var tallyUpvotes = 0;
var tallyDownvotes = 0;
var tallyStars = 0;
var tallyComments = 0;
// use this reference for progress indicator
var profileDiv = "";
var profileDivOriginalHTML = "";
var profileDivTabulateHTML = "<br></br><a href='javascript:tabulate();'>Tabulate</a> <a href='javascript:options();' title='Click for options' style='text-decoration:none;font-size:135%;font-weight:bold'>" + String.fromCharCode(9829) + "</a>";
function assembleDisplayBuffers (completeSetOfTableRows, reviewsProcessed) {
var today = new Date();
var formattedToday = today.toLocaleDateString('en-US',{month:'long',day:'numeric',year:'numeric'});
var toggleLink = (GM_config.get('DisplayMode')) ? "<p><a href='javascript:toggleView();'>Toggle View: All Reviews | Updates Only</a>" : "";
var bMargin = (GM_config.get('FixedFooter')) ? "36" : "0";
// set up top of display page
primaryDisplayBuffer = "<!DOCTYPE html><html lang='en'>" +
"<head><meta charset='utf-8'/><title>TART Amazon Review Details</title>" +
"<style type='text/css'>" +
"body {font-family:Arial,sans-serif;font-size:" + GM_config.get('FontSize') + "px; margin:0; padding:0px 5px}" +
".tg {border-collapse:collapse;border-spacing:0;width:100%}" +
".tg td{padding:" + GM_config.get('RowPadding') + "px 4px; border-style:solid; border-width:1px; overflow:hidden; word-break:normal; font-size:" + GM_config.get('FontSize') + "px}" +
".tg th{padding:" + GM_config.get('RowPadding') + "px 4px; border-style:solid; border-width:1px; overflow:hidden; word-break:normal; font-size:" + GM_config.get('FontSize') + "px}" +
".tg .header-left{font-weight:bold;background-color:#010066;color:#ffffff;text-align:left}" +
".tg .header-right{font-weight:bold;background-color:#010066;color:#ffffff;text-align:right}" +
".tg .cell-left{text-align:left}" +
".tg .cell-right{text-align:right}" +
".tg .hilite-left{text-align:left;background-color:#FFFF12}" +
".tg .hilite-right{text-align:right;background-color:#FFFF12}" +
".txtLarge {font-size:18px;font-weight:bold}" +
".tableLink, .tableLink:link, .tableLink:visited {text-decoration:none; font-weight:bold; color:#000000}" +
".footerLink, .footerLink:link, .footerLink:visited {text-decoration:none; font-weight:bold; color:#ffffff}" +
"table.sortable th.sorted {background-color:#000000}" +
"#footer {position: fixed;bottom: 0}" +
"</style></head><body>" +
"<span class='txtLarge'>Amazon Review Details</span><br>" +
"Prepared with <a href='https://gf.qytechs.cn/en/scripts/24434-the-amazon-review-tabulator-tart' target='_new'>TART</a> - " + formattedToday +
"<p>Reviewer Ranking: " + reviewerRanking + "<br>" +
"Review Count: " + reviewCount + "<br>" +
"Helpful Votes: " + helpfulVotes + "<br>" +
"Upvote/Review Ratio: " + (helpfulVotes/reviewCount).toFixed(2) + toggleLink +
"</p><table class='tg sortable' style='margin-bottom:" + bMargin + "px'>" +
"<thead><tr>" +
"<th class='header-left sort-number sort-default' style='width:5%'>#</th>" +
"<th class='header-left sort-text' style='width:42%'>Item</th>" +
"<th class='header-left sort-date' style='width:12%'>Date</th>" +
"<th class='header-right sort-number' style='width:6%'>Stars</th>" +
"<th class='header-right sort-number' style='width:8%'>Upvotes</th>" +
"<th class='header-right sort-number' style='width:10%'>Downvotes</th>" +
"<th class='header-right sort-number' style='width:8%'>% Helpful</th>" +
"<th class='header-right sort-number' style='width:9%'>Comments</th></tr></thead><tbody>";
// had to go with fixed % widths on the columns, in order to have second table
// with fixed footers... that would have matching column widths
updateDisplayBuffer = primaryDisplayBuffer; // both displays have same top section
primaryDisplayBuffer += completeSetOfTableRows;
// info needed in footer
var calcStars = (tallyStars/reviewsProcessed).toFixed(1);
var calcHelpfulPct = helpfulPercent(tallyUpvotes,tallyDownvotes);
var newStoreFooter = calcStars + " " + tallyUpvotes + " " + tallyDownvotes + " " + calcHelpfulPct + " " + tallyComments;
var oldStoreFooter = GM_getValue("recentFooterValues", " ").split(" "); // read 'em
GM_setValue("recentFooterValues", newStoreFooter.trim()); // write 'em with new values
// prep footer; cell widths are superfluous if footer will not be fixed
var tableFooter = "<tfoot><tr><td class='header-left' style='width:5%'> </td>" +
"<td class='header-left' style='width:42%'> </td>" +
"<td class='header-left' style='width:12%'> </td>" +
"<td class='header-right' style='width:6%'><a href='javascript: void(0)' class='footerLink' title='Previous: " + oldStoreFooter[0] + "'>" + calcStars + "</a></td>" +
"<td class='header-right' style='width:8%'><a href='javascript: void(0)' class='footerLink' title='Previous: " + oldStoreFooter[1] + "'>" + tallyUpvotes + "</a></td>" +
"<td class='header-right' style='width:10%'><a href='javascript: void(0)' class='footerLink' title='Previous: " + oldStoreFooter[2] + "'>" + tallyDownvotes + "</a></td>" +
"<td class='header-right' style='width:8%'><a href='javascript: void(0)' class='footerLink' title='Previous: " + oldStoreFooter[3] + "'>" + calcHelpfulPct + "</a></td>" +
"<td class='header-right' style='width:9%'><a href='javascript: void(0)' class='footerLink' title='Previous: " + oldStoreFooter[4] + "'>" + tallyComments + "</a></td>" + "</tr></tfoot>";
// add footer either to be fixed at bottom of screen, or normal
if(GM_config.get('FixedFooter')) {
primaryDisplayBuffer += "</tbody></table><table class='tg' id='footer' style='width:calc(100% - 10px)'>" + tableFooter + "</table></body></html>"; // fixed, by virtue of the styled 'footer' id
}
else {
primaryDisplayBuffer += "</tbody>" + tableFooter + "</table></body></html>"; // normal
}
// get rows containing updated reviews, only
var tempDiv = document.createElement('div');
tempDiv.innerHTML = primaryDisplayBuffer;
var findUpdateRows = document.evaluate("//td[@class='hilite-left']/..", tempDiv, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
for(var d = 0; d < findUpdateRows.snapshotLength; d++) {
updateDisplayBuffer += findUpdateRows.snapshotItem(d).outerHTML;
}
updateDisplayBuffer += "</tbody></table></body></html>";
}
function tabulate() {
// reset global accumulators to ensure that repeated script runs remain clean
newStoreItemIDs = "";
newStoreUpvotes = "";
newStoreDownvotes = "";
newStoreComments = "";
tallyUpvotes = 0;
tallyDownvotes = 0;
tallyStars = 0;
tallyComments = 0;
// read in stored info from past run, for use in change detection
oldStoreItemIDs = GM_getValue("recentItemIDs", "").split(" ");
oldStoreUpvotes = GM_getValue("recentUpvotes", "").split(" ");
oldStoreDownvotes = GM_getValue("recentDownvotes", "").split(" ");
oldStoreComments = GM_getValue("recentComments", "").split(" ");
// prepare url with country domain and user ID, ready for review page number
var tld = "com";
var url = window.location.href;
if(url.indexOf('amazon.co.uk/') > -1) tld = "co.uk";
if(url.indexOf('amazon.ca/') > -1) tld = "ca";
if(url.indexOf('amazon.com.au/') > -1) tld = "com.au";
var urlStart = "https://www.amazon." + tld + "/gp/cdp/member-reviews/" + userID + "?ie=UTF8&display=public&page=";
var urlEnd = "&sort_by=MostRecentReview";
// space and counters for incoming data
var perPageResponseDiv = [];
var pageSetOfTableRows = [];
var pageResponseCount = 0;
var reviewsProcessed = 0;
var pageCount = Math.floor(reviewCount / 10) + ((reviewCount % 10 > 0) ? 1 : 0);
//var pageCount = 3; // for testing
// initialize the progress indicator
// sort of pre-redundant to do this here AND in the loop, but,
// looks better, if there is a lag before the first response
var progressHTML = "<br></br><b>" + pageCount + "</b>";
profileDiv.innerHTML = profileDivOriginalHTML + progressHTML;
// download and process Amazon pages
var x = 1;
while (x <= pageCount) {
(function(x){
var urlComplete = urlStart + x + urlEnd;
perPageResponseDiv[x] = document.createElement('div');
GM_xmlhttpRequest({
method: "GET",
url: urlComplete,
onload: function(response) {
// capture incoming data
perPageResponseDiv[x].innerHTML = response.responseText;
pageResponseCount++;
// update the progress indicator
var progressHTML = "<br></br><b>" + (pageCount - pageResponseCount) + "</b>";
profileDiv.innerHTML = profileDivOriginalHTML + progressHTML;
// get parent of any reviewText DIV
var findReviews = document.evaluate("//div[@class='reviewText']/..", perPageResponseDiv[x], null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null); // evaluating the doc DIV made above
// process each review found on current page
pageSetOfTableRows[x] = ""; // initialize each member prior to concatenating
for (var j = 0; j < findReviews.snapshotLength; j++) {
var oneReview = findReviews.snapshotItem(j);
var reviewChildren = oneReview.children;
var childCount = reviewChildren.length;
var commentCount = 0;
var itemTitle = "No Title Available";
var itemLink = "";
var permaLink = "";
var starRating = 0;
var reviewDate = "";
var upVotes = 0;
var downVotes = 0;
var itemID = "";
// get number of comments, and permalink
var tempText = reviewChildren[childCount-2].textContent;
if(tempText.indexOf('Comment (') > -1 || tempText.indexOf('Comments (') > -1) {
var paren1 = tempText.indexOf('(');
var paren2 = tempText.indexOf(')');
commentCount = tempText.substring(paren1+1,paren2);
commentCount = parseInt(commentCount.replace(/,/g, '')); // remove commas
}
var lst = reviewChildren[childCount-2].getElementsByTagName('a');
permaLink = lst[2].getAttribute("href");
// the data items below do not have reliable positions, due to presence
// or not, of vine voice tags, verified purchase, votes, etc.
// so, are done in a loop with IF checks. Must start loop just above review
// text, in case the reviewer has used any of the phrases I am searching for
for (var i = childCount - 4; i > -1; i--) {
var childHTML = reviewChildren[i].innerHTML; // used 2x, below
// get item title and item link
var titleClue = childHTML.indexOf('This review is from');
if(titleClue > -1) {
var lst = reviewChildren[i].getElementsByTagName('a');
itemLink = lst[0].getAttribute("href");
itemTitle = lst[0].textContent;
}
// get star rating AND review date
var ratingClue = childHTML.indexOf('out of 5 stars');
if(ratingClue > -1) {
starRating = childHTML.substring(ratingClue-4,ratingClue-1);
reviewDate = reviewChildren[i].lastElementChild.textContent;
var lst = reviewDate.split(" ");
reviewDate = lst[0].substring(0,3) + " " + lst[1] + " " + lst[2];
}
// get vote counts
var childText = reviewChildren[i].textContent;
var voteClue = childText.indexOf('people found the following review helpful');
if(voteClue > -1) {
var list = childText.trim().split(" "); // there were extra, invisible spaces!
upVotes = parseInt(list[0].replace(/,/g, '')); // remove commas
var totalVotes = parseInt(list[2].replace(/,/g, ''));
downVotes = totalVotes - upVotes;
}
}
// get item ID
var lst = oneReview.parentNode.getElementsByTagName('a');
itemID = lst[0].getAttribute("name");
// get HTML formatted table row
pageSetOfTableRows[x] += prepOneTableRow((j+1+(x-1)*10),itemID,itemTitle,permaLink,reviewDate,starRating,upVotes,downVotes,commentCount);
// clear the response, to save memory --
// could be critical when there are many review pages
perPageResponseDiv[x].innerHTML = "";
reviewsProcessed++; // more reliable than reviewCount, for calculating avg. rating
}
// see if all data from multiple page loads has arrived
if(pageResponseCount==pageCount) {
// assemble the sets of table rows
var completeSetOfTableRows = "";
for(var y=1; y <= pageCount; y++) {
completeSetOfTableRows += pageSetOfTableRows[y];
}
assembleDisplayBuffers(completeSetOfTableRows, reviewsProcessed);
// store info to be used in subsequent run, for change detection
GM_setValue("recentItemIDs", newStoreItemIDs.trim());
GM_setValue("recentUpvotes", newStoreUpvotes.trim());
GM_setValue("recentDownvotes", newStoreDownvotes.trim());
GM_setValue("recentComments", newStoreComments.trim());
// replace progress indicator with Tabulate link
profileDiv.innerHTML = profileDivOriginalHTML + profileDivTabulateHTML;
// --- a display alternative - put results IN the Amazon page
//document.body.innerHTML = primaryDisplayBuffer;
// --- to open new window, user must allow popups for https://www.amazon.com
//var resultsWindow = window.open("data:text/html," + encodeURIComponent(primaryDisplayBuffer), "_blank", "scrollbars=yes");
// --- display using GM_openInTab; does not require exception to be set by user
if(!GM_config.get('DisplayMode')) GM_openInTab("data:text/html," + encodeURIComponent(primaryDisplayBuffer));
else document.body.innerHTML = primaryDisplayBuffer;
}
}
});
})(x);
x++;
}
}
function toggleView() {
showUpdatesOnly = !showUpdatesOnly;
if(showUpdatesOnly) document.body.innerHTML = updateDisplayBuffer;
else document.body.innerHTML = primaryDisplayBuffer;
}
function helpfulPercent(upVotes,downVotes) {
var helpfulPercent = "";
upVotes = upVotes;
downVotes = downVotes;
if(upVotes + downVotes > 0) helpfulPercent = (upVotes/(upVotes+downVotes)*100).toFixed(1);
return helpfulPercent;
}
function prepOneTableRow (row,itemID,itemTitle,permaLink,reviewDate,starRating,upVotes,downVotes,commentCount) {
// do these before mangling the values with <b> tags </b>
var helpfulPct = helpfulPercent(upVotes,downVotes);
itemTitle = "<a href='" + permaLink + "' target='_new'>" + itemTitle.substring(0,55) + "</a>";
// keep tallies to use in table footer
tallyUpvotes += upVotes;
tallyDownvotes += downVotes;
tallyStars += parseInt(starRating);
tallyComments += commentCount;
// assemble storage info, to use in subsequent run, for change detection
newStoreItemIDs += itemID + " ";
newStoreUpvotes += upVotes + " ";
newStoreDownvotes += downVotes + " ";
newStoreComments += commentCount + " ";
// see if review for this item has previously been examined
var matchIdx = -1;
for(var i=0; i<oldStoreItemIDs.length; i++) {
if(oldStoreItemIDs[i] == itemID) {
// we have a match, an item that has previously been seen
matchIdx = i;
break;
}
}
var hiliteRow = false;
if(matchIdx > -1) {
// entry exists; see if any of the numbers have changed
if(oldStoreUpvotes[matchIdx] != upVotes) {
// for changed number, make it bold, and hilite row
// and store previous value for display as tooltip, for mouse hover
upVotes = "<a href='javascript: void(0)' class='tableLink' title='Previous: " + oldStoreUpvotes[matchIdx] + "'>" + upVotes + "</a>";
hiliteRow = true;
}
if(oldStoreDownvotes[matchIdx] != downVotes) {
downVotes = "<a href='javascript: void(0)' class='tableLink' title='Previous: " + oldStoreDownvotes[matchIdx] + "'>" + downVotes + "</a>";
hiliteRow = true;
}
if(oldStoreComments[matchIdx] != commentCount) {
commentCount = "<a href='javascript: void(0)' class='tableLink' title='Previous: " + oldStoreComments[matchIdx] + "'>" + commentCount + "</a>";
hiliteRow = true;
}
}
else {
// no match, so, it's a new review; bold the title and hilite the row
itemTitle = "<b>" + itemTitle + "</b>";
hiliteRow = true;
}
var tdLeft = "<td class='cell-left'>";
var tdRight = "<td class='cell-right'>";
if(hiliteRow===true && oldStoreItemIDs[0].length > 0) {
tdLeft = "<td class='hilite-left'>";
tdRight = "<td class='hilite-right'>";
}
var tableRow = "<tr>" + tdLeft + row + "</td>" + tdLeft + itemTitle + "</td>" + tdLeft + reviewDate + "</td>" + tdRight + starRating + "</td>" + tdRight + upVotes + "</td>" + tdRight + downVotes + "</td>" + tdRight + helpfulPct + "</td>" + tdRight + commentCount + "</td></tr>";
return tableRow;
}
// create Options menu with GM_config
var frame = document.createElement('div');
document.body.appendChild(frame);
GM_config.init(
{
'id': 'MyConfig', // The id used for this instance of GM_config
'title': 'TART Options', // Panel Title
'fields': // Fields object
{
'DisplayMode': // Line item
{
'type': 'checkbox',
'label': 'Enhanced display (uncheck for new tab with fewer features)',
'default': true
},
'FixedFooter':
{
'type': 'checkbox',
'label': 'Show fixed footer at bottom of screen',
'default': true
},
'FontSize':
{
'label': 'Font size',
'type': 'unsigned int',
'size': 2,
'default': 12
},
'RowPadding':
{
'label': 'Row padding',
'type': 'unsigned int',
'size': 2,
'default': 10
}
},
'events': // Callback functions object
{
'open': function() {
// style the panel as it's being displayed
frame.style.position = "auto";
frame.style.width = "auto";
frame.style.height = "auto";
frame.style.backgroundColor = "#F3F3F3";
frame.style.padding = "10px";
frame.style.borderWidth = "5px";
frame.style.borderStyle = "ridge";
frame.style.borderColor = "gray";
var x = (document.documentElement.clientWidth - frame.offsetWidth) / 2;
frame.style.left = x + 'px';
}
},
'frame': frame, // specify the DIV element used for the panel
'css': '#MyConfig .config_header { font-size: 12pt; font-weight:bold; margin-bottom:12px }' +
'#MyConfig .field_label { font-size: 12px; font-weight:normal; margin: 0 3px }'
});
// event listener to pick up mouse clicks, to run script functions
document.addEventListener('click', function(event) {
var tempstr = new String(event.target);
var quash = false;
if(tempstr.indexOf('tabulate') > -1) {
tabulate();
quash = true;
}
if(tempstr.indexOf('options') > -1) {
GM_config.open();
quash = true;
}
if(tempstr.indexOf('toggleView') > -1) {
toggleView();
quash = true;
}
if(quash) {
event.stopPropagation();
event.preventDefault();
}
}, true);
function main() {
// find reviewer ID
var findProfileLink = "";
var url = window.location.href;
try {
if(url.indexOf('amazon.com/') > -1) {
// for Amazon US
findProfileLink = document.evaluate("//b[contains(.,'Your Profile')]/a", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
}
else {
// Amazon UK, CA, AU
findProfileLink = document.evaluate("//a[contains(.,'Customer Reviews')]", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
}
// and, the following lines are ok for US and UK, but, maybe not for others
var profileLink = findProfileLink.snapshotItem(0).getAttribute("href");
var lst = profileLink.split("/");
userID = lst[4];
}
catch(err) {
alert("TART: There was a problem getting user ID");
return;
}
// find profile info panel
try {
var findDiv = document.evaluate("//div[contains(.,'Helpful Votes')]", document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
profileDiv = findDiv.snapshotItem(0);
// get reviewer ranking and helpful votes
var lst = profileDiv.textContent.split(" ");
reviewerRanking = lst[4].substring(9);
helpfulVotes = lst[9].substring(7);
}
catch(err) {
alert("TART: There was a problem finding profile panel info");
return;
}
// get review count
try {
var prevSibDiv = profileDiv.previousElementSibling;
charIdx = prevSibDiv.textContent.lastIndexOf(':');
reviewCount = prevSibDiv.textContent.substring(charIdx+2);
// remove any commas, though has not been necessary w/thousands of reviews
reviewCount = parseInt(reviewCount.replace(/,/g, ''));
}
catch(err) {
alert("TART: There was a problem getting the review count");
return;
}
// add Tabulate link; also, save content for use with progress indicator
profileDivOriginalHTML = profileDiv.innerHTML;
profileDiv.innerHTML += profileDivTabulateHTML;
}
main();
})();
// End