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, '<')
29 .replace(/>/g, '>')
30 .replace(/"/g, '"')
31 .replace(/'/g, ''');
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})();