376 lines
13 KiB
JavaScript
376 lines
13 KiB
JavaScript
// ==UserScript==
|
|
// @name STATdx Passive Capture Link
|
|
// @namespace http://tampermonkey.net/
|
|
// @version 1.1
|
|
// @description Captures STATdx content (JSON/HTML/images) passively and sends it to the central cache server.
|
|
// @author Antigravity
|
|
// @match https://app.statdx.com/*
|
|
// @grant GM_xmlhttpRequest
|
|
// @connect localhost
|
|
// @connect *
|
|
// @run-at document-start
|
|
// ==/UserScript==
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
// CHANGE THIS URL to the IP address or hostname of your central cache server if running on remote machines
|
|
// e.g., "http://192.168.1.50:8000"
|
|
const CACHE_SERVER_URL = "http://localhost:8000";
|
|
|
|
const capturedUrls = new Set();
|
|
|
|
function shouldCapture(url, contentType) {
|
|
try {
|
|
if (!url) return false;
|
|
const lowerUrl = url.toLowerCase();
|
|
const lowerContentType = (contentType || "").toLowerCase();
|
|
|
|
// Always capture known document content / summary endpoints
|
|
if (lowerUrl.includes('/document/content/') || lowerUrl.includes('/document/summary/')) {
|
|
return true;
|
|
}
|
|
|
|
// Check if contentType indicates target resource types
|
|
if (lowerContentType.includes('application/json') ||
|
|
lowerContentType.startsWith('text/') ||
|
|
lowerContentType.includes('javascript') ||
|
|
lowerContentType.startsWith('image/')) {
|
|
return true;
|
|
}
|
|
} catch (e) {}
|
|
return false;
|
|
}
|
|
|
|
// Helper: Compute SHA-256 hash of an ArrayBuffer using Web Crypto API
|
|
async function getSha256(buffer) {
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
// Helper: Convert ArrayBuffer to Base64 string
|
|
function arrayBufferToBase64(buffer) {
|
|
let binary = '';
|
|
const bytes = new Uint8Array(buffer);
|
|
const len = bytes.byteLength;
|
|
for (let i = 0; i < len; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return window.btoa(binary);
|
|
}
|
|
|
|
// Helper: Parse response headers string to dict
|
|
function parseHeaders(headersString) {
|
|
const headers = {};
|
|
try {
|
|
if (!headersString) return headers;
|
|
const lines = headersString.split('\r\n');
|
|
lines.forEach(line => {
|
|
const parts = line.split(':');
|
|
if (parts.length >= 2) {
|
|
const key = parts[0].trim().toLowerCase();
|
|
const value = parts.slice(1).join(':').trim();
|
|
headers[key] = value;
|
|
}
|
|
});
|
|
} catch (e) {}
|
|
return headers;
|
|
}
|
|
|
|
// Helper: Get Headers dict from fetch Headers object
|
|
function getHeadersDict(headers) {
|
|
const dict = {};
|
|
try {
|
|
if (!headers) return dict;
|
|
if (typeof headers.forEach === 'function') {
|
|
headers.forEach((val, key) => {
|
|
dict[key.toLowerCase()] = val;
|
|
});
|
|
} else {
|
|
for (const [key, val] of Object.entries(headers)) {
|
|
dict[key.toLowerCase()] = val;
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
return dict;
|
|
}
|
|
|
|
// Helper: Extract buffer from XHR
|
|
async function getXhrBuffer(xhr) {
|
|
try {
|
|
if (xhr.responseType === 'arraybuffer') {
|
|
return xhr.response;
|
|
}
|
|
if (xhr.responseType === 'blob') {
|
|
return await xhr.response.arrayBuffer();
|
|
}
|
|
if (typeof xhr.response === 'string' || !xhr.responseType || xhr.responseType === 'text') {
|
|
const text = xhr.responseText || xhr.response;
|
|
if (text) {
|
|
return new TextEncoder().encode(text).buffer;
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
// Server request: check if server already has hash
|
|
function checkServerHash(contentHash) {
|
|
return new Promise((resolve) => {
|
|
try {
|
|
GM_xmlhttpRequest({
|
|
method: "POST",
|
|
url: `${CACHE_SERVER_URL}/api/check-hash`,
|
|
headers: { "Content-Type": "application/json" },
|
|
data: JSON.stringify({ content_hash: contentHash }),
|
|
onload: function(res) {
|
|
if (res.status === 200) {
|
|
try {
|
|
const data = JSON.parse(res.responseText);
|
|
resolve(data.exists);
|
|
} catch (e) {
|
|
resolve(false);
|
|
}
|
|
} else {
|
|
resolve(false);
|
|
}
|
|
},
|
|
onerror: function() {
|
|
resolve(false);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
resolve(false);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Server request: upload capture payload
|
|
function sendToServer(payload) {
|
|
return new Promise((resolve, reject) => {
|
|
try {
|
|
GM_xmlhttpRequest({
|
|
method: "POST",
|
|
url: `${CACHE_SERVER_URL}/api/capture`,
|
|
headers: { "Content-Type": "application/json" },
|
|
data: JSON.stringify(payload),
|
|
onload: function(res) {
|
|
if (res.status === 200) {
|
|
resolve(res.responseText);
|
|
} else {
|
|
reject(new Error(`Server error`));
|
|
}
|
|
},
|
|
onerror: function(err) {
|
|
reject(err);
|
|
}
|
|
});
|
|
} catch (e) {
|
|
reject(e);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Core processing function
|
|
async function processBuffer(url, resourceType, status, buffer, contentType, headers) {
|
|
try {
|
|
if (!shouldCapture(url, contentType)) {
|
|
return;
|
|
}
|
|
|
|
const contentHash = await getSha256(buffer);
|
|
|
|
// Deduplication check: check server cache
|
|
const exists = await checkServerHash(contentHash);
|
|
if (exists) {
|
|
return;
|
|
}
|
|
|
|
// Convert to base64 and send
|
|
const bodyBase64 = arrayBufferToBase64(buffer);
|
|
const payload = {
|
|
url: url,
|
|
resource_type: resourceType,
|
|
status: status,
|
|
timestamp: new Date().toISOString(),
|
|
headers: headers || {},
|
|
body_base64: bodyBase64,
|
|
content_type: contentType
|
|
};
|
|
|
|
await sendToServer(payload);
|
|
} catch (e) {
|
|
// Fail completely silently to prevent stack trace leaks
|
|
}
|
|
}
|
|
|
|
// Helper: Masquerade overridden functions as native code (toString spoofing)
|
|
function masqueradeAsNative(customFn, originalFn) {
|
|
try {
|
|
Object.defineProperty(customFn, 'toString', {
|
|
value: function() {
|
|
return `function ${originalFn.name || 'fetch'}() { [native code] }`;
|
|
},
|
|
writable: false,
|
|
enumerable: false,
|
|
configurable: true
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
// --- INTERCEPT FETCH ---
|
|
const originalFetch = window.fetch;
|
|
const myFetch = async function(...args) {
|
|
const response = await originalFetch.apply(this, args);
|
|
try {
|
|
const url = response.url;
|
|
const contentType = response.headers.get("content-type") || "";
|
|
|
|
if (shouldCapture(url, contentType)) {
|
|
const clone = response.clone();
|
|
(async () => {
|
|
try {
|
|
const buffer = await clone.arrayBuffer();
|
|
const headers = getHeadersDict(clone.headers);
|
|
await processBuffer(url, "fetch", clone.status, buffer, contentType, headers);
|
|
} catch (e) {}
|
|
})();
|
|
}
|
|
} catch (e) {}
|
|
return response;
|
|
};
|
|
|
|
masqueradeAsNative(myFetch, originalFetch);
|
|
try {
|
|
Object.defineProperty(window, 'fetch', {
|
|
value: myFetch,
|
|
writable: true,
|
|
configurable: true,
|
|
enumerable: true
|
|
});
|
|
} catch (e) {
|
|
window.fetch = myFetch;
|
|
}
|
|
|
|
// --- INTERCEPT XHR ---
|
|
const originalOpen = XMLHttpRequest.prototype.open;
|
|
const originalSend = XMLHttpRequest.prototype.send;
|
|
|
|
const myOpen = function(method, url) {
|
|
try {
|
|
this._url = url;
|
|
} catch (e) {}
|
|
return originalOpen.apply(this, arguments);
|
|
};
|
|
|
|
masqueradeAsNative(myOpen, originalOpen);
|
|
try {
|
|
Object.defineProperty(XMLHttpRequest.prototype, 'open', {
|
|
value: myOpen,
|
|
writable: true,
|
|
configurable: true,
|
|
enumerable: true
|
|
});
|
|
} catch (e) {
|
|
XMLHttpRequest.prototype.open = myOpen;
|
|
}
|
|
|
|
const mySend = function() {
|
|
try {
|
|
this.addEventListener('load', function() {
|
|
try {
|
|
const url = this._url;
|
|
const contentType = this.getResponseHeader("content-type") || "";
|
|
if (shouldCapture(url, contentType)) {
|
|
const xhr = this;
|
|
(async () => {
|
|
try {
|
|
const buffer = await getXhrBuffer(xhr);
|
|
if (buffer) {
|
|
const headers = parseHeaders(xhr.getAllResponseHeaders());
|
|
await processBuffer(url, "xhr", xhr.status, buffer, contentType, headers);
|
|
}
|
|
} catch (e) {}
|
|
})();
|
|
}
|
|
} catch (e) {}
|
|
});
|
|
} catch (e) {}
|
|
return originalSend.apply(this, arguments);
|
|
};
|
|
|
|
masqueradeAsNative(mySend, originalSend);
|
|
try {
|
|
Object.defineProperty(XMLHttpRequest.prototype, 'send', {
|
|
value: mySend,
|
|
writable: true,
|
|
configurable: true,
|
|
enumerable: true
|
|
});
|
|
} catch (e) {
|
|
XMLHttpRequest.prototype.send = mySend;
|
|
}
|
|
|
|
// --- INTERCEPT STATIC IMAGES IN THE DOM ---
|
|
function captureImageByUrl(url) {
|
|
try {
|
|
GM_xmlhttpRequest({
|
|
method: "GET",
|
|
url: url,
|
|
responseType: "arraybuffer",
|
|
onload: async function(details) {
|
|
try {
|
|
if (details.status !== 200) return;
|
|
const buffer = details.response;
|
|
if (!buffer) return;
|
|
|
|
const contentType = details.responseHeaders.match(/content-type:\s*(.*)/i)?.[1] || "image/png";
|
|
const headers = parseHeaders(details.responseHeaders);
|
|
await processBuffer(url, "image", details.status, buffer, contentType, headers);
|
|
} catch (e) {}
|
|
}
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
function scanImages() {
|
|
try {
|
|
const images = document.querySelectorAll('img');
|
|
images.forEach(img => {
|
|
const src = img.src;
|
|
if (!src) return;
|
|
|
|
// Focus on STATdx / Elsevier images and skip local or non-related URLs
|
|
const isTarget = src.includes('statdx') || src.includes('elsevier') || src.startsWith('/');
|
|
if (isTarget && !capturedUrls.has(src)) {
|
|
capturedUrls.add(src);
|
|
captureImageByUrl(src);
|
|
}
|
|
});
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Set up MutationObserver to dynamically capture images added by page scripts
|
|
try {
|
|
const observer = new MutationObserver((mutations) => {
|
|
scanImages();
|
|
});
|
|
|
|
// Wait for DOM to be ready to observe
|
|
if (document.body) {
|
|
scanImages();
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
} else {
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
scanImages();
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
});
|
|
}
|
|
} catch (e) {}
|
|
|
|
// Periodic sweep as a backup
|
|
setInterval(scanImages, 4000);
|
|
|
|
})();
|