mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(media-upload): Electron recipe parity and diagnostics
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user