mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(media-hosting): add multi-provider configurable upload with fallback
Add Random/Preferred/None modes, preferred provider selection, and provider-order fallback. Catbox/Imgur/PostImages on Android via WebView; Electron CDP automation for Imgur/PostImages. Hide upload controls on web runtime.
This commit is contained in:
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
|
||||
|
||||
/** 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'];
|
||||
|
||||
function isDirectMediaUrl(url) {
|
||||
try {
|
||||
const normalized = url.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, postimages)
|
||||
* @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');
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
win.loadURL(recipe.uploadUrl);
|
||||
win.webContents.once('did-finish-load', () => {
|
||||
resolve();
|
||||
});
|
||||
win.webContents.once('did-fail-load', (_, code, desc) => {
|
||||
reject(new Error(`Page load failed: ${code} ${desc}`));
|
||||
});
|
||||
});
|
||||
|
||||
// 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 true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (await checkBlocked()) {
|
||||
throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider})`);
|
||||
}
|
||||
|
||||
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],
|
||||
});
|
||||
|
||||
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) return;
|
||||
const [x1, y1, x2, y2] = model.content;
|
||||
const x = (x1 + x2) / 2;
|
||||
const y = (y1 + y2) / 2;
|
||||
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)};
|
||||
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();
|
||||
}
|
||||
if (url && url.startsWith('http')) return url;
|
||||
} catch (e) {}
|
||||
}
|
||||
return null;
|
||||
})()
|
||||
`;
|
||||
const { result } = await sendCommand('Runtime.evaluate', {
|
||||
expression: code,
|
||||
returnByValue: true,
|
||||
});
|
||||
return result?.value ?? null;
|
||||
};
|
||||
|
||||
const pollIntervalMs = 500;
|
||||
const start = Date.now();
|
||||
let url = null;
|
||||
while (Date.now() - start < recipe.timeoutMs) {
|
||||
if (await checkBlocked()) {
|
||||
throw new Error(`Provider blocked during upload: captcha or challenge (${provider})`);
|
||||
}
|
||||
url = await extractUrl();
|
||||
if (url && isDirectMediaUrl(url)) {
|
||||
break;
|
||||
}
|
||||
url = null;
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
|
||||
if (!url || !isDirectMediaUrl(url)) {
|
||||
throw new Error(`Upload timeout or no direct URL extracted for ${provider} (${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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user