mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
266 lines
9.0 KiB
JavaScript
266 lines
9.0 KiB
JavaScript
/**
|
||
* 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;
|
||
}
|
||
}
|
||
}
|