Greasy Fork 还支持 简体中文。

JSON Response Capture

Capture and save JSON responses from web requests with URL filtering

目前為 2024-12-22 提交的版本,檢視 最新版本

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