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.
|
* Main-process automation for media upload via provider web UIs.
|
||||||
* Uses a hidden BrowserWindow + CDP (DOM.setFileInputFiles) for non-interactive uploads.
|
* Uses a hidden BrowserWindow + CDP (DOM.setFileInputFiles) for non-interactive uploads.
|
||||||
* Fail-fast on blocked indicators (captcha/login). No interactive fallback.
|
* 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 { BrowserWindow } from 'electron';
|
||||||
import { MEDIA_UPLOAD_RECIPES } from './media-upload-recipes.js';
|
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) */
|
/** 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'];
|
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 {
|
try {
|
||||||
const normalized = url.split('?')[0].toLowerCase();
|
const normalized = url.split('?')[0].toLowerCase();
|
||||||
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
return DIRECT_MEDIA_EXTENSIONS.some((ext) => normalized.endsWith(ext));
|
||||||
@@ -89,15 +97,14 @@ export async function automateUploadMedia(options) {
|
|||||||
const checkBlocked = async () => {
|
const checkBlocked = async () => {
|
||||||
for (const sel of recipe.blockedIndicators) {
|
for (const sel of recipe.blockedIndicators) {
|
||||||
const nodeId = await queryOne(sel);
|
const nodeId = await queryOne(sel);
|
||||||
if (nodeId) {
|
if (nodeId) return sel;
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return false;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (await checkBlocked()) {
|
const blockedSel = await checkBlocked();
|
||||||
throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider})`);
|
if (blockedSel) {
|
||||||
|
throw new Error(`Provider blocked: captcha, login, or challenge detected (${provider}), selector: ${blockedSel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileInputNodeId = null;
|
let fileInputNodeId = null;
|
||||||
@@ -181,23 +188,24 @@ export async function automateUploadMedia(options) {
|
|||||||
return result?.value ?? null;
|
return result?.value ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const pollIntervalMs = 500;
|
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
let url = null;
|
let url = null;
|
||||||
while (Date.now() - start < recipe.timeoutMs) {
|
while (Date.now() - start < recipe.timeoutMs) {
|
||||||
if (await checkBlocked()) {
|
const blockedDuring = await checkBlocked();
|
||||||
throw new Error(`Provider blocked during upload: captcha or challenge (${provider})`);
|
if (blockedDuring) {
|
||||||
|
throw new Error(`Provider blocked during upload: captcha or challenge (${provider}), selector: ${blockedDuring}`);
|
||||||
}
|
}
|
||||||
url = await extractUrl();
|
url = await extractUrl();
|
||||||
if (url && isDirectMediaUrl(url)) {
|
if (url && isDirectMediaUrl(url)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
url = null;
|
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)) {
|
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 };
|
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.
|
* 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.
|
* 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
|
* @typedef {Object} ProviderRecipe
|
||||||
* @property {string} uploadUrl - Full URL of the provider's upload page
|
* @property {string} uploadUrl - Full URL of the provider's upload page
|
||||||
* @property {readonly string[]} fileInputSelectorCandidates - CSS selectors for file input (first match wins)
|
* @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']),
|
blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '.login-form', '#recaptcha']),
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
}),
|
}),
|
||||||
|
/* Reconciled with Android MediaUploadRecipes (imgur): file input, success extractors, blocked indicators. */
|
||||||
imgur: Object.freeze({
|
imgur: Object.freeze({
|
||||||
uploadUrl: 'https://imgur.com/upload',
|
uploadUrl: 'https://imgur.com/upload',
|
||||||
fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '[data-file-input]']),
|
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]']),
|
selectorCandidates: Object.freeze(['a[href*="i.imgur.com"]', 'input[value*="i.imgur.com"]', '[class*="copy-link"] input', '[data-link]']),
|
||||||
attribute: 'href',
|
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,
|
timeoutMs: 45_000,
|
||||||
}),
|
}),
|
||||||
|
/* Reconciled with Android MediaUploadRecipes (postimages): file input, success extractors, blocked indicators. */
|
||||||
postimages: Object.freeze({
|
postimages: Object.freeze({
|
||||||
uploadUrl: 'https://postimages.org',
|
uploadUrl: 'https://postimages.org',
|
||||||
fileInputSelectorCandidates: Object.freeze(['input[type="file"]', 'input[type=file]', '#uploadFile', '.fileinput']),
|
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,
|
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