feat(media-upload): Electron recipe parity and diagnostics

This commit is contained in:
plebeius
2026-02-19 17:49:27 +08:00
parent bbddb9ce17
commit b3e8df318c
4 changed files with 164 additions and 13 deletions
+20 -12
View File
@@ -2,14 +2,22 @@
* 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/postimages: 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'];
function isDirectMediaUrl(url) {
/** 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].toLowerCase();
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
@@ -89,15 +97,14 @@ export async function automateUploadMedia(options) {
const checkBlocked = async () => {
for (const sel of recipe.blockedIndicators) {
const nodeId = await queryOne(sel);
if (nodeId) {
return true;
}
if (nodeId) return sel;
}
return false;
return null;
};
if (await checkBlocked()) {
throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider})`);
const blockedSel = await checkBlocked();
if (blockedSel) {
throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider}), selector: ${blockedSel}`);
}
let fileInputNodeId = null;
@@ -181,23 +188,24 @@ export async function automateUploadMedia(options) {
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})`);
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, pollIntervalMs));
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} (${recipe.timeoutMs}ms)`);
throw new Error(`Upload timeout or no direct URL extracted for ${provider} (elapsed: ${elapsed}ms, timeout: ${recipe.timeoutMs}ms)`);
}
return { url, provider };
+57
View File
@@ -0,0 +1,57 @@
/**
* Unit tests for media-upload-automation (Electron).
* Covers isDirectMediaUrl (URL extraction guard) and recipe usage.
*/
import { describe, expect, it } from 'vitest';
import { isDirectMediaUrl } from './media-upload-automation.js';
import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
describe('media-upload-automation', () => {
describe('isDirectMediaUrl', () => {
it('returns true for image extensions', () => {
expect(isDirectMediaUrl('https://example.com/photo.jpg')).toBe(true);
expect(isDirectMediaUrl('https://i.imgur.com/abc.png')).toBe(true);
expect(isDirectMediaUrl('https://i.postimg.cc/xyz.webp')).toBe(true);
});
it('returns true for video extensions', () => {
expect(isDirectMediaUrl('https://example.com/video.webm')).toBe(true);
expect(isDirectMediaUrl('https://example.com/video.mp4')).toBe(true);
});
it('strips query strings before checking', () => {
expect(isDirectMediaUrl('https://i.imgur.com/abc.png?size=large')).toBe(true);
expect(isDirectMediaUrl('https://imgur.com/page.html?img=photo.jpg')).toBe(false);
});
it('returns false for non-direct URLs (guards against non-media pages)', () => {
expect(isDirectMediaUrl('https://imgur.com/abc123')).toBe(false);
expect(isDirectMediaUrl('https://imgur.com/upload')).toBe(false);
expect(isDirectMediaUrl('https://postimages.org')).toBe(false);
expect(isDirectMediaUrl('')).toBe(false);
});
it('returns false for invalid input', () => {
expect(isDirectMediaUrl(null)).toBe(false);
expect(isDirectMediaUrl(undefined)).toBe(false);
});
});
});
describe('media-upload-automation + recipes integration', () => {
it('imgur and postimages success extractors target direct-media domains', () => {
const imgurSelectors = MEDIA_UPLOAD_RECIPES.imgur.successExtractor.selectorCandidates;
expect(imgurSelectors.some((s) => s.includes('i.imgur.com'))).toBe(true);
const postimagesSelectors = MEDIA_UPLOAD_RECIPES.postimages.successExtractor.selectorCandidates;
expect(postimagesSelectors.some((s) => s.includes('postimg') || s.includes('i.postimg'))).toBe(true);
});
it('all providers have fallback selector chains for file input and submit', () => {
for (const [provider, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) {
expect(recipe.fileInputSelectorCandidates.length).toBeGreaterThanOrEqual(1);
expect(recipe.submitSelectorCandidates.length).toBeGreaterThanOrEqual(1);
expect(recipe.successExtractor.selectorCandidates.length).toBeGreaterThanOrEqual(1);
}
});
});
+45 -1
View File
@@ -3,6 +3,15 @@
* Each recipe defines selectors and behavior for DOM-based upload flows.
* Selectors are candidate-based: try each until one matches. Fail fast on blocked indicators.
*
* Android vs Electron differences (documented to prevent drift):
* - Android (MediaUploadRecipes.java): imgur/postimages only; no catbox (uses different flow).
* Uses WebChromeClient file chooser interception; trigger JS clicks file input.
* - Electron: catbox/imgur/postimages. Uses CDP DOM.setFileInputFiles + submit button click.
* Catbox: Electron-only; keep behavior unchanged.
* - Blocked/success selectors: reconciled with Android for imgur/postimages; Electron may add
* extra candidates (e.g. [data-captcha], .login-form) where DOM differs.
* - Timeouts: imgur/postimages 45s (parity); catbox 30s (Electron-only).
*
* @typedef {Object} ProviderRecipe
* @property {string} uploadUrl - Full URL of the provider's upload page
* @property {readonly string[]} fileInputSelectorCandidates - CSS selectors for file input (first match wins)
@@ -27,6 +36,7 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({
blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '.login-form', '#recaptcha']),
timeoutMs: 30_000,
}),
/* Reconciled with Android MediaUploadRecipes (imgur): file input, success extractors, blocked indicators. */
imgur: Object.freeze({
uploadUrl: 'https://imgur.com/upload',
fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '[data-file-input]']),
@@ -35,9 +45,11 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({
selectorCandidates: Object.freeze(['a[href*="i.imgur.com"]', 'input[value*="i.imgur.com"]', '[class*="copy-link"] input', '[data-link]']),
attribute: 'href',
}),
blockedIndicators: Object.freeze(['#challenge', '.captcha', '.g-recaptcha', '#recaptcha', '.signin', '.login']),
/* Android: .signin, .login. Electron adds: [data-captcha], .login-form for DOM variations. */
blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '#recaptcha', '.login-form', '.signin', '.login']),
timeoutMs: 45_000,
}),
/* Reconciled with Android MediaUploadRecipes (postimages): file input, success extractors, blocked indicators. */
postimages: Object.freeze({
uploadUrl: 'https://postimages.org',
fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '#uploadFile', '.fileinput']),
@@ -50,3 +62,35 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({
timeoutMs: 45_000,
}),
});
/**
* Validates that every provider has required recipe fields: trigger (file input), success extractor,
* blocked indicators, and timeout. Throws if any provider is invalid.
*/
function validateRecipes() {
const required = ['fileInputSelectorCandidates', 'submitSelectorCandidates', 'successExtractor', 'blockedIndicators', 'timeoutMs'];
for (const [provider, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) {
for (const key of required) {
if (!(key in recipe) || recipe[key] == null) {
throw new Error(`Recipe validation failed: ${provider} missing or null: ${key}`);
}
}
const ex = recipe.successExtractor;
if (!Array.isArray(ex?.selectorCandidates) || ex.selectorCandidates.length === 0 || !ex.attribute) {
throw new Error(`Recipe validation failed: ${provider} successExtractor must have non-empty selectorCandidates and attribute`);
}
if (!Array.isArray(recipe.fileInputSelectorCandidates) || recipe.fileInputSelectorCandidates.length === 0) {
throw new Error(`Recipe validation failed: ${provider} fileInputSelectorCandidates must be non-empty array`);
}
if (!Array.isArray(recipe.blockedIndicators) || recipe.blockedIndicators.length === 0) {
throw new Error(`Recipe validation failed: ${provider} blockedIndicators must be non-empty array`);
}
if (typeof recipe.timeoutMs !== 'number' || recipe.timeoutMs <= 0) {
throw new Error(`Recipe validation failed: ${provider} timeoutMs must be positive number`);
}
}
}
validateRecipes();
export { validateRecipes };
+42
View File
@@ -0,0 +1,42 @@
/**
* Tests for media-upload-recipes validation.
* Recipes are validated at module load; these tests verify the validation logic.
*/
import { describe, expect, it } from 'vitest';
import { MEDIA_UPLOAD_RECIPES, validateRecipes } from './media-upload-recipes.js';
describe('media-upload-recipes', () => {
it('exports MEDIA_UPLOAD_RECIPES with expected providers', () => {
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('catbox');
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('imgur');
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('postimages');
});
it('validateRecipes passes for current recipes', () => {
expect(() => validateRecipes()).not.toThrow();
});
it('every provider has trigger, success, blocked selectors and timeout', () => {
for (const [provider, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) {
expect(recipe.fileInputSelectorCandidates?.length).toBeGreaterThan(0);
expect(recipe.submitSelectorCandidates?.length).toBeGreaterThan(0);
expect(recipe.successExtractor?.selectorCandidates?.length).toBeGreaterThan(0);
expect(recipe.successExtractor?.attribute).toBeTruthy();
expect(recipe.blockedIndicators?.length).toBeGreaterThan(0);
expect(typeof recipe.timeoutMs).toBe('number');
expect(recipe.timeoutMs).toBeGreaterThan(0);
}
});
it('imgur and postimages have 45s timeout (parity with Android)', () => {
expect(MEDIA_UPLOAD_RECIPES.imgur.timeoutMs).toBe(45_000);
expect(MEDIA_UPLOAD_RECIPES.postimages.timeoutMs).toBe(45_000);
});
it('selector fallback order: generic file input before provider-specific', () => {
for (const [, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) {
const first = recipe.fileInputSelectorCandidates[0];
expect(first).toMatch(/input\[type\s*=\s*["']?file["']?\]/i);
}
});
});