Working
This commit is contained in:
598
content-script.js
Normal file
598
content-script.js
Normal file
@@ -0,0 +1,598 @@
|
||||
"use strict";
|
||||
|
||||
const TARGET_SELECTOR = ".playbar-playback-info-bar.playback-row-section.svelte-11gjrk3";
|
||||
const INJECTED_CONTAINER_ID = "s3-audio-capture-controls";
|
||||
const SPLICE_DOMAIN_RE = /^https?:\/\/([a-z0-9-]+\.)*splice\.com(\/|$)/i;
|
||||
const GRAPHQL_URL_FRAGMENT = "surfaces-graphql.splice.com/graphql";
|
||||
const PROBE_EVENT_SOURCE = "S3_CAPTURE_PROBE";
|
||||
const DEBUG_CAPTURE = true;
|
||||
let knownUrls = new Set();
|
||||
let knownPerformanceUrls = new Set();
|
||||
let observer = null;
|
||||
let captureObserver = null;
|
||||
let performanceObserver = null;
|
||||
let fetchPatched = false;
|
||||
let xhrPatched = false;
|
||||
let probeListenerInstalled = false;
|
||||
|
||||
function isDomainAllowed(url) {
|
||||
return typeof url === "string" && SPLICE_DOMAIN_RE.test(url);
|
||||
}
|
||||
|
||||
function isS3Url(url) {
|
||||
if (typeof url !== "string") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.protocol !== "https:") {
|
||||
return false;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const isAwsHost = host.endsWith(".amazonaws.com") || host.endsWith(".amazonaws.com.cn");
|
||||
const isS3Host = host.includes(".s3.") || host.startsWith("s3.");
|
||||
if (!isAwsHost || !isS3Host) {
|
||||
return false;
|
||||
}
|
||||
const path = parsed.pathname || "";
|
||||
if (!path.includes("/audio_samples/")) {
|
||||
return false;
|
||||
}
|
||||
if (path.endsWith(".wv.json")) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUrl(rawUrl) {
|
||||
try {
|
||||
return new URL(rawUrl, window.location.href).toString();
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function logDebug(message, extra) {
|
||||
if (!DEBUG_CAPTURE) {
|
||||
return;
|
||||
}
|
||||
if (typeof extra === "undefined") {
|
||||
console.debug("[S3 capture]", message);
|
||||
return;
|
||||
}
|
||||
console.debug("[S3 capture]", message, extra);
|
||||
}
|
||||
|
||||
function collectProbeCandidateUrls(payload) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
const keys = ["requestUrl", "responseUrl", "linkedUrl", "url", "blobUrl"];
|
||||
for (const key of keys) {
|
||||
if (typeof payload[key] === "string" && payload[key]) {
|
||||
candidates.push(payload[key]);
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function extractSampleIdFromUrl(url) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const marker = "/audio_samples/";
|
||||
const idx = parsed.pathname.indexOf(marker);
|
||||
if (idx < 0) {
|
||||
return "";
|
||||
}
|
||||
const rest = parsed.pathname.slice(idx + marker.length);
|
||||
const parts = rest.split("/").filter(Boolean);
|
||||
const first = (parts[0] || "").replace(/-scrambled$/i, "");
|
||||
const second = (parts[1] || "").split(".")[0];
|
||||
const hashRe = /^[a-f0-9]{64}$/i;
|
||||
if (hashRe.test(first)) {
|
||||
return first.toLowerCase();
|
||||
}
|
||||
if (hashRe.test(second)) {
|
||||
return second.toLowerCase();
|
||||
}
|
||||
return "";
|
||||
} catch (error) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function recursivelyCollectAudioUrls(value, output) {
|
||||
if (typeof value === "string") {
|
||||
const normalized = normalizeUrl(value);
|
||||
if (normalized && isS3Url(normalized)) {
|
||||
output.add(normalized);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
recursivelyCollectAudioUrls(item, output);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
for (const nested of Object.values(value)) {
|
||||
recursivelyCollectAudioUrls(nested, output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractPreviewContextsFromGraphql(json) {
|
||||
const contexts = [];
|
||||
const items = json &&
|
||||
json.data &&
|
||||
json.data.assetsSearch &&
|
||||
Array.isArray(json.data.assetsSearch.items)
|
||||
? json.data.assetsSearch.items
|
||||
: [];
|
||||
|
||||
for (const item of items) {
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
const sampleId = typeof item.uuid === "string" ? item.uuid.toLowerCase() : "";
|
||||
const files = Array.isArray(item.files) ? item.files : [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file || typeof file !== "object") {
|
||||
continue;
|
||||
}
|
||||
const typeSlug = String(file.asset_file_type_slug || "").toLowerCase();
|
||||
const fileUrl = normalizeUrl(file.url || "");
|
||||
if (typeSlug !== "preview_mp3" || !fileUrl) {
|
||||
continue;
|
||||
}
|
||||
contexts.push({
|
||||
sampleId: sampleId || extractSampleIdFromUrl(fileUrl),
|
||||
previewUrl: fileUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}
|
||||
|
||||
function sendPreviewContexts(contexts) {
|
||||
if (!contexts.length) {
|
||||
return;
|
||||
}
|
||||
browser.runtime.sendMessage({
|
||||
type: "CAPTURE_PREVIEW_CONTEXT",
|
||||
contexts
|
||||
}).catch(() => {
|
||||
// Ignore transient extension messaging failures.
|
||||
});
|
||||
logDebug("Captured preview contexts", contexts);
|
||||
}
|
||||
|
||||
function inspectGraphqlResponse(candidateUrl, response) {
|
||||
if (typeof candidateUrl !== "string" || !candidateUrl.includes(GRAPHQL_URL_FRAGMENT)) {
|
||||
return;
|
||||
}
|
||||
if (!response || typeof response.clone !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
response.clone().json().then((json) => {
|
||||
const contexts = extractPreviewContextsFromGraphql(json);
|
||||
if (contexts.length) {
|
||||
sendPreviewContexts(contexts);
|
||||
}
|
||||
const urls = new Set();
|
||||
recursivelyCollectAudioUrls(json, urls);
|
||||
if (urls.size) {
|
||||
captureUrls(Array.from(urls));
|
||||
}
|
||||
}).catch(() => {
|
||||
// Some GraphQL responses may not be parseable JSON.
|
||||
});
|
||||
}
|
||||
|
||||
function captureUrls(inputUrls) {
|
||||
if (!isDomainAllowed(window.location.href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newUrls = [];
|
||||
for (const rawUrl of inputUrls) {
|
||||
const normalized = normalizeUrl(rawUrl);
|
||||
if (!normalized || !isS3Url(normalized) || knownUrls.has(normalized)) {
|
||||
continue;
|
||||
}
|
||||
knownUrls.add(normalized);
|
||||
newUrls.push(normalized);
|
||||
}
|
||||
|
||||
if (!newUrls.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
browser.runtime.sendMessage({
|
||||
type: "CAPTURE_URLS",
|
||||
urls: newUrls,
|
||||
source: "content_capture"
|
||||
}).catch(() => {
|
||||
// Ignore transient extension context failures.
|
||||
});
|
||||
logDebug("Captured S3 URLs", newUrls);
|
||||
}
|
||||
|
||||
function scanNodeForUrls(root) {
|
||||
if (!(root instanceof Element || root instanceof Document)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlCandidates = [];
|
||||
const selectors = [
|
||||
"a[href]",
|
||||
"audio[src]",
|
||||
"source[src]",
|
||||
"[data-url]",
|
||||
"[data-src]"
|
||||
];
|
||||
|
||||
for (const selector of selectors) {
|
||||
const nodes = root.querySelectorAll(selector);
|
||||
for (const node of nodes) {
|
||||
const attrs = ["href", "src", "data-url", "data-src"];
|
||||
for (const attr of attrs) {
|
||||
const value = node.getAttribute(attr);
|
||||
if (value) {
|
||||
urlCandidates.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
captureUrls(urlCandidates);
|
||||
}
|
||||
|
||||
function patchFetch() {
|
||||
if (fetchPatched || typeof window.fetch !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = function patchedFetch(input, init) {
|
||||
const candidate = typeof input === "string" ? input : input && input.url;
|
||||
if (candidate) {
|
||||
captureUrls([candidate]);
|
||||
}
|
||||
if (init && typeof init === "object" && typeof init.url === "string") {
|
||||
captureUrls([init.url]);
|
||||
}
|
||||
return originalFetch(input, init).then((response) => {
|
||||
if (candidate) {
|
||||
inspectGraphqlResponse(candidate, response);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
};
|
||||
fetchPatched = true;
|
||||
} catch (error) {
|
||||
// Firefox may expose fetch as read-only in content script worlds.
|
||||
fetchPatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
function patchXhr() {
|
||||
if (xhrPatched || !window.XMLHttpRequest) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const originalOpen = window.XMLHttpRequest.prototype.open;
|
||||
window.XMLHttpRequest.prototype.open = function patchedOpen(method, url) {
|
||||
if (typeof url === "string") {
|
||||
captureUrls([url]);
|
||||
}
|
||||
return originalOpen.apply(this, arguments);
|
||||
};
|
||||
xhrPatched = true;
|
||||
} catch (error) {
|
||||
xhrPatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
function capturePerformanceResourceUrls() {
|
||||
if (typeof performance === "undefined" || typeof performance.getEntriesByType !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const resources = performance.getEntriesByType("resource");
|
||||
const candidates = [];
|
||||
|
||||
for (const entry of resources) {
|
||||
if (!entry || typeof entry.name !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (knownPerformanceUrls.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
knownPerformanceUrls.add(entry.name);
|
||||
candidates.push(entry.name);
|
||||
}
|
||||
|
||||
if (candidates.length) {
|
||||
captureUrls(candidates);
|
||||
}
|
||||
}
|
||||
|
||||
function startPerformanceObserver() {
|
||||
if (performanceObserver || typeof PerformanceObserver !== "function") {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
performanceObserver = new PerformanceObserver((list) => {
|
||||
const candidates = [];
|
||||
for (const entry of list.getEntries()) {
|
||||
if (!entry || typeof entry.name !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (knownPerformanceUrls.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
knownPerformanceUrls.add(entry.name);
|
||||
candidates.push(entry.name);
|
||||
}
|
||||
if (candidates.length) {
|
||||
captureUrls(candidates);
|
||||
}
|
||||
});
|
||||
performanceObserver.observe({ type: "resource", buffered: true });
|
||||
} catch (error) {
|
||||
performanceObserver = null;
|
||||
}
|
||||
}
|
||||
|
||||
function createIcon(kind) {
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(ns, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("width", "14");
|
||||
svg.setAttribute("height", "14");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
|
||||
if (kind === "download") {
|
||||
const arrow = document.createElementNS(ns, "path");
|
||||
arrow.setAttribute("d", "M12 3v10m0 0l4-4m-4 4l-4-4");
|
||||
arrow.setAttribute("fill", "none");
|
||||
arrow.setAttribute("stroke", "currentColor");
|
||||
arrow.setAttribute("stroke-width", "2");
|
||||
arrow.setAttribute("stroke-linecap", "round");
|
||||
arrow.setAttribute("stroke-linejoin", "round");
|
||||
|
||||
const tray = document.createElementNS(ns, "path");
|
||||
tray.setAttribute("d", "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2");
|
||||
tray.setAttribute("fill", "none");
|
||||
tray.setAttribute("stroke", "currentColor");
|
||||
tray.setAttribute("stroke-width", "2");
|
||||
tray.setAttribute("stroke-linecap", "round");
|
||||
tray.setAttribute("stroke-linejoin", "round");
|
||||
|
||||
svg.appendChild(arrow);
|
||||
svg.appendChild(tray);
|
||||
return svg;
|
||||
}
|
||||
|
||||
const circle = document.createElementNS(ns, "circle");
|
||||
circle.setAttribute("cx", "12");
|
||||
circle.setAttribute("cy", "12");
|
||||
circle.setAttribute("r", "9");
|
||||
circle.setAttribute("fill", "none");
|
||||
circle.setAttribute("stroke", "currentColor");
|
||||
circle.setAttribute("stroke-width", "2");
|
||||
|
||||
const hand = document.createElementNS(ns, "path");
|
||||
hand.setAttribute("d", "M12 7v5l3 2");
|
||||
hand.setAttribute("fill", "none");
|
||||
hand.setAttribute("stroke", "currentColor");
|
||||
hand.setAttribute("stroke-width", "2");
|
||||
hand.setAttribute("stroke-linecap", "round");
|
||||
hand.setAttribute("stroke-linejoin", "round");
|
||||
|
||||
svg.appendChild(circle);
|
||||
svg.appendChild(hand);
|
||||
return svg;
|
||||
}
|
||||
|
||||
function createButtonMetaItem(iconType, ariaLabel, onClick) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "bordered-item meta-item svelte-11gjrk3";
|
||||
|
||||
const inner = document.createElement("div");
|
||||
inner.className = "meta-wrap svelte-11gjrk3";
|
||||
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "meta-value svelte-11gjrk3 s3-capture-button";
|
||||
button.setAttribute("aria-label", ariaLabel);
|
||||
button.setAttribute("title", ariaLabel);
|
||||
button.appendChild(createIcon(iconType));
|
||||
button.addEventListener("click", onClick);
|
||||
|
||||
inner.appendChild(button);
|
||||
wrapper.appendChild(inner);
|
||||
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function ensureInlineStyles() {
|
||||
if (document.getElementById("s3-audio-capture-style")) {
|
||||
return;
|
||||
}
|
||||
const style = document.createElement("style");
|
||||
style.id = "s3-audio-capture-style";
|
||||
style.textContent = `
|
||||
.s3-capture-button {
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
font: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.s3-capture-button:hover {
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.s3-capture-button svg {
|
||||
display: block;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function injectControls() {
|
||||
const target = document.querySelector(TARGET_SELECTOR);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.querySelector("#" + INJECTED_CONTAINER_ID)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureInlineStyles();
|
||||
|
||||
const controlsRoot = document.createElement("div");
|
||||
controlsRoot.id = INJECTED_CONTAINER_ID;
|
||||
controlsRoot.style.display = "contents";
|
||||
|
||||
const currentButton = createButtonMetaItem("download", "Download current audio", () => {
|
||||
browser.runtime.sendMessage({ type: "DOWNLOAD_LATEST" }).then((result) => {
|
||||
if (!result || !result.ok) {
|
||||
window.alert((result && result.error) || "No URL available for download.");
|
||||
}
|
||||
}).catch(() => {
|
||||
window.alert("Extension message failed.");
|
||||
});
|
||||
});
|
||||
|
||||
const historyButton = createButtonMetaItem("history", "Open captured history", () => {
|
||||
browser.runtime.sendMessage({ type: "OPEN_HISTORY" }).catch(() => {
|
||||
window.alert("Unable to open history.");
|
||||
});
|
||||
});
|
||||
|
||||
controlsRoot.appendChild(currentButton);
|
||||
controlsRoot.appendChild(historyButton);
|
||||
target.appendChild(controlsRoot);
|
||||
}
|
||||
|
||||
function startUiObserver() {
|
||||
if (observer) {
|
||||
observer.disconnect();
|
||||
}
|
||||
observer = new MutationObserver(() => {
|
||||
injectControls();
|
||||
});
|
||||
observer.observe(document.documentElement, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function startCaptureObserver() {
|
||||
if (captureObserver) {
|
||||
captureObserver.disconnect();
|
||||
}
|
||||
|
||||
captureObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const addedNode of mutation.addedNodes) {
|
||||
scanNodeForUrls(addedNode);
|
||||
}
|
||||
if (mutation.type === "attributes" && mutation.target instanceof Element) {
|
||||
scanNodeForUrls(mutation.target);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
captureObserver.observe(document.documentElement, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["href", "src", "data-url", "data-src"]
|
||||
});
|
||||
}
|
||||
|
||||
function installPageProbeListener() {
|
||||
if (probeListenerInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener("message", (event) => {
|
||||
if (event.source !== window) {
|
||||
return;
|
||||
}
|
||||
const data = event.data;
|
||||
if (!data || data.source !== PROBE_EVENT_SOURCE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const kind = typeof data.kind === "string" ? data.kind : "unknown";
|
||||
const payload = data.payload && typeof data.payload === "object" ? data.payload : {};
|
||||
const candidateUrls = collectProbeCandidateUrls(payload);
|
||||
if (candidateUrls.length) {
|
||||
captureUrls(candidateUrls);
|
||||
}
|
||||
logDebug("Probe event: " + kind, payload);
|
||||
});
|
||||
|
||||
probeListenerInstalled = true;
|
||||
}
|
||||
|
||||
function injectPageProbeScript() {
|
||||
if (document.getElementById("s3-capture-page-probe")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const root = document.head || document.documentElement;
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.id = "s3-capture-page-probe";
|
||||
script.src = browser.runtime.getURL("page-probe.js");
|
||||
script.async = false;
|
||||
script.onload = () => {
|
||||
script.remove();
|
||||
logDebug("Injected page probe.");
|
||||
};
|
||||
script.onerror = () => {
|
||||
logDebug("Failed to inject page probe.");
|
||||
};
|
||||
root.appendChild(script);
|
||||
}
|
||||
|
||||
function init() {
|
||||
if (!isDomainAllowed(window.location.href)) {
|
||||
return;
|
||||
}
|
||||
|
||||
installPageProbeListener();
|
||||
injectPageProbeScript();
|
||||
scanNodeForUrls(document);
|
||||
patchFetch();
|
||||
patchXhr();
|
||||
capturePerformanceResourceUrls();
|
||||
startPerformanceObserver();
|
||||
window.setInterval(capturePerformanceResourceUrls, 2000);
|
||||
injectControls();
|
||||
startUiObserver();
|
||||
startCaptureObserver();
|
||||
}
|
||||
|
||||
init();
|
||||
Reference in New Issue
Block a user