// ==UserScript==
// @name Amazon Transaction History Exporter
// @namespace http://tampermonkey.net/
// @version 0.3
// @description Export Amazon transaction history to CSV
// @author kylemd
// @match https://www.amazon.com.au/cpe/yourpayments/transactions*
// @grant GM_xmlhttpRequest
// @grant GM_download
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// Configuration
const config = {
buttonText: 'Export Transactions to CSV',
csvFilename: 'amazon_transactions.csv',
csvHeader: 'Date,Description,Amount,Order ID,Is Refund\n',
maxPages: 50, // Safety limit to prevent infinite loops
statusElementId: 'amazon-export-status',
containerElementId: 'amazon-export-container',
buttonCheckInterval: 2000, // Check for button existence every 2 seconds
};
// Main data storage
let allTransactions = [];
let currentPage = 1;
let isExporting = false;
let widgetState = '';
let nextPageKey = '';
let statusElement = null;
let buttonContainer = null;
let buttonCheckTimer = null;
// Helper functions
function createExportButton() {
// Check if container already exists
if (document.getElementById(config.containerElementId)) {
return document.getElementById(config.containerElementId);
}
// Create container for button and status
const container = document.createElement('div');
container.id = config.containerElementId;
container.style.cssText = 'margin: 20px 0; padding: 10px; background-color: #f8f8f8; border: 1px solid #ddd; border-radius: 4px; position: sticky; top: 0; z-index: 1000;';
// Create export button
const button = document.createElement('button');
button.textContent = config.buttonText;
button.style.cssText = 'background-color: #f0c14b; border: 1px solid #a88734; border-radius: 3px; padding: 8px 16px; margin-right: 10px; cursor: pointer;';
button.addEventListener('click', startExport);
// Create status element
statusElement = document.createElement('span');
statusElement.id = config.statusElementId;
statusElement.style.cssText = 'display: inline-block; margin-left: 10px; color: #555;';
// Add elements to container
container.appendChild(button);
container.appendChild(statusElement);
// Find a good place to insert the container - try multiple possible locations
let inserted = false;
// Try to insert before the transactions box
const targetElement = document.querySelector('.a-box-group');
if (targetElement && targetElement.parentNode) {
targetElement.parentNode.insertBefore(container, targetElement);
inserted = true;
}
// If that failed, try the main content area
if (!inserted) {
const mainContent = document.getElementById('a-page') || document.querySelector('main');
if (mainContent) {
mainContent.insertBefore(container, mainContent.firstChild);
inserted = true;
}
}
// Last resort - add to body
if (!inserted) {
document.body.insertBefore(container, document.body.firstChild);
}
buttonContainer = container;
return container;
}
function ensureButtonExists() {
if (!document.getElementById(config.containerElementId)) {
createExportButton();
}
}
function startButtonCheckTimer() {
stopButtonCheckTimer(); // Clear any existing timer
buttonCheckTimer = setInterval(ensureButtonExists, config.buttonCheckInterval);
}
function stopButtonCheckTimer() {
if (buttonCheckTimer) {
clearInterval(buttonCheckTimer);
buttonCheckTimer = null;
}
}
function updateStatus(message) {
// Ensure the status element exists
ensureButtonExists();
const statusEl = document.getElementById(config.statusElementId);
if (statusEl) {
statusEl.textContent = message;
}
}
function getWidgetState() {
const widgetStateInput = document.querySelector('input[name="ppw-widgetState"]');
return widgetStateInput ? widgetStateInput.value : '';
}
function extractTransactionsFromPage() {
try {
const transactions = [];
// Get all transaction date containers - these define the transaction date groups
const dateContainers = document.querySelectorAll('.apx-transaction-date-container');
dateContainers.forEach(dateContainer => {
const dateText = dateContainer.textContent.trim();
// Get all transactions for this date by looking at the next sibling element
let currentElement = dateContainer.nextElementSibling;
while (currentElement && !currentElement.classList.contains('apx-transaction-date-container')) {
// Process all transaction line items in this group
const lineItemContainers = currentElement.querySelectorAll('.apx-transactions-line-item-component-container');
lineItemContainers.forEach(container => {
// Extract the transaction details
const descriptionElement = container.querySelector('.a-column.a-span9 span');
const amountElement = container.querySelector('.a-column.a-span3 span');
const orderLinkElement = container.querySelector('a.a-link-normal');
if (descriptionElement && amountElement) {
const description = descriptionElement.textContent.trim();
const amount = amountElement.textContent.trim();
// Handle both regular orders and refunds
let orderID = '';
let isRefund = false;
if (orderLinkElement) {
const linkText = orderLinkElement.textContent.trim();
if (linkText.startsWith('Refund: Order #')) {
orderID = linkText.replace('Refund: Order #', '');
isRefund = true;
} else {
orderID = linkText.replace('Order #', '');
}
}
// Check if this is a refund by looking at amount color
if (!isRefund && amountElement.classList.contains('a-color-success')) {
isRefund = true;
}
transactions.push({
date: dateText,
description: description,
amount: amount,
orderID: orderID,
isRefund: isRefund
});
}
});
// Move to the next element at the same level
currentElement = currentElement.nextElementSibling;
}
});
return transactions;
} catch (error) {
console.error('Error extracting transactions:', error);
return [];
}
}
function findNextPageKey() {
// Try to find the next page button input element
const nextPageInput = document.querySelector('input[name*="DefaultNextPageNavigationEvent"]');
if (nextPageInput) {
const nameAttr = nextPageInput.getAttribute('name');
const match = nameAttr.match(/nextPageKey":"([^"]+)"/);
return match ? match[1] : '';
}
return '';
}
function convertToCSV(transactions) {
let csv = config.csvHeader;
transactions.forEach(transaction => {
// Escape fields that might contain commas
const description = `"${transaction.description.replace(/"/g, '""')}"`;
const orderID = `"${transaction.orderID.replace(/"/g, '""')}"`;
const isRefund = transaction.isRefund ? "Yes" : "No";
csv += `${transaction.date},${description},${transaction.amount},${orderID},${isRefund}\n`;
});
return csv;
}
function downloadCSV(csv) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
GM_download({
url: url,
name: config.csvFilename,
saveAs: true,
onload: () => URL.revokeObjectURL(url)
});
}
function constructRequestBody(nextPageKey, widgetState) {
// This is a more accurate representation of what Amazon expects in the request
return `ppw-widgetEvent%3ADefaultNextPageNavigationEvent%3A%7B%22nextPageKey%22%3A%22${encodeURIComponent(nextPageKey)}%22%7D=&ppw-jsEnabled=true&ppw-widgetState=${encodeURIComponent(widgetState)}&ie=UTF-8`;
}
function getCustomerIdAndWidgetId() {
// Initialize with empty values - we'll try to extract them from the page
let customerId = '';
let widgetInstanceId = '';
// Try to extract from URL or page elements
try {
// Look for the customer ID in the continueWidget URL in any script on the page
const scripts = document.querySelectorAll('script');
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i].textContent || '';
// Check for customer ID pattern in any widget URLs
const customerMatch = script.match(/customer\/([A-Z0-9]+)\/continueWidget/);
if (customerMatch && customerMatch[1]) {
customerId = customerMatch[1];
break;
}
}
// If we couldn't find it in scripts, look in the current URL
if (!customerId) {
const urlMatch = window.location.href.match(/customer\/([A-Z0-9]+)/);
if (urlMatch && urlMatch[1]) {
customerId = urlMatch[1];
}
}
// Last resort - try to find it in any element's data attributes
if (!customerId) {
const elements = document.querySelectorAll('[data-customer-id]');
if (elements.length > 0 && elements[0].getAttribute('data-customer-id')) {
customerId = elements[0].getAttribute('data-customer-id');
}
}
// Look for widget instance ID
const widgetInfoElements = document.querySelectorAll('[data-pmts-component-id]');
if (widgetInfoElements.length > 0) {
const componentId = widgetInfoElements[0].getAttribute('data-pmts-component-id');
if (componentId) {
// The widget ID might be encoded somewhere in the page
const widgetIdMatch = document.body.innerHTML.match(/widgetInstanceId":"([^"]+)"/);
if (widgetIdMatch && widgetIdMatch[1]) {
widgetInstanceId = widgetIdMatch[1];
}
}
}
// If we still don't have the widget ID, try to find it in other patterns
if (!widgetInstanceId) {
const widgetMatch = document.body.innerHTML.match(/widget-info":\s*"([^"]+)"/);
if (widgetMatch && widgetMatch[1]) {
const parts = widgetMatch[1].split('/');
if (parts.length > 2) {
widgetInstanceId = parts[2];
}
}
}
} catch (e) {
console.error('Error extracting customer ID and widget ID:', e);
}
// If we couldn't find the values, provide a warning but continue
if (!customerId || !widgetInstanceId) {
console.warn('Could not automatically detect customer ID or widget instance ID. ' +
'You may need to manually extract these from the network request.');
}
return { customerId, widgetInstanceId };
}
function requestNextPage() {
if (!nextPageKey || currentPage >= config.maxPages) {
finishExport();
return;
}
updateStatus(`Fetching page ${currentPage + 1}...`);
// Get the customer ID and widget instance ID
const { customerId, widgetInstanceId } = getCustomerIdAndWidgetId();
// Log the values for debugging
console.log('Using Customer ID:', customerId);
console.log('Using Widget Instance ID:', widgetInstanceId);
const requestBody = constructRequestBody(nextPageKey, widgetState);
GM_xmlhttpRequest({
method: 'POST',
url: `https://www.amazon.com.au/payments-portal/data/widgets2/v1/customer/${customerId}/continueWidget`,
data: requestBody,
headers: {
'accept': 'application/json, text/javascript, */*; q=0.01',
'accept-language': 'en-AU,en;q=0.9',
'content-type': 'application/x-www-form-urlencoded; charset=UTF-8',
'apx-widget-info': `YA:MPO/desktop/${widgetInstanceId}`,
'x-requested-with': 'XMLHttpRequest'
},
onload: processNextPage,
onerror: handleError
});
}
function processNextPage(response) {
if (response.status === 200) {
try {
const data = JSON.parse(response.responseText);
// Create a temporary div to parse the HTML content
const tempDiv = document.createElement('div');
tempDiv.innerHTML = data.htmlContent;
// Extract transactions from the processed HTML
const transactions = extractTransactionsFromHTML(tempDiv);
if (transactions.length > 0) {
allTransactions = allTransactions.concat(transactions);
updateStatus(`Found ${allTransactions.length} transactions so far...`);
}
// Update the widget state for the next request
const newWidgetState = extractWidgetStateFromHTML(tempDiv);
if (newWidgetState) {
widgetState = newWidgetState;
}
// Find the next page key for the next request
const newNextPageKey = extractNextPageKeyFromHTML(tempDiv);
nextPageKey = newNextPageKey;
// Increment page counter and continue if we have a next page
currentPage++;
// Ensuring the button is still there after potential page updates
ensureButtonExists();
if (nextPageKey && currentPage < config.maxPages) {
// Slight delay to avoid hammering Amazon servers
setTimeout(requestNextPage, 500);
} else {
finishExport();
}
} catch (error) {
console.error('Error processing response:', error);
handleError(error);
}
} else {
console.error('Failed to get next page. Status:', response.status);
handleError(new Error(`HTTP error ${response.status}`));
}
}
function extractTransactionsFromHTML(html) {
const transactions = [];
// Get all transaction date containers
const dateContainers = html.querySelectorAll('.apx-transaction-date-container');
dateContainers.forEach(dateContainer => {
const dateText = dateContainer.textContent.trim();
// Get all transactions under this date container
let currentElement = dateContainer.nextElementSibling;
while (currentElement && !currentElement.classList.contains('apx-transaction-date-container')) {
// Process all transaction line items
const lineItemContainers = currentElement.querySelectorAll('.apx-transactions-line-item-component-container');
lineItemContainers.forEach(container => {
const descriptionElement = container.querySelector('.a-column.a-span9 span');
const amountElement = container.querySelector('.a-column.a-span3 span');
const orderLinkElement = container.querySelector('a.a-link-normal');
if (descriptionElement && amountElement) {
const description = descriptionElement.textContent.trim();
const amount = amountElement.textContent.trim();
// Handle both regular orders and refunds
let orderID = '';
let isRefund = false;
if (orderLinkElement) {
const linkText = orderLinkElement.textContent.trim();
if (linkText.startsWith('Refund: Order #')) {
orderID = linkText.replace('Refund: Order #', '');
isRefund = true;
} else {
orderID = linkText.replace('Order #', '');
}
}
// Check if this is a refund by looking for the success color class
if (!isRefund && amountElement.classList.contains('a-color-success')) {
isRefund = true;
}
transactions.push({
date: dateText,
description: description,
amount: amount,
orderID: orderID,
isRefund: isRefund
});
}
});
currentElement = currentElement.nextElementSibling;
}
});
return transactions;
}
function extractWidgetStateFromHTML(html) {
const widgetStateInput = html.querySelector('input[name="ppw-widgetState"]');
return widgetStateInput ? widgetStateInput.value : '';
}
function extractNextPageKeyFromHTML(html) {
const nextPageInput = html.querySelector('input[name*="DefaultNextPageNavigationEvent"]');
if (nextPageInput) {
const nameAttr = nextPageInput.getAttribute('name');
const match = nameAttr.match(/nextPageKey":"([^"]+)"/);
return match ? match[1] : '';
}
return '';
}
function handleError(error) {
console.error('Error during export:', error);
updateStatus(`Error: ${error.message || 'Unknown error during export'}`);
isExporting = false;
}
function startExport() {
if (isExporting) return;
isExporting = true;
allTransactions = [];
currentPage = 1;
updateStatus('Starting export...');
// Get the current page's widget state
widgetState = getWidgetState();
if (!widgetState) {
handleError(new Error('Could not find widget state. Please try reloading the page.'));
return;
}
// Get the next page key for pagination
nextPageKey = findNextPageKey();
// Extract transactions from the current page
updateStatus('Processing current page...');
const currentPageTransactions = extractTransactionsFromPage();
if (currentPageTransactions.length > 0) {
allTransactions = allTransactions.concat(currentPageTransactions);
updateStatus(`Found ${currentPageTransactions.length} transactions on the current page.`);
// Request the next page if available
if (nextPageKey) {
requestNextPage();
} else {
updateStatus('No more pages to process.');
finishExport();
}
} else {
updateStatus('No transactions found on the current page.');
finishExport();
}
}
function finishExport() {
if (allTransactions.length > 0) {
updateStatus(`Export complete! Downloading ${allTransactions.length} transactions...`);
const csv = convertToCSV(allTransactions);
downloadCSV(csv);
} else {
updateStatus('No transactions found to export.');
}
isExporting = false;
}
// Set up a mutation observer to detect when the page content changes
function setupMutationObserver() {
const targetNode = document.body;
const config = { childList: true, subtree: true };
const callback = function(mutationsList, observer) {
for (const mutation of mutationsList) {
if (mutation.type === 'childList') {
// Check if our button still exists, recreate if needed
ensureButtonExists();
}
}
};
const observer = new MutationObserver(callback);
observer.observe(targetNode, config);
}
// Initialize: Create the export button and start monitoring for DOM changes
function initialize() {
createExportButton();
setupMutationObserver();
startButtonCheckTimer();
// Also log some debug information
console.log('Amazon Transaction History Exporter initialized');
}
// Start the script once the page is fully loaded
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
})();