Files
5chan/electron/media-upload-automation.js
T

266 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Main-process automation for media upload via provider web UIs.
* Uses a hidden BrowserWindow + CDP (DOM.setFileInputFiles) for non-interactive uploads.
* Fail-fast on blocked indicators (captcha/login). No interactive fallback.
*
* Diagnostics (parity with Android): selector match/timeout info included in errors
* for debugging. Poll interval 500ms, timeout per recipe matches Android where
* providers overlap (imgur: 45s).
*/
import { BrowserWindow } from 'electron';
import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
/** Poll interval (ms) parity with Android MediaUploadRecipes.POLL_INTERVAL_MS */
const POLL_INTERVAL_MS = 500;
/** File extensions that denote direct media URLs (mirrors src/lib/media-hosting/direct-url.ts) */
const DIRECT_MEDIA_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.webm', '.mp4', '.mov', '.avi', '.mkv', '.gifv', '.swf'];
/** Returns true if the URL appears to point to a direct media file. Exported for tests. */
export function isDirectMediaUrl(url) {
try {
const normalized = url.split('?')[0].split('#')[0].toLowerCase();
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
} catch {
return false;
}
}
/**
* Run automated upload for a provider.
* @param {Object} options
* @param {string} options.provider - Provider id (catbox, imgur, imgbb)
* @param {string} options.filePath - Absolute path to the file to upload
* @returns {Promise<{ url: string; provider: string }>}
* @throws {Error} On missing recipe, blocked indicators, timeout, or invalid URL
*/
export async function automateUploadMedia(options) {
const { provider, filePath } = options;
const recipe = MEDIA_UPLOAD_RECIPES[provider];
if (!recipe) {
throw new Error(`No automation recipe for provider: ${provider}`);
}
let win = null;
try {
win = new BrowserWindow({
show: false,
webPreferences: {
webSecurity: true,
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
},
});
await new Promise((resolve, reject) => {
try {
win.webContents.debugger.attach('1.3');
resolve();
} catch (e) {
reject(e);
}
});
const sendCommand = (method, params = {}) => win.webContents.debugger.sendCommand(method, params);
await sendCommand('DOM.enable');
await sendCommand('Page.enable');
const PAGE_LOAD_TIMEOUT_MS = 30_000;
await new Promise((resolve, reject) => {
let settled = false;
const settle = (fn, arg) => {
if (settled) return;
settled = true;
clearTimeout(timer);
win.webContents.removeListener('did-finish-load', onLoad);
win.webContents.removeListener('did-fail-load', onFail);
fn(arg);
};
const onLoad = () => settle(resolve);
const onFail = (_, code, desc) => settle(reject, new Error(`Page load failed: ${code} ${desc}`));
const timer = setTimeout(() => settle(reject, new Error(`Page load timed out after ${PAGE_LOAD_TIMEOUT_MS}ms for ${recipe.uploadUrl}`)), PAGE_LOAD_TIMEOUT_MS);
win.webContents.once('did-finish-load', onLoad);
win.webContents.once('did-fail-load', onFail);
win.loadURL(recipe.uploadUrl);
});
// Small delay for SPA/JS to settle
await new Promise((r) => setTimeout(r, 1500));
const doc = await sendCommand('DOM.getDocument');
const rootNodeId = doc.root?.nodeId;
if (rootNodeId == null) {
throw new Error('Could not get document root');
}
const queryOne = async (selector) => {
const { nodeId } = await sendCommand('DOM.querySelector', {
nodeId: rootNodeId,
selector,
});
return nodeId || null;
};
const checkBlocked = async () => {
for (const sel of recipe.blockedIndicators) {
const nodeId = await queryOne(sel);
if (nodeId) return sel;
}
return null;
};
const blockedSel = await checkBlocked();
if (blockedSel) {
throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider}), selector: ${blockedSel}`);
}
let fileInputNodeId = null;
for (const sel of recipe.fileInputSelectorCandidates) {
fileInputNodeId = await queryOne(sel);
if (fileInputNodeId) break;
}
if (!fileInputNodeId) {
throw new Error(`No file input found for ${provider}. Tried: ${recipe.fileInputSelectorCandidates.join(', ')}`);
}
await sendCommand('DOM.setFileInputFiles', {
nodeId: fileInputNodeId,
files: [filePath],
});
await new Promise((r) => setTimeout(r, 500));
if (recipe.prepareSubmitJs) {
await sendCommand('Runtime.evaluate', {
expression: recipe.prepareSubmitJs,
returnByValue: true,
});
}
let submitNodeId = null;
for (const sel of recipe.submitSelectorCandidates) {
submitNodeId = await queryOne(sel);
if (submitNodeId) break;
}
if (!submitNodeId) {
throw new Error(`No submit button found for ${provider}. Tried: ${recipe.submitSelectorCandidates.join(', ')}`);
}
const clickNode = async (nodeId) => {
const { model } = await sendCommand('DOM.getBoxModel', { nodeId });
if (!model?.content || model.content.length < 8) {
throw new Error('Cannot click node: box model unavailable (element may be hidden or zero-size)');
}
const content = model.content;
const x = (content[0] + content[2] + content[4] + content[6]) / 4;
const y = (content[1] + content[3] + content[5] + content[7]) / 4;
await sendCommand('Input.dispatchMouseEvent', {
type: 'mousePressed',
x,
y,
button: 'left',
clickCount: 1,
});
await sendCommand('Input.dispatchMouseEvent', {
type: 'mouseReleased',
x,
y,
button: 'left',
clickCount: 1,
});
};
await sendCommand('Input.enable');
await clickNode(submitNodeId);
const extractUrl = async () => {
const { selectorCandidates, attribute } = recipe.successExtractor;
const attr = attribute;
const code = `
(function() {
const selectors = ${JSON.stringify(selectorCandidates)};
const attr = ${JSON.stringify(attr)};
function normalizeUrl(value) {
if (!value) return '';
let url = String(value).trim();
if (url.startsWith('//')) url = 'https:' + url;
return url;
}
function hasMediaExtension(url) {
return /\\.(?:jpe?g|png|gif|webp|bmp|avif|mp4|webm|mov|avi|mkv|gifv)(?:[?#].*)?$/i.test(url);
}
function pickDirectMediaUrl(value) {
const text = String(value || '');
const candidates = text.match(/https?:\\/\\/[^\\s"'<>\\[\\]]+/g) || [];
for (const candidate of candidates) {
const normalized = normalizeUrl(candidate);
if (hasMediaExtension(normalized)) return normalized;
}
const normalized = normalizeUrl(text);
if (normalized.startsWith('http') && hasMediaExtension(normalized)) return normalized;
if (normalized.startsWith('http')) return normalized;
return '';
}
for (const sel of selectors) {
try {
const el = document.querySelector(sel);
if (!el) continue;
let url = '';
if (attr === 'text') {
url = (el.textContent || '').trim();
} else if (attr === 'value') {
url = (el.value || el.getAttribute('value') || '').trim();
} else {
url = (el.getAttribute(attr) || el[attr] || '').trim();
}
const directUrl = pickDirectMediaUrl(url);
if (directUrl) return directUrl;
} catch (e) {}
}
return null;
})()
`;
const { result } = await sendCommand('Runtime.evaluate', {
expression: code,
returnByValue: true,
});
return result?.value ?? null;
};
const start = Date.now();
let url = null;
while (Date.now() - start < recipe.timeoutMs) {
const blockedDuring = await checkBlocked();
if (blockedDuring) {
throw new Error(`Provider blocked during upload: captcha or challenge (${provider}), selector: ${blockedDuring}`);
}
url = await extractUrl();
if (url && isDirectMediaUrl(url)) {
break;
}
url = null;
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
const elapsed = Date.now() - start;
if (!url || !isDirectMediaUrl(url)) {
throw new Error(`Upload timeout or no direct URL extracted for ${provider} (elapsed: ${elapsed}ms, timeout: ${recipe.timeoutMs}ms)`);
}
return { url, provider };
} finally {
if (win && !win.isDestroyed()) {
try {
if (win.webContents?.debugger?.isAttached?.()) {
win.webContents.debugger.detach();
}
} catch (_) {
/* ignore */
}
win.destroy();
win = null;
}
}
}