Last active
October 10, 2025 20:48
-
-
Save ivanlonel/292e43f6dda6417f1bd242f31f21be9e to your computer and use it in GitHub Desktop.
Pokemon Zone Card Collection CSV Downloader
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Pokemon Zone Card Collection CSV Downloader | |
| // @namespace http://tampermonkey.net/ | |
| // @version 1.1 | |
| // @description Download Pokemon card collection data as CSV from Pokemon Zone | |
| // @author Ivan Donisete Lonel | |
| // @match https://www.pokemon-zone.com/players/*/cards/ | |
| // @grant none | |
| // ==/UserScript== | |
| (function () { | |
| 'use strict'; | |
| // Configuration constants | |
| const CONFIG = { | |
| WAIT_TIMEOUT: 10000, | |
| ELEMENT_CHECK_INTERVAL: 100, | |
| MAX_LOAD_MORE_CLICKS: 1000, | |
| LOAD_MORE_DELAY: 800, | |
| FINAL_LOAD_DELAY: 2000, | |
| BUTTON_RESET_DELAY: 3000, | |
| CONTENT_WAIT_TIMEOUT: 5000, | |
| BATCH_SIZE: 500 | |
| }; | |
| // Selector constants | |
| const SELECTORS = { | |
| ROOT: 'div.data-infinite-scroller', | |
| LOAD_MORE: 'div.data-infinite-scroller__more > button', | |
| CARD_CONTAINER: 'div.collection-card-grid > div.player-expansion-collection-card', | |
| CARD_LINK: 'div.player-expansion-collection-card__preview > div.player-expansion-collection-card__card > div.game-card-image > a', | |
| CARD_NAME: 'div.player-expansion-collection-card__footer > div.player-expansion-collection-card__name > div.player-expansion-collection-card__name-text', | |
| CARD_RARITY: 'div.player-expansion-collection-card__footer > div.player-expansion-collection-card__name > div.player-expansion-collection-card__name-rarity > span.rarity-icon', | |
| CARD_AMOUNT: 'div.player-expansion-collection-card__preview > div.player-expansion-collection-card__count', | |
| CARD_UNREGISTERED: 'div.player-expansion-collection-card__preview > div.player-expansion-collection-card__number' | |
| }; | |
| // Rarity icon mapping | |
| const RARITY_ICONS = { | |
| 'rarity-icon__icon--diamond': '♦', | |
| 'rarity-icon__icon--star': '★', | |
| 'rarity-icon__icon--shiny': '✷', | |
| 'rarity-icon__icon--crown': '♛' | |
| }; | |
| // Global state | |
| let isDownloading = false; | |
| let downloadCancelled = false; | |
| // Utility function to log with timestamp | |
| function log(message, ...args) { | |
| const timestamp = new Date().toISOString().split('T')[1].slice(0, -1); | |
| console.log(`[${timestamp}] ${message}`, ...args); | |
| } | |
| // Wait for element to appear | |
| function waitForElement(selector, timeout = CONFIG.WAIT_TIMEOUT) { | |
| return new Promise((resolve, reject) => { | |
| const startTime = Date.now(); | |
| function check() { | |
| const element = document.querySelector(selector); | |
| if (element) { | |
| resolve(element); | |
| } else if (Date.now() - startTime > timeout) { | |
| reject(new Error(`Element ${selector} not found within ${timeout}ms`)); | |
| } else { | |
| setTimeout(check, CONFIG.ELEMENT_CHECK_INTERVAL); | |
| } | |
| } | |
| check(); | |
| }); | |
| } | |
| // Wait for new content to load | |
| async function waitForNewContent(previousCount, rootDiv) { | |
| const maxWait = CONFIG.CONTENT_WAIT_TIMEOUT; | |
| const startTime = Date.now(); | |
| while (Date.now() - startTime < maxWait) { | |
| if (downloadCancelled) return false; | |
| const currentCount = countCardElements(rootDiv); | |
| if (currentCount > previousCount) { | |
| log(`Content loaded: ${previousCount} -> ${currentCount} cards`); | |
| return true; | |
| } | |
| await new Promise(r => setTimeout(r, CONFIG.ELEMENT_CHECK_INTERVAL)); | |
| } | |
| log('No new content detected within timeout'); | |
| return false; | |
| } | |
| // Count card elements | |
| function countCardElements(rootDiv) { | |
| const cardElements = rootDiv.querySelectorAll(SELECTORS.CARD_CONTAINER); | |
| return Array.from(cardElements).filter(el => { | |
| const classes = Array.from(el.classList); | |
| return classes.some(cls => | |
| cls === 'player-expansion-collection-card' || | |
| (cls.startsWith('player-expansion-collection-card') && !cls.includes('__')) | |
| ); | |
| }).length; | |
| } | |
| // Sanitize CSV field to prevent injection | |
| function sanitizeCSVField(field) { | |
| const str = field.toString(); | |
| // Prevent CSV injection attacks | |
| if (str.startsWith('=') || str.startsWith('+') || | |
| str.startsWith('-') || str.startsWith('@') || | |
| str.startsWith('\t') || str.startsWith('\r')) { | |
| return `'${str.replace(/"/g, '""')}`; | |
| } | |
| return str.replace(/"/g, '""'); | |
| } | |
| // Create and download CSV | |
| function downloadCSV(data, filename) { | |
| const csvContent = data.map(row => | |
| row.map(field => `"${sanitizeCSVField(field)}"`).join(',') | |
| ).join('\n'); | |
| const bom = '\uFEFF'; // UTF-8 BOM for better Excel compatibility | |
| const blob = new Blob([bom + csvContent], { type: 'text/csv;charset=utf-8;' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', filename); | |
| link.style.visibility = 'hidden'; | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| // Clean up the object URL | |
| setTimeout(() => URL.revokeObjectURL(url), 100); | |
| } | |
| // Compose rarity string from icons | |
| function getRarityString(rarityElement) { | |
| if (!rarityElement) return ''; | |
| const spans = rarityElement.querySelectorAll('span'); | |
| let rarityString = ''; | |
| spans.forEach(span => { | |
| for (const [className, icon] of Object.entries(RARITY_ICONS)) { | |
| if (span.classList.contains(className)) { | |
| rarityString += icon; | |
| break; | |
| } | |
| } | |
| }); | |
| return rarityString; | |
| } | |
| // Extract card data with error handling | |
| function extractCardData(cardElement) { | |
| const data = { | |
| set: '', | |
| id: '', | |
| name: 'Unknown Card', | |
| rarity: '', | |
| amount: '0', | |
| registered: false | |
| }; | |
| try { | |
| if (!cardElement) return data; | |
| // Extract Set and ID from link | |
| const linkElement = cardElement.querySelector(SELECTORS.CARD_LINK); | |
| if (linkElement && linkElement.href) { | |
| const href = linkElement.getAttribute('href'); | |
| const pathParts = href.split('/').filter(part => part.length > 0); | |
| if (pathParts.length >= 4 && pathParts[0] === 'cards') { | |
| data.set = pathParts[1].charAt(0).toUpperCase() + pathParts[1].slice(1); | |
| data.id = pathParts[2]; | |
| } | |
| } | |
| // Extract name | |
| const nameElement = cardElement.querySelector(SELECTORS.CARD_NAME); | |
| if (nameElement) { | |
| data.name = nameElement.textContent.trim() || data.name; | |
| } | |
| // Extract rarity | |
| const rarityElement = cardElement.querySelector(SELECTORS.CARD_RARITY); | |
| if (rarityElement) { | |
| data.rarity = getRarityString(rarityElement); | |
| } | |
| // Extract amount | |
| const amountElement = cardElement.querySelector(SELECTORS.CARD_AMOUNT); | |
| if (amountElement) { | |
| data.amount = amountElement.textContent.trim() || '0'; | |
| } | |
| // Check registration status | |
| const unregisteredElement = cardElement.querySelector(SELECTORS.CARD_UNREGISTERED); | |
| data.registered = !unregisteredElement; | |
| return data; | |
| } catch (error) { | |
| log('Error extracting card data:', error, cardElement); | |
| return data; | |
| } | |
| } | |
| // Update UI elements | |
| function updateUI(button, text) { | |
| button.textContent = text; | |
| } | |
| // Calculate statistics | |
| function calculateStats(cardData) { | |
| const registered = cardData.filter(c => c.registered).length; | |
| const unregistered = cardData.length - registered; | |
| const totalAmount = cardData.reduce((sum, c) => sum + parseInt(c.amount || 0), 0); | |
| const uniqueSets = new Set(cardData.map(c => c.set).filter(s => s)).size; | |
| return { registered, unregistered, totalAmount, uniqueSets }; | |
| } | |
| // Main download handler | |
| async function handleDownload(button, cancelButton) { | |
| if (isDownloading) { | |
| log('Download already in progress'); | |
| return; | |
| } | |
| isDownloading = true; | |
| downloadCancelled = false; | |
| button.disabled = true; | |
| cancelButton.style.display = 'inline-block'; | |
| try { | |
| const rootDiv = document.querySelector(SELECTORS.ROOT); | |
| if (!rootDiv) { | |
| throw new Error('Root div not found'); | |
| } | |
| updateUI(button, 'Loading cards...'); | |
| // Load all cards by clicking "Load More" | |
| let loadMoreButton; | |
| let clickCount = 0; | |
| let previousCount = countCardElements(rootDiv); | |
| let consecutiveNoChange = 0; | |
| const maxNoChange = 3; // Stop if no change after 3 attempts | |
| while (clickCount < CONFIG.MAX_LOAD_MORE_CLICKS) { | |
| if (downloadCancelled) { | |
| throw new Error('Download cancelled by user'); | |
| } | |
| loadMoreButton = rootDiv.querySelector(SELECTORS.LOAD_MORE); | |
| if (!loadMoreButton || loadMoreButton.style.display === 'none' || !loadMoreButton.offsetParent) { | |
| log('Load more button no longer visible'); | |
| break; | |
| } | |
| log(`Clicking load more button (${clickCount + 1})`); | |
| loadMoreButton.click(); | |
| clickCount++; | |
| // Wait for new content with smart detection | |
| const currentCount = countCardElements(rootDiv); | |
| const contentLoaded = await waitForNewContent(currentCount, rootDiv); | |
| const newCount = countCardElements(rootDiv); | |
| // Check if content actually increased | |
| if (newCount <= previousCount) { | |
| consecutiveNoChange++; | |
| if (consecutiveNoChange >= maxNoChange) { | |
| log(`No new cards loaded after ${maxNoChange} attempts, stopping`); | |
| break; | |
| } | |
| } else { | |
| consecutiveNoChange = 0; | |
| } | |
| previousCount = newCount; | |
| updateUI(button, `Loading... (${previousCount} cards found)`); | |
| await new Promise(resolve => setTimeout(resolve, CONFIG.LOAD_MORE_DELAY)); | |
| } | |
| if (clickCount >= CONFIG.MAX_LOAD_MORE_CLICKS) { | |
| log('Reached maximum click limit'); | |
| } | |
| updateUI(button, 'Waiting for final content...'); | |
| await new Promise(resolve => setTimeout(resolve, CONFIG.FINAL_LOAD_DELAY)); | |
| // Extract all card elements | |
| updateUI(button, 'Extracting card data...'); | |
| const allDivs = rootDiv.querySelectorAll(SELECTORS.CARD_CONTAINER); | |
| const cardElements = Array.from(allDivs).filter(el => { | |
| const classes = Array.from(el.classList); | |
| return classes.some(cls => | |
| cls === 'player-expansion-collection-card' || | |
| (cls.startsWith('player-expansion-collection-card') && !cls.includes('__')) | |
| ); | |
| }); | |
| log(`Found ${cardElements.length} card elements`); | |
| if (cardElements.length === 0) { | |
| throw new Error('No card elements found'); | |
| } | |
| // Extract card data in batches | |
| const allCardData = []; | |
| for (let i = 0; i < cardElements.length; i += CONFIG.BATCH_SIZE) { | |
| if (downloadCancelled) { | |
| throw new Error('Download cancelled by user'); | |
| } | |
| const batch = cardElements.slice(i, Math.min(i + CONFIG.BATCH_SIZE, cardElements.length)); | |
| const batchData = batch.map(extractCardData); | |
| allCardData.push(...batchData); | |
| updateUI(button, `Processing... (${i + batch.length}/${cardElements.length})`); | |
| } | |
| // Calculate statistics | |
| const stats = calculateStats(allCardData); | |
| log('Statistics:', stats); | |
| // Prepare CSV data | |
| updateUI(button, 'Generating CSV...'); | |
| const csvData = [['Set', 'ID', 'Name', 'Rarity', 'Amount', 'Registered']]; | |
| allCardData.forEach(card => { | |
| csvData.push([ | |
| card.set, | |
| card.id, | |
| card.name, | |
| card.rarity, | |
| card.amount, | |
| card.registered ? '✓' : '' | |
| ]); | |
| }); | |
| // Download CSV | |
| const now = new Date(); | |
| const dateStr = now.toISOString().split('T')[0]; | |
| const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '-'); | |
| const filename = `pokemon-cards-${dateStr}_${timeStr}.csv`; | |
| downloadCSV(csvData, filename); | |
| updateUI( | |
| button, | |
| `✓ Downloaded ${allCardData.length} cards (${stats.registered} registered, ${stats.uniqueSets} sets)` | |
| ); | |
| log(`CSV downloaded: ${allCardData.length} cards, ${stats.registered} registered, ${stats.uniqueSets} unique sets`); | |
| } catch (error) { | |
| log('Error during download:', error); | |
| updateUI(button, `Error: ${error.message}`); | |
| alert(`Download failed: ${error.message}\n\nCheck the console for details.`); | |
| } finally { | |
| isDownloading = false; | |
| cancelButton.style.display = 'none'; | |
| setTimeout(() => { | |
| button.disabled = false; | |
| updateUI(button, 'Download CSV'); | |
| }, CONFIG.BUTTON_RESET_DELAY); | |
| } | |
| } | |
| // Cancel download | |
| function cancelDownload(button, cancelButton) { | |
| if (!isDownloading) return; | |
| downloadCancelled = true; | |
| log('Download cancellation requested'); | |
| updateUI(button, 'Cancelling...'); | |
| } | |
| // Create UI elements | |
| function createUI() { | |
| const container = document.createElement('div'); | |
| container.style.cssText = ` | |
| margin: 10px 0; | |
| padding: 15px; | |
| background: #f5f5f5; | |
| border-radius: 8px; | |
| box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| `; | |
| // Download button | |
| const downloadButton = document.createElement('button'); | |
| downloadButton.id = 'pokemon-csv-download'; | |
| downloadButton.textContent = 'Download CSV'; | |
| downloadButton.style.cssText = ` | |
| background-color: #4CAF50; | |
| color: white; | |
| padding: 12px 24px; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| font-weight: bold; | |
| margin-right: 10px; | |
| transition: background-color 0.3s; | |
| `; | |
| // Cancel button | |
| const cancelButton = document.createElement('button'); | |
| cancelButton.textContent = 'Cancel'; | |
| cancelButton.style.cssText = ` | |
| background-color: #f44336; | |
| color: white; | |
| padding: 12px 24px; | |
| border: none; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 14px; | |
| font-weight: bold; | |
| display: none; | |
| transition: background-color 0.3s; | |
| `; | |
| // Button hover effects | |
| downloadButton.addEventListener('mouseenter', () => { | |
| if (!downloadButton.disabled) { | |
| downloadButton.style.backgroundColor = '#45a049'; | |
| } | |
| }); | |
| downloadButton.addEventListener('mouseleave', () => { | |
| downloadButton.style.backgroundColor = '#4CAF50'; | |
| }); | |
| cancelButton.addEventListener('mouseenter', () => { | |
| cancelButton.style.backgroundColor = '#da190b'; | |
| }); | |
| cancelButton.addEventListener('mouseleave', () => { | |
| cancelButton.style.backgroundColor = '#f44336'; | |
| }); | |
| // Event listeners | |
| downloadButton.addEventListener('click', () => | |
| handleDownload(downloadButton, cancelButton) | |
| ); | |
| cancelButton.addEventListener('click', () => | |
| cancelDownload(downloadButton, cancelButton) | |
| ); | |
| // Assemble UI | |
| container.appendChild(downloadButton); | |
| container.appendChild(cancelButton); | |
| return container; | |
| } | |
| // Initialize script | |
| async function init() { | |
| try { | |
| log('Initializing Pokemon Zone CSV Downloader v2.0'); | |
| const rootDiv = await waitForElement(SELECTORS.ROOT); | |
| const uiContainer = createUI(); | |
| if (rootDiv.firstChild) { | |
| rootDiv.insertBefore(uiContainer, rootDiv.firstChild); | |
| } else { | |
| rootDiv.appendChild(uiContainer); | |
| } | |
| log('Pokemon Zone CSV Downloader initialized successfully'); | |
| } catch (error) { | |
| log('Failed to initialize:', error); | |
| } | |
| } | |
| // Start the script | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', init); | |
| } else { | |
| init(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment