2026-02-18 18:51:02 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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.
|
2026-02-19 17:49:27 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 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/postimages: 45s).
|
2026-02-18 18:51:02 +08:00
|
|
|
|
*/
|
|
|
|
|
|
import { BrowserWindow } from 'electron';
|
|
|
|
|
|
import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
|
|
|
|
|
|
|
2026-02-19 17:49:27 +08:00
|
|
|
|
/** Poll interval (ms) – parity with Android MediaUploadRecipes.POLL_INTERVAL_MS */
|
|
|
|
|
|
const POLL_INTERVAL_MS = 500;
|
|
|
|
|
|
|
2026-02-18 18:51:02 +08:00
|
|
|
|
/** 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'];
|
|
|
|
|
|
|
2026-02-19 17:49:27 +08:00
|
|
|
|
/** Returns true if the URL appears to point to a direct media file. Exported for tests. */
|
|
|
|
|
|
export function isDirectMediaUrl(url) {
|
2026-02-18 18:51:02 +08:00
|
|
|
|
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);
|
2026-02-19 17:49:27 +08:00
|
|
|
|
if (nodeId) return sel;
|
2026-02-18 18:51:02 +08:00
|
|
|
|
}
|
2026-02-19 17:49:27 +08:00
|
|
|
|
return null;
|
2026-02-18 18:51:02 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-19 17:49:27 +08:00
|
|
|
|
const blockedSel = await checkBlocked();
|
|
|
|
|
|
if (blockedSel) {
|
|
|
|
|
|
throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider}), selector: ${blockedSel}`);
|
2026-02-18 18:51:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-02-18 18:58:36 +08:00
|
|
|
|
const content = model.content;
|
|
|
|
|
|
if (content.length < 8) return;
|
|
|
|
|
|
const x = (content[0] + content[2] + content[4] + content[6]) / 4;
|
|
|
|
|
|
const y = (content[1] + content[3] + content[5] + content[7]) / 4;
|
2026-02-18 18:51:02 +08:00
|
|
|
|
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 start = Date.now();
|
|
|
|
|
|
let url = null;
|
|
|
|
|
|
while (Date.now() - start < recipe.timeoutMs) {
|
2026-02-19 17:49:27 +08:00
|
|
|
|
const blockedDuring = await checkBlocked();
|
|
|
|
|
|
if (blockedDuring) {
|
|
|
|
|
|
throw new Error(`Provider blocked during upload: captcha or challenge (${provider}), selector: ${blockedDuring}`);
|
2026-02-18 18:51:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
url = await extractUrl();
|
|
|
|
|
|
if (url && isDirectMediaUrl(url)) {
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
url = null;
|
2026-02-19 17:49:27 +08:00
|
|
|
|
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
2026-02-18 18:51:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-19 17:49:27 +08:00
|
|
|
|
const elapsed = Date.now() - start;
|
2026-02-18 18:51:02 +08:00
|
|
|
|
if (!url || !isDirectMediaUrl(url)) {
|
2026-02-19 17:49:27 +08:00
|
|
|
|
throw new Error(`Upload timeout or no direct URL extracted for ${provider} (elapsed: ${elapsed}ms, timeout: ${recipe.timeoutMs}ms)`);
|
2026-02-18 18:51:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { url, provider };
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (win && !win.isDestroyed()) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (win.webContents?.debugger?.isAttached?.()) {
|
|
|
|
|
|
win.webContents.debugger.detach();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
/* ignore */
|
|
|
|
|
|
}
|
|
|
|
|
|
win.destroy();
|
|
|
|
|
|
win = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|