feat(media hosting): add imgbb provider

This commit is contained in:
Tommaso Casaburi
2026-05-01 22:52:54 +07:00
parent e2154b3edf
commit 78e5d58d08
10 changed files with 136 additions and 12 deletions
+32 -2
View File
@@ -29,7 +29,7 @@ export function isDirectMediaUrl(url) {
/** /**
* Run automated upload for a provider. * Run automated upload for a provider.
* @param {Object} options * @param {Object} options
* @param {string} options.provider - Provider id (catbox, imgur) * @param {string} options.provider - Provider id (catbox, imgur, imgbb)
* @param {string} options.filePath - Absolute path to the file to upload * @param {string} options.filePath - Absolute path to the file to upload
* @returns {Promise<{ url: string; provider: string }>} * @returns {Promise<{ url: string; provider: string }>}
* @throws {Error} On missing recipe, blocked indicators, timeout, or invalid URL * @throws {Error} On missing recipe, blocked indicators, timeout, or invalid URL
@@ -129,6 +129,14 @@ export async function automateUploadMedia(options) {
nodeId: fileInputNodeId, nodeId: fileInputNodeId,
files: [filePath], files: [filePath],
}); });
await new Promise((r) => setTimeout(r, 500));
if (recipe.prepareSubmitJs) {
await sendCommand('Runtime.evaluate', {
expression: recipe.prepareSubmitJs,
returnByValue: true,
});
}
let submitNodeId = null; let submitNodeId = null;
for (const sel of recipe.submitSelectorCandidates) { for (const sel of recipe.submitSelectorCandidates) {
@@ -173,6 +181,27 @@ export async function automateUploadMedia(options) {
(function() { (function() {
const selectors = ${JSON.stringify(selectorCandidates)}; const selectors = ${JSON.stringify(selectorCandidates)};
const attr = ${JSON.stringify(attr)}; const attr = ${JSON.stringify(attr)};
function normalizeUrl(value) {
if (!value) return '';
let url = String(value).trim();
if (url.startsWith('//')) url = 'https:' + url;
return url;
}
function hasMediaExtension(url) {
return /\\.(?:jpe?g|png|gif|webp|bmp|avif|mp4|webm|mov|avi|mkv|gifv)(?:[?#].*)?$/i.test(url);
}
function pickDirectMediaUrl(value) {
const text = String(value || '');
const candidates = text.match(/https?:\\/\\/[^\\s"'<>\\[\\]]+/g) || [];
for (const candidate of candidates) {
const normalized = normalizeUrl(candidate);
if (hasMediaExtension(normalized)) return normalized;
}
const normalized = normalizeUrl(text);
if (normalized.startsWith('http') && hasMediaExtension(normalized)) return normalized;
if (normalized.startsWith('http')) return normalized;
return '';
}
for (const sel of selectors) { for (const sel of selectors) {
try { try {
const el = document.querySelector(sel); const el = document.querySelector(sel);
@@ -185,7 +214,8 @@ export async function automateUploadMedia(options) {
} else { } else {
url = (el.getAttribute(attr) || el[attr] || '').trim(); url = (el.getAttribute(attr) || el[attr] || '').trim();
} }
if (url && url.startsWith('http')) return url; const directUrl = pickDirectMediaUrl(url);
if (directUrl) return directUrl;
} catch (e) {} } catch (e) {}
} }
return null; return null;
+27
View File
@@ -155,6 +155,28 @@ describe('media-upload-automation', () => {
expect(fakeWindow.destroy).toHaveBeenCalledOnce(); expect(fakeWindow.destroy).toHaveBeenCalledOnce();
}); });
it('uploads to ImgBB through the hidden BrowserWindow recipe', async () => {
const recipe = MEDIA_UPLOAD_RECIPES.imgbb;
const fakeWindow = createFakeBrowserWindow({
getNodeId: (selector) => {
if (selector === recipe.fileInputSelectorCandidates[0]) return 10;
if (selector === recipe.submitSelectorCandidates[0]) return 20;
return 0;
},
runtimeValues: [true, 'https://i.ibb.co/example/uploaded.png'],
});
electronState.createWindow = () => fakeWindow;
const result = await automateUploadMedia({ provider: 'imgbb', filePath: '/tmp/upload.png' });
expect(result).toEqual({ url: 'https://i.ibb.co/example/uploaded.png', provider: 'imgbb' });
expect(fakeWindow.loadURL).toHaveBeenCalledWith(recipe.uploadUrl);
expect(fakeWindow.webContents.debugger.sendCommand).toHaveBeenCalledWith('DOM.setFileInputFiles', {
nodeId: 10,
files: ['/tmp/upload.png'],
});
});
it('fails fast when a blocked indicator is present before upload begins', async () => { it('fails fast when a blocked indicator is present before upload begins', async () => {
const recipe = MEDIA_UPLOAD_RECIPES.imgur; const recipe = MEDIA_UPLOAD_RECIPES.imgur;
const fakeWindow = createFakeBrowserWindow({ const fakeWindow = createFakeBrowserWindow({
@@ -242,4 +264,9 @@ describe('media-upload-automation + recipes integration', () => {
expect(recipe.successExtractor.selectorCandidates.length).toBeGreaterThanOrEqual(1); expect(recipe.successExtractor.selectorCandidates.length).toBeGreaterThanOrEqual(1);
} }
}); });
it('imgbb success extractor targets direct-media domain', () => {
const imgbbSelectors = MEDIA_UPLOAD_RECIPES.imgbb.successExtractor.selectorCandidates;
expect(imgbbSelectors.some((selector) => selector.includes('i.ibb.co') || selector.includes('html-embed-medium'))).toBe(true);
});
}); });
+28 -5
View File
@@ -4,12 +4,14 @@
* 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 vs Electron differences (documented to prevent drift):
* - Android (MediaUploadRecipes.java): imgur only; no catbox (uses different flow). * - Android (MediaUploadRecipes.java): app uploads use native catbox, plus WebView
* Uses DataTransfer JS injection. * automation for imgbb. The imgur runner is retained for diagnostics/tests only.
* - Electron: catbox/imgur. Uses CDP DOM.setFileInputFiles + submit button click. * Real Uri attempts use the WebView file chooser callback; fixtures/fallback use
* DataTransfer.
* - Electron: catbox/imgur/imgbb. Uses CDP DOM.setFileInputFiles + submit button click.
* Catbox: Electron-only; keep behavior unchanged. * Catbox: Electron-only; keep behavior unchanged.
* - Blocked/success selectors: reconciled with Android for imgur. * - Blocked/success selectors: reconciled with Android for imgbb and imgur.
* - Timeouts: imgur 45s; catbox 30s (Electron-only). * - Timeouts: imgbb 60s; imgur 45s; 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
@@ -18,6 +20,7 @@
* @property {Object} successExtractor - How to extract the result URL from the page * @property {Object} successExtractor - How to extract the result URL from the page
* @property {readonly string[]} successExtractor.selectorCandidates - Selectors for element containing result URL * @property {readonly string[]} successExtractor.selectorCandidates - Selectors for element containing result URL
* @property {'href'|'src'|'value'|'text'} successExtractor.attribute - Attribute or 'text' for textContent * @property {'href'|'src'|'value'|'text'} successExtractor.attribute - Attribute or 'text' for textContent
* @property {string=} prepareSubmitJs - Optional JS to normalize provider controls before clicking submit
* @property {readonly string[]} blockedIndicators - Selectors that indicate captcha/login/challenge (fail immediately if present) * @property {readonly string[]} blockedIndicators - Selectors that indicate captcha/login/challenge (fail immediately if present)
* @property {number} timeoutMs - Max time to wait for upload completion * @property {number} timeoutMs - Max time to wait for upload completion
*/ */
@@ -48,6 +51,26 @@ export const MEDIA_UPLOAD_RECIPES = Object.freeze({
blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '#recaptcha', '.login-form', '.signin', '.login']), blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '#recaptcha', '.login-form', '.signin', '.login']),
timeoutMs: 45_000, timeoutMs: 45_000,
}), }),
imgbb: Object.freeze({
uploadUrl: 'https://imgbb.com/',
fileInputSelectorCandidates: Object.freeze(['input[type="file"]', '#anywhere-upload-input', 'input[data-action="anywhere-upload-input"]']),
submitSelectorCandidates: Object.freeze(['button[data-action="upload"]', 'button.btn.green', 'button[type="submit"]', '[data-action="upload"]']),
successExtractor: Object.freeze({
selectorCandidates: Object.freeze([
'input[name="html-embed-medium"]',
'textarea[name="html-embed-medium"]',
'#uploaded-embed-code-1',
'input[value*="i.ibb.co"]',
'textarea',
'img[src*="i.ibb.co"]',
]),
attribute: 'value',
}),
prepareSubmitJs:
"(function(){var select=document.querySelector('#upload-expiration,select[name=\"upload-expiration\"]');if(select){select.value='';select.dispatchEvent(new Event('change',{bubbles:true}));}return true;})()",
blockedIndicators: Object.freeze(['#challenge', '.captcha', '[data-captcha]', '.g-recaptcha', '#recaptcha', '.login-form', '.signin', '.login']),
timeoutMs: 60_000,
}),
}); });
/** /**
+7
View File
@@ -9,6 +9,7 @@ describe('media-upload-recipes', () => {
it('exports MEDIA_UPLOAD_RECIPES with expected providers', () => { it('exports MEDIA_UPLOAD_RECIPES with expected providers', () => {
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('catbox'); expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('catbox');
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('imgur'); expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('imgur');
expect(Object.keys(MEDIA_UPLOAD_RECIPES)).toContain('imgbb');
}); });
it('validateRecipes passes for current recipes', () => { it('validateRecipes passes for current recipes', () => {
@@ -31,6 +32,12 @@ describe('media-upload-recipes', () => {
expect(MEDIA_UPLOAD_RECIPES.imgur.timeoutMs).toBe(45_000); expect(MEDIA_UPLOAD_RECIPES.imgur.timeoutMs).toBe(45_000);
}); });
it('imgbb targets full image embed codes and has Android parity timeout', () => {
expect(MEDIA_UPLOAD_RECIPES.imgbb.timeoutMs).toBe(60_000);
expect(MEDIA_UPLOAD_RECIPES.imgbb.uploadUrl).toBe('https://imgbb.com/');
expect(MEDIA_UPLOAD_RECIPES.imgbb.successExtractor.selectorCandidates).toContain('input[name="html-embed-medium"]');
});
it('selector fallback order: generic file input before provider-specific', () => { it('selector fallback order: generic file input before provider-specific', () => {
for (const [, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) { for (const [, recipe] of Object.entries(MEDIA_UPLOAD_RECIPES)) {
const first = recipe.fileInputSelectorCandidates[0]; const first = recipe.fileInputSelectorCandidates[0];
@@ -5,6 +5,7 @@ describe('direct-url', () => {
describe('isDirectMediaUrl', () => { describe('isDirectMediaUrl', () => {
it('returns true for image extensions', () => { it('returns true for image extensions', () => {
expect(isDirectMediaUrl('https://example.com/photo.jpg')).toBe(true); expect(isDirectMediaUrl('https://example.com/photo.jpg')).toBe(true);
expect(isDirectMediaUrl('https://i.ibb.co/example/photo.jpg')).toBe(true);
expect(isDirectMediaUrl('https://example.com/photo.jpeg')).toBe(true); expect(isDirectMediaUrl('https://example.com/photo.jpeg')).toBe(true);
expect(isDirectMediaUrl('https://example.com/photo.png')).toBe(true); expect(isDirectMediaUrl('https://example.com/photo.png')).toBe(true);
expect(isDirectMediaUrl('https://example.com/photo.gif')).toBe(true); expect(isDirectMediaUrl('https://example.com/photo.gif')).toBe(true);
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { orchestrateElectronUpload } from '../upload-orchestrator'; import { orchestrateElectronUpload } from '../upload-orchestrator';
import { uploadToCatbox } from '../../utils/catbox-utils'; import { uploadToCatbox } from '../../utils/catbox-utils';
import type { ProviderId } from '../types';
vi.mock('../../utils/catbox-utils', () => ({ vi.mock('../../utils/catbox-utils', () => ({
uploadToCatbox: vi.fn(), uploadToCatbox: vi.fn(),
@@ -11,7 +12,7 @@ function createElectronApiMock() {
isElectron: true, isElectron: true,
copyToClipboard: vi.fn(async () => ({ success: true })), copyToClipboard: vi.fn(async () => ({ success: true })),
getPlatform: vi.fn(async () => ({ platform: 'darwin' as NodeJS.Platform, arch: 'x64', version: 'v20.0.0' })), getPlatform: vi.fn(async () => ({ platform: 'darwin' as NodeJS.Platform, arch: 'x64', version: 'v20.0.0' })),
automateUploadMedia: vi.fn(async () => ({ url: 'https://i.imgur.com/abc.png', provider: 'imgur' as const })), automateUploadMedia: vi.fn(async (options: { provider: ProviderId }) => ({ url: 'https://i.imgur.com/abc.png', provider: options.provider })),
getPathForFile: vi.fn((): string | null => '/tmp/image.png'), getPathForFile: vi.fn((): string | null => '/tmp/image.png'),
}; };
} }
@@ -48,6 +49,21 @@ describe('orchestrateElectronUpload', () => {
}); });
}); });
it('routes ImgBB through Electron automation', async () => {
const electronApi = createElectronApiMock();
electronApi.automateUploadMedia = vi.fn(async () => ({ url: 'https://i.ibb.co/example/image.png', provider: 'imgbb' as const }));
window.electronApi = electronApi;
const file = new File(['x'], 'x.png', { type: 'image/png' });
const url = await orchestrateElectronUpload(file, ['imgbb']);
expect(url).toBe('https://i.ibb.co/example/image.png');
expect(electronApi.automateUploadMedia).toHaveBeenCalledWith({
provider: 'imgbb',
filePath: '/tmp/image.png',
});
});
it('fails with provider attempt details if no file path can be resolved', async () => { it('fails with provider attempt details if no file path can be resolved', async () => {
const electronApi = createElectronApiMock(); const electronApi = createElectronApiMock();
electronApi.getPathForFile = vi.fn((): string | null => null); electronApi.getPathForFile = vi.fn((): string | null => null);
+11
View File
@@ -4,6 +4,8 @@ interface ProviderDefinition {
id: ProviderId; id: ProviderId;
label: string; label: string;
homepageUrl: string; homepageUrl: string;
/** Image URLs used to detect whether this provider is reachable from the user's network. */
availabilityProbeUrls: readonly string[];
/** Runtimes where automated upload is supported (non-web = no interactive fallback) */ /** Runtimes where automated upload is supported (non-web = no interactive fallback) */
supportedRuntimes: readonly MediaHostingRuntime[]; supportedRuntimes: readonly MediaHostingRuntime[];
} }
@@ -14,12 +16,21 @@ export const MEDIA_HOSTING_PROVIDERS: readonly ProviderDefinition[] = [
id: 'catbox', id: 'catbox',
label: 'Catbox', label: 'Catbox',
homepageUrl: 'https://catbox.moe', homepageUrl: 'https://catbox.moe',
availabilityProbeUrls: ['https://catbox.moe/pictures/logo.png', 'https://files.catbox.moe/8ten4y.png'],
supportedRuntimes: ['web', 'electron', 'android'], supportedRuntimes: ['web', 'electron', 'android'],
}, },
{ {
id: 'imgur', id: 'imgur',
label: 'Imgur', label: 'Imgur',
homepageUrl: 'https://imgur.com', homepageUrl: 'https://imgur.com',
availabilityProbeUrls: ['https://s.imgur.com/images/favicon-32x32.png', 'https://i.imgur.com/YpB7qfa.jpg'],
supportedRuntimes: ['electron'],
},
{
id: 'imgbb',
label: 'ImgBB',
homepageUrl: 'https://imgbb.com',
availabilityProbeUrls: ['https://simgbb.com/images/logo.png', 'https://i.ibb.co/7Jsq00V5/spoiler.png'],
supportedRuntimes: ['electron', 'android'], supportedRuntimes: ['electron', 'android'],
}, },
] as const; ] as const;
+1 -1
View File
@@ -1,5 +1,5 @@
/** Supported media hosting provider identifiers */ /** Supported media hosting provider identifiers */
export type ProviderId = 'catbox' | 'imgur'; export type ProviderId = 'catbox' | 'imgur' | 'imgbb';
/** User-facing upload mode */ /** User-facing upload mode */
export type UploadMode = 'random' | 'preferred' | 'none'; export type UploadMode = 'random' | 'preferred' | 'none';
+2 -2
View File
@@ -58,11 +58,11 @@ function resolveElectronFilePath(file: File): string | null {
/** /**
* Uploads a file via a single provider. Catbox uses the web API; * Uploads a file via a single provider. Catbox uses the web API;
* imgur uses Electron automation when available. * imgur/imgbb use Electron automation when available.
*/ */
async function uploadViaProvider(provider: ProviderId, file: File): Promise<string> { async function uploadViaProvider(provider: ProviderId, file: File): Promise<string> {
if (provider === 'catbox') return uploadToCatbox(file); if (provider === 'catbox') return uploadToCatbox(file);
if (provider === 'imgur') { if (provider === 'imgur' || provider === 'imgbb') {
const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia; const fn = typeof window !== 'undefined' && window.electronApi?.automateUploadMedia;
if (fn) { if (fn) {
const filePath = resolveElectronFilePath(file); const filePath = resolveElectronFilePath(file);
@@ -19,14 +19,23 @@ describe('useMediaHostingStore', () => {
it('exports MEDIA_HOSTING_PROVIDERS with ids, labels, homepage URLs, runtime metadata', () => { it('exports MEDIA_HOSTING_PROVIDERS with ids, labels, homepage URLs, runtime metadata', () => {
expect(MEDIA_HOSTING_PROVIDERS).toBeDefined(); expect(MEDIA_HOSTING_PROVIDERS).toBeDefined();
expect(MEDIA_HOSTING_PROVIDERS.length).toBe(2); expect(MEDIA_HOSTING_PROVIDERS.length).toBe(3);
const catbox = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'catbox'); const catbox = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'catbox');
expect(catbox).toEqual({ expect(catbox).toEqual({
id: 'catbox', id: 'catbox',
label: 'Catbox', label: 'Catbox',
homepageUrl: 'https://catbox.moe', homepageUrl: 'https://catbox.moe',
availabilityProbeUrls: ['https://catbox.moe/pictures/logo.png', 'https://files.catbox.moe/8ten4y.png'],
supportedRuntimes: ['web', 'electron', 'android'], supportedRuntimes: ['web', 'electron', 'android'],
}); });
const imgbb = MEDIA_HOSTING_PROVIDERS.find((p) => p.id === 'imgbb');
expect(imgbb).toEqual({
id: 'imgbb',
label: 'ImgBB',
homepageUrl: 'https://imgbb.com',
availabilityProbeUrls: ['https://simgbb.com/images/logo.png', 'https://i.ibb.co/7Jsq00V5/spoiler.png'],
supportedRuntimes: ['electron', 'android'],
});
}); });
it('defaults uploadMode to random and preferredProvider to catbox', () => { it('defaults uploadMode to random and preferredProvider to catbox', () => {