JSON Response Capture

Capture and save JSON responses from web requests with URL filtering

  1. // ==UserScript==
  2. // @name JSON Response Capture
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.3
  5. // @description Capture and save JSON responses from web requests with URL filtering
  6. // @author nickm8
  7. // @license MIT
  8. // @match *://*/*
  9. // @grant none
  10. // @require https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js
  11. // @require https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js
  12. // ==/UserScript==
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. const style = document.createElement('style');
  18. style.textContent = `
  19. :root {
  20. --bg-primary: #ffffff;
  21. --bg-secondary: #f9fafb;
  22. --text-primary: #1f2937;
  23. --text-secondary: #4b5563;
  24. --text-tertiary: #6b7280;
  25. --border-color: #e5e7eb;
  26. --shadow-color: rgba(0, 0, 0, 0.1);
  27. --hover-color: #2563eb;
  28. --danger-color: #dc2626;
  29. --tag-bg: #f3f4f6;
  30. }
  31.  
  32. @media (prefers-color-scheme: dark) {
  33. :root {
  34. --bg-primary: #1f2937;
  35. --bg-secondary: #374151;
  36. --text-primary: #f9fafb;
  37. --text-secondary: #d1d5db;
  38. --text-tertiary: #9ca3af;
  39. --border-color: #4b5563;
  40. --shadow-color: rgba(0, 0, 0, 0.3);
  41. --hover-color: #60a5fa;
  42. --danger-color: #ef4444;
  43. --tag-bg: #374151;
  44. }
  45. }
  46.  
  47. .json-capture-panel {
  48. position: fixed;
  49. bottom: 1rem;
  50. right: 1rem;
  51. background: var(--bg-primary);
  52. color: var(--text-primary);
  53. border-radius: 0.5rem;
  54. box-shadow: 0 4px 6px -1px var(--shadow-color);
  55. transition: all 200ms;
  56. z-index: 10000;
  57. }
  58.  
  59. .json-capture-panel.minimized { width: 3rem; }
  60. .json-capture-panel.expanded { width: 24rem; }
  61.  
  62. .json-capture-header {
  63. display: flex;
  64. align-items: center;
  65. justify-content: space-between;
  66. padding: 0.5rem;
  67. border-bottom: 1px solid var(--border-color);
  68. background: var(--bg-secondary);
  69. border-top-left-radius: 0.5rem;
  70. border-top-right-radius: 0.5rem;
  71. }
  72.  
  73. .json-capture-content {
  74. max-height: 24rem;
  75. overflow: auto;
  76. padding: 1rem;
  77. }
  78.  
  79. .json-capture-item {
  80. margin-bottom: 1rem;
  81. padding: 0.5rem;
  82. border: 1px solid var(--border-color);
  83. border-radius: 0.375rem;
  84. }
  85.  
  86. .json-capture-url {
  87. font-size: 0.875rem;
  88. color: var(--text-secondary);
  89. margin-bottom: 0.25rem;
  90. word-break: break-all;
  91. }
  92.  
  93. .json-capture-timestamp {
  94. font-size: 0.75rem;
  95. color: var(--text-tertiary);
  96. margin-bottom: 0.5rem;
  97. }
  98.  
  99. .json-capture-json {
  100. font-size: 0.75rem;
  101. background: var(--bg-secondary);
  102. padding: 0.5rem;
  103. border-radius: 0.375rem;
  104. overflow: auto;
  105. white-space: pre-wrap;
  106. max-height: 12rem;
  107. color: var(--text-primary);
  108. }
  109.  
  110. .json-capture-button {
  111. padding: 0.25rem 0.5rem;
  112. background: none;
  113. border: none;
  114. cursor: pointer;
  115. color: var(--text-secondary);
  116. }
  117.  
  118. .json-capture-button:hover {
  119. color: var(--hover-color);
  120. }
  121.  
  122. .json-capture-button.delete:hover {
  123. color: var(--danger-color);
  124. }
  125.  
  126. .config-section {
  127. margin-bottom: 1rem;
  128. padding: 0.5rem;
  129. border: 1px solid var(--border-color);
  130. border-radius: 0.375rem;
  131. }
  132.  
  133. .config-title {
  134. font-weight: 600;
  135. margin-bottom: 0.5rem;
  136. color: var(--text-primary);
  137. }
  138.  
  139. .config-input {
  140. display: flex;
  141. gap: 0.5rem;
  142. margin-bottom: 0.5rem;
  143. }
  144.  
  145. .config-input input {
  146. flex: 1;
  147. padding: 0.25rem 0.5rem;
  148. border: 1px solid var(--border-color);
  149. border-radius: 0.25rem;
  150. background: var(--bg-primary);
  151. color: var(--text-primary);
  152. }
  153.  
  154. .config-list {
  155. display: flex;
  156. flex-wrap: wrap;
  157. gap: 0.25rem;
  158. }
  159.  
  160. .config-tag {
  161. background: var(--tag-bg);
  162. color: var(--text-primary);
  163. padding: 0.25rem 0.5rem;
  164. border-radius: 0.25rem;
  165. display: flex;
  166. align-items: center;
  167. gap: 0.25rem;
  168. }
  169.  
  170. .config-tag button {
  171. padding: 0;
  172. background: none;
  173. border: none;
  174. cursor: pointer;
  175. color: var(--text-tertiary);
  176. }
  177.  
  178. .tabs {
  179. display: flex;
  180. border-bottom: 1px solid var(--border-color);
  181. margin-bottom: 1rem;
  182. }
  183.  
  184. .tab {
  185. padding: 0.5rem 1rem;
  186. cursor: pointer;
  187. border-bottom: 2px solid transparent;
  188. color: var(--text-secondary);
  189. }
  190.  
  191. .tab.active {
  192. border-bottom-color: var(--hover-color);
  193. color: var(--hover-color);
  194. }
  195. `;
  196. document.head.appendChild(style);
  197. const { useState, useEffect } = React;
  198. // Configuration component
  199. function ConfigSection({ matches, ignores, onUpdateMatches, onUpdateIgnores }) {
  200. const [newMatch, setNewMatch] = useState('');
  201. const [newIgnore, setNewIgnore] = useState('');
  202. const addMatch = () => {
  203. if (newMatch && !matches.includes(newMatch)) {
  204. onUpdateMatches([...matches, newMatch]);
  205. setNewMatch('');
  206. }
  207. };
  208. const addIgnore = () => {
  209. if (newIgnore && !ignores.includes(newIgnore)) {
  210. onUpdateIgnores([...ignores, newIgnore]);
  211. setNewIgnore('');
  212. }
  213. };
  214. const removeMatch = (match) => {
  215. onUpdateMatches(matches.filter(m => m !== match));
  216. };
  217. const removeIgnore = (ignore) => {
  218. onUpdateIgnores(ignores.filter(i => i !== ignore));
  219. };
  220. return React.createElement('div', { className: 'config-content' }, [
  221. React.createElement('div', { key: 'matches', className: 'config-section' }, [
  222. React.createElement('div', { className: 'config-title' }, 'URL Matches'),
  223. React.createElement('div', { className: 'config-input' }, [
  224. React.createElement('input', {
  225. value: newMatch,
  226. onChange: (e) => setNewMatch(e.target.value),
  227. placeholder: 'Enter URL keyword to match',
  228. onKeyPress: (e) => e.key === 'Enter' && addMatch()
  229. }),
  230. React.createElement('button', {
  231. className: 'json-capture-button',
  232. onClick: addMatch
  233. }, '+')
  234. ]),
  235. React.createElement('div', { className: 'config-list' },
  236. matches.map(match =>
  237. React.createElement('span', { key: match, className: 'config-tag' }, [
  238. match,
  239. React.createElement('button', {
  240. onClick: () => removeMatch(match)
  241. }, '×')
  242. ])
  243. )
  244. )
  245. ]),
  246. React.createElement('div', { key: 'ignores', className: 'config-section' }, [
  247. React.createElement('div', { className: 'config-title' }, 'URL Ignores'),
  248. React.createElement('div', { className: 'config-input' }, [
  249. React.createElement('input', {
  250. value: newIgnore,
  251. onChange: (e) => setNewIgnore(e.target.value),
  252. placeholder: 'Enter URL keyword to ignore',
  253. onKeyPress: (e) => e.key === 'Enter' && addIgnore()
  254. }),
  255. React.createElement('button', {
  256. className: 'json-capture-button',
  257. onClick: addIgnore
  258. }, '+')
  259. ]),
  260. React.createElement('div', { className: 'config-list' },
  261. ignores.map(ignore =>
  262. React.createElement('span', { key: ignore, className: 'config-tag' }, [
  263. ignore,
  264. React.createElement('button', {
  265. onClick: () => removeIgnore(ignore)
  266. }, '×')
  267. ])
  268. )
  269. )
  270. ])
  271. ]);
  272. }
  273. function JsonCapturePanel() {
  274. const [captures, setCaptures] = useState([]);
  275. const [isMinimized, setIsMinimized] = useState(true);
  276. const [activeTab, setActiveTab] = useState('captures');
  277. const [matches, setMatches] = useState([]);
  278. const [ignores, setIgnores] = useState([]);
  279. const domain = window.location.hostname;
  280. // Load configuration from localStorage
  281. useEffect(() => {
  282. const config = JSON.parse(localStorage.getItem(`jsonCapture_${domain}`) || '{"matches":[],"ignores":[]}');
  283. setMatches(config.matches);
  284. setIgnores(config.ignores);
  285. }, []);
  286. // Save configuration to localStorage
  287. useEffect(() => {
  288. localStorage.setItem(`jsonCapture_${domain}`, JSON.stringify({ matches, ignores }));
  289. }, [matches, ignores]);
  290. const shouldCaptureUrl = (url) => {
  291. if (matches.length === 0) return false;
  292. return matches.some(match => url.includes(match)) &&
  293. !ignores.some(ignore => url.includes(ignore));
  294. };
  295. useEffect(() => {
  296. // Intercept fetch
  297. const originalFetch = window.fetch;
  298. window.fetch = async function(...args) {
  299. const response = await originalFetch.apply(this, args);
  300. const clone = response.clone();
  301. try {
  302. const contentType = clone.headers.get('content-type');
  303. if (contentType && contentType.includes('application/json')) {
  304. const url = typeof args[0] === 'string' ? args[0] : args[0].url;
  305. if (shouldCaptureUrl(url)) {
  306. const json = await clone.json();
  307. setCaptures(prev => [...prev, {
  308. timestamp: new Date().toISOString(),
  309. url,
  310. data: json
  311. }]);
  312. }
  313. }
  314. } catch (err) {
  315. console.error('Error processing response:', err);
  316. }
  317. return response;
  318. };
  319. // Intercept XHR
  320. const originalXHROpen = XMLHttpRequest.prototype.open;
  321. const originalXHRSend = XMLHttpRequest.prototype.send;
  322. XMLHttpRequest.prototype.open = function(...args) {
  323. this._url = args[1];
  324. return originalXHROpen.apply(this, args);
  325. };
  326. XMLHttpRequest.prototype.send = function(...args) {
  327. this.addEventListener('load', function() {
  328. try {
  329. const contentType = this.getResponseHeader('content-type');
  330. if (contentType && contentType.includes('application/json')) {
  331. if (shouldCaptureUrl(this._url)) {
  332. const json = JSON.parse(this.responseText);
  333. setCaptures(prev => [...prev, {
  334. timestamp: new Date().toISOString(),
  335. url: this._url,
  336. data: json
  337. }]);
  338. }
  339. }
  340. } catch (err) {
  341. console.error('Error processing XHR response:', err);
  342. }
  343. });
  344. return originalXHRSend.apply(this, args);
  345. };
  346. }, [matches, ignores]);
  347. const handleSave = () => {
  348. const blob = new Blob([JSON.stringify(captures, null, 2)], { type: 'application/json' });
  349. const url = URL.createObjectURL(blob);
  350. const a = document.createElement('a');
  351. a.href = url;
  352. a.download = `json-captures-${domain}-${new Date().toISOString()}.json`;
  353. a.click();
  354. URL.revokeObjectURL(url);
  355. };
  356. const handleClear = () => {
  357. setCaptures([]);
  358. };
  359. return React.createElement('div', {
  360. className: `json-capture-panel ${isMinimized ? 'minimized' : 'expanded'}`
  361. }, [
  362. // Header
  363. React.createElement('div', {
  364. key: 'header',
  365. className: 'json-capture-header'
  366. }, [
  367. !isMinimized && React.createElement('div', {
  368. key: 'title'
  369. }, `JSON Captures (${captures.length})`),
  370. React.createElement('div', {
  371. key: 'controls',
  372. style: { display: 'flex', gap: '0.5rem' }
  373. }, [
  374. !isMinimized && React.createElement('button', {
  375. key: 'save',
  376. className: 'json-capture-button',
  377. onClick: handleSave,
  378. title: 'Save captures'
  379. }, '💾'),
  380. React.createElement('button', {
  381. key: 'toggle',
  382. className: 'json-capture-button',
  383. onClick: () => setIsMinimized(!isMinimized),
  384. title: isMinimized ? 'Expand' : 'Minimize'
  385. }, isMinimized ? '⤢' : '⤡'),
  386. !isMinimized && React.createElement('button', {
  387. key: 'clear',
  388. className: 'json-capture-button delete',
  389. onClick: handleClear,
  390. title: 'Clear captures'
  391. }, '✕')
  392. ])
  393. ]),
  394. // Content
  395. !isMinimized && React.createElement('div', { key: 'content' }, [
  396. // Tabs
  397. React.createElement('div', { key: 'tabs', className: 'tabs' }, [
  398. React.createElement('div', {
  399. className: `tab ${activeTab === 'captures' ? 'active' : ''}`,
  400. onClick: () => setActiveTab('captures')
  401. }, 'Captures'),
  402. React.createElement('div', {
  403. className: `tab ${activeTab === 'config' ? 'active' : ''}`,
  404. onClick: () => setActiveTab('config')
  405. }, 'Configuration')
  406. ]),
  407. // Tab content
  408. activeTab === 'captures' ?
  409. React.createElement('div', {
  410. key: 'captures',
  411. className: 'json-capture-content'
  412. },
  413. captures.length === 0
  414. ? React.createElement('div', {
  415. style: { textAlign: 'center', color: '#6b7280' }
  416. }, matches.length === 0 ? 'Configure URL matches to start capturing' : 'No JSON responses captured yet')
  417. : captures.map((capture, index) =>
  418. React.createElement('div', {
  419. key: index,
  420. className: 'json-capture-item'
  421. }, [
  422. React.createElement('div', {
  423. key: 'url',
  424. className: 'json-capture-url'
  425. }, capture.url),
  426. React.createElement('div', {
  427. key: 'timestamp',
  428. className: 'json-capture-timestamp'
  429. }, capture.timestamp),
  430. React.createElement('pre', {
  431. key: 'json',
  432. className: 'json-capture-json'
  433. }, JSON.stringify(capture.data, null, 2))
  434. ])
  435. )
  436. )
  437. : React.createElement(ConfigSection, {
  438. key: 'config',
  439. matches,
  440. ignores,
  441. onUpdateMatches: setMatches,
  442. onUpdateIgnores: setIgnores
  443. })
  444. ])
  445. ]);
  446. }
  447. const container = document.createElement('div');
  448. document.body.appendChild(container);
  449. ReactDOM.render(React.createElement(JsonCapturePanel), container);
  450. })();