Asodesk Keyword Table XML Exporter

Export keyword table data to XML format with all columns

Size

16.9 KB

Version

2.0.0

Created

Mar 3, 2026

Updated

19 days ago

1// ==UserScript==
2// @name		Asodesk Keyword Table XML Exporter
3// @description		Export keyword table data to XML format with all columns
4// @version		2.0.0
5// @match		https://*.hq.asodesk.com/*
6// @icon		https://hq.asodesk.com/static/assets/img/favicon.ico?v=1
7// ==/UserScript==
8(function() {
9    'use strict';
10
11    console.log('Asodesk Keyword Table XML Exporter v2.0 initialized');
12
13    // ─── UTILS ───────────────────────────────────────────────────────────────
14
15    function debounce(func, wait) {
16        let timeout;
17        return function executedFunction(...args) {
18            const later = () => { clearTimeout(timeout); func(...args); };
19            clearTimeout(timeout);
20            timeout = setTimeout(later, wait);
21        };
22    }
23
24    function escapeXml(unsafe) {
25        if (unsafe === null || unsafe === undefined) return '';
26        return String(unsafe)
27            .replace(/&/g, '&')
28            .replace(/</g, '&lt;')
29            .replace(/>/g, '&gt;')
30            .replace(/"/g, '&quot;')
31            .replace(/'/g, '&apos;');
32    }
33
34    function cleanColumnName(name) {
35        return name
36            .replace(/\s+/g, '_')
37            .replace(/[^a-zA-Z0-9_]/g, '')
38            .replace(/^[0-9]/, '_$&')
39            .toLowerCase() || 'column';
40    }
41
42    // ─── DEBUG HELPER ─────────────────────────────────────────────────────────
43
44    function debugDOM() {
45        console.group('=== ASODESK DOM DEBUG ===');
46
47        // ReactTable variants
48        const selectors = [
49            '.ReactTable__variableList',
50            '.ReactTable',
51            '[class*="ReactTable"]',
52            '.rt-table',
53            '[class*="rt-table"]',
54            '.rt-thead',
55            '.rt-tbody',
56            '.rt-tr',
57            '.rt-th',
58            '.rt-td',
59            '#tr-groups',
60            '[id*="tr-group"]',
61            '[class*="keyword"]',
62            'table',
63            'thead',
64            'tbody',
65            'tr',
66            'th',
67            'td',
68        ];
69
70        selectors.forEach(sel => {
71            const els = document.querySelectorAll(sel);
72            if (els.length > 0) console.log(`✅ "${sel}": ${els.length} found`);
73        });
74
75        // Log all unique classes on visible rows
76        const allRows = document.querySelectorAll('[class*="row"], [class*="Row"]');
77        const rowClasses = new Set();
78        allRows.forEach(r => r.classList.forEach(c => rowClasses.add(c)));
79        console.log('Row-like classes:', [...rowClasses].slice(0, 30));
80
81        // Log all h3 texts
82        document.querySelectorAll('h3').forEach(h => {
83            console.log('h3:', h.className, '|', h.textContent.trim().slice(0, 60));
84        });
85
86        console.groupEnd();
87    }
88
89    // ─── DATA EXTRACTION ─────────────────────────────────────────────────────
90
91    function extractTableData() {
92        console.group('=== EXTRACTING TABLE DATA ===');
93
94        // ── 1. Headers ──────────────────────────────────────────────────────
95        // Try multiple header selectors in order of specificity
96        const headerSelectors = [
97            '.ReactTable__variableList .rt-thead .rt-th',
98            '.ReactTable .rt-thead .rt-th',
99            '[class*="ReactTable"] .rt-thead .rt-th',
100            '.rt-thead .rt-th',
101            'thead th',
102        ];
103
104        let headerElements = [];
105        for (const sel of headerSelectors) {
106            const found = document.querySelectorAll(sel);
107            if (found.length > 0) {
108                headerElements = Array.from(found);
109                console.log(`Headers via "${sel}": ${found.length}`);
110                break;
111            }
112        }
113
114        const headers = headerElements
115            .map(th => {
116                // Some headers have nested spans/divs — grab the deepest meaningful text
117                const inner = th.querySelector('[class*="title"], [class*="label"], span, div');
118                const text = (inner ? inner.textContent : th.textContent).trim();
119                return text.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
120            })
121            .filter(h => h.length > 0);
122
123        console.log('Headers:', headers);
124
125        // ── 2. Rows ─────────────────────────────────────────────────────────
126        const rowSelectors = [
127            '#tr-groups .rt-tr.default-row',
128            '#tr-groups .rt-tr',
129            '.rt-tbody .rt-tr.default-row',
130            '.rt-tbody .rt-tr',
131            '[class*="rt-tbody"] [class*="rt-tr"]',
132            'tbody tr',
133        ];
134
135        let rows = [];
136        for (const sel of rowSelectors) {
137            const found = document.querySelectorAll(sel);
138            if (found.length > 0) {
139                rows = Array.from(found);
140                console.log(`Rows via "${sel}": ${found.length}`);
141                break;
142            }
143        }
144
145        // ── 3. Cell extraction ──────────────────────────────────────────────
146        const cellSelectors = ['.rt-td', 'td'];
147
148        const tableData = [];
149
150        rows.forEach((row, rowIndex) => {
151            // Skip header rows that may have sneaked in
152            if (row.closest('.rt-thead') || row.closest('thead')) return;
153
154            let cells = [];
155            for (const sel of cellSelectors) {
156                const found = row.querySelectorAll(sel);
157                if (found.length > 0) { cells = Array.from(found); break; }
158            }
159
160            if (cells.length === 0) return;
161
162            const rowData = {};
163            cells.forEach((cell, cellIndex) => {
164                // Use header name if available, otherwise generic column_N
165                const headerName = headers[cellIndex] || `column_${cellIndex + 1}`;
166
167                // Try to get the visible text cleanly
168                // Remove hidden elements like tooltips/popups
169                const cloned = cell.cloneNode(true);
170                cloned.querySelectorAll('[class*="tooltip"], [class*="Tooltip"], [class*="popup"], [class*="Popup"], [aria-hidden="true"]')
171                    .forEach(el => el.remove());
172
173                let cellText = cloned.textContent.trim().replace(/\s+/g, ' ');
174                if (!cellText || cellText === '') cellText = '-';
175
176                rowData[headerName] = cellText;
177            });
178
179            // Only add rows that have at least one non-empty, non-header value
180            if (Object.keys(rowData).length > 0) {
181                tableData.push(rowData);
182            }
183        });
184
185        // ── 4. Fallback: generic table ───────────────────────────────────────
186        if (tableData.length === 0) {
187            console.warn('Standard selectors returned 0 rows — trying generic table fallback');
188            const genericTable = document.querySelector('table');
189            if (genericTable) {
190                const ths = genericTable.querySelectorAll('th');
191                const genericHeaders = Array.from(ths).map(th => th.textContent.trim());
192                const trs = genericTable.querySelectorAll('tbody tr');
193                trs.forEach(tr => {
194                    const tds = tr.querySelectorAll('td');
195                    const rowData = {};
196                    tds.forEach((td, i) => {
197                        const key = genericHeaders[i] || `column_${i + 1}`;
198                        rowData[key] = td.textContent.trim().replace(/\s+/g, ' ') || '-';
199                    });
200                    if (Object.keys(rowData).length > 0) tableData.push(rowData);
201                });
202                if (genericHeaders.length > 0 && headers.length === 0) {
203                    headers.push(...genericHeaders);
204                }
205            }
206        }
207
208        console.log(`Total rows extracted: ${tableData.length}`);
209        console.groupEnd();
210
211        // Resolve final headers from actual data keys if headers array is empty
212        const finalHeaders = headers.length > 0
213            ? headers
214            : tableData.length > 0 ? Object.keys(tableData[0]) : [];
215
216        return { headers: finalHeaders, data: tableData };
217    }
218
219    // ─── XML CONVERSION ───────────────────────────────────────────────────────
220
221    function convertToXML(tableData) {
222        let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
223        xml += '<KeywordTable>\n';
224        xml += '  <Metadata>\n';
225        xml += `    <ExportDate>${escapeXml(new Date().toISOString())}</ExportDate>\n`;
226        xml += `    <TotalKeywords>${tableData.data.length}</TotalKeywords>\n`;
227        xml += `    <Source>${escapeXml(window.location.href)}</Source>\n`;
228        xml += '  </Metadata>\n';
229        xml += '  <Keywords>\n';
230
231        tableData.data.forEach((row, index) => {
232            xml += `    <Keyword id="${index + 1}">\n`;
233            tableData.headers.forEach(header => {
234                const cleanTag = cleanColumnName(header);
235                const value = row[header] !== undefined ? row[header] : '-';
236                xml += `      <${cleanTag}>${escapeXml(value)}</${cleanTag}>\n`;
237            });
238            xml += '    </Keyword>\n';
239        });
240
241        xml += '  </Keywords>\n';
242        xml += '</KeywordTable>';
243        return xml;
244    }
245
246    // ─── DOWNLOAD ─────────────────────────────────────────────────────────────
247
248    function downloadXML(xmlContent) {
249        const blob = new Blob([xmlContent], { type: 'application/xml' });
250        const url = URL.createObjectURL(blob);
251        const a = document.createElement('a');
252        a.href = url;
253        const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
254        a.download = `asodesk-keywords-${timestamp}.xml`;
255        document.body.appendChild(a);
256        a.click();
257        document.body.removeChild(a);
258        URL.revokeObjectURL(url);
259        console.log('XML downloaded');
260    }
261
262    // ─── BUTTON ───────────────────────────────────────────────────────────────
263
264    function findKeywordTableHeader() {
265        // Try multiple approaches to locate the "Keyword Table" section header
266        const candidates = [
267            ...document.querySelectorAll('h3'),
268            ...document.querySelectorAll('h2'),
269            ...document.querySelectorAll('[class*="title"]'),
270            ...document.querySelectorAll('[class*="header"]'),
271        ];
272        return candidates.find(el =>
273            el.textContent.trim().toLowerCase().includes('keyword table') ||
274            el.textContent.trim().toLowerCase().includes('keywords')
275        ) || null;
276    }
277
278    function addExportButton() {
279        if (document.getElementById('xml-export-button')) return; // already present
280
281        const targetHeader = findKeywordTableHeader();
282        if (!targetHeader) {
283            console.log('Keyword Table header not found yet...');
284            return;
285        }
286
287        console.log('Adding export button next to:', targetHeader.textContent.trim().slice(0, 40));
288
289        // ── Debug button (secondary) ─────────────────────────────────────────
290        const debugBtn = document.createElement('button');
291        debugBtn.id = 'xml-debug-button';
292        debugBtn.textContent = '🔍 Debug DOM';
293        debugBtn.style.cssText = `
294            padding: 8px 12px;
295            background-color: #6366f1;
296            color: white;
297            border: none;
298            border-radius: 4px;
299            cursor: pointer;
300            font-size: 13px;
301            font-weight: 600;
302            margin-left: 8px;
303        `;
304        debugBtn.addEventListener('click', () => {
305            debugDOM();
306            const td = extractTableData();
307            console.log('Headers:', td.headers);
308            console.log('First 3 rows:', td.data.slice(0, 3));
309            alert(`Debug info in console.\nHeaders: ${td.headers.length}\nRows: ${td.data.length}`);
310        });
311
312        // ── Export button (primary) ──────────────────────────────────────────
313        const exportButton = document.createElement('button');
314        exportButton.id = 'xml-export-button';
315        exportButton.innerHTML = `
316            <svg xmlns="http://www.w3.org/2000/svg" style="margin-right:6px" width="15" height="15"
317                 viewBox="0 0 24 24" fill="none" stroke="currentColor"
318                 stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
319                <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
320                <polyline points="7 10 12 15 17 10"/>
321                <line x1="12" y1="15" x2="12" y2="3"/>
322            </svg>
323            Export to XML
324        `;
325        exportButton.style.cssText = `
326            padding: 8px 16px;
327            background-color: #10b981;
328            color: white;
329            border: none;
330            border-radius: 4px;
331            cursor: pointer;
332            font-size: 14px;
333            font-weight: 600;
334            margin-left: 8px;
335            display: inline-flex;
336            align-items: center;
337            transition: background-color 0.2s;
338        `;
339        exportButton.addEventListener('mouseenter', () => exportButton.style.backgroundColor = '#059669');
340        exportButton.addEventListener('mouseleave', () => exportButton.style.backgroundColor = '#10b981');
341
342        exportButton.addEventListener('click', () => {
343            const original = exportButton.innerHTML;
344            exportButton.innerHTML = '<span>Exporting…</span>';
345            exportButton.disabled = true;
346            exportButton.style.opacity = '0.7';
347
348            try {
349                const tableData = extractTableData();
350
351                if (tableData.data.length === 0) {
352                    alert('⚠️ No data found.\n\nTry clicking "🔍 Debug DOM" to inspect what the script sees.\nMake sure the table is fully loaded.');
353                    exportButton.innerHTML = original;
354                    exportButton.disabled = false;
355                    exportButton.style.opacity = '1';
356                    return;
357                }
358
359                const xmlContent = convertToXML(tableData);
360                downloadXML(xmlContent);
361
362                exportButton.innerHTML = '<span>✓ Exported!</span>';
363                setTimeout(() => {
364                    exportButton.innerHTML = original;
365                    exportButton.disabled = false;
366                    exportButton.style.opacity = '1';
367                }, 2000);
368
369            } catch (error) {
370                console.error('Export error:', error);
371                alert('Error: ' + error.message);
372                exportButton.innerHTML = original;
373                exportButton.disabled = false;
374                exportButton.style.opacity = '1';
375            }
376        });
377
378        // Insert both buttons into / after the header element
379        // If header has a flex parent, append to parent; otherwise append to header itself
380        const parent = targetHeader.parentElement;
381        const insertTarget = (parent && getComputedStyle(parent).display === 'flex') ? parent : targetHeader;
382        insertTarget.appendChild(debugBtn);
383        insertTarget.appendChild(exportButton);
384
385        console.log('Buttons added successfully');
386    }
387
388    // ─── INIT ─────────────────────────────────────────────────────────────────
389
390    function init() {
391        if (document.readyState === 'loading') {
392            document.addEventListener('DOMContentLoaded', init);
393            return;
394        }
395
396        // First attempt after page settles
397        setTimeout(addExportButton, 2500);
398
399        // Watch for SPA navigation / dynamic content
400        const debouncedAdd = debounce(() => {
401            // Only try to add if button is gone (navigation may have removed it)
402            if (!document.getElementById('xml-export-button')) {
403                addExportButton();
404            }
405        }, 1200);
406
407        const observer = new MutationObserver(debouncedAdd);
408        observer.observe(document.body, { childList: true, subtree: true });
409
410        console.log('Extension ready');
411    }
412
413    init();
414
415})();