The "kein Filecode — Server gab leeren Link zurueck" error was treated as a generic upload failure → after retries exhausted, the manager called mark-failed and added the account to _failedAccounts → next batch re-primed with primedFailed=1 → pre-job-swap-blocked because no fallback override exists for a single-account hoster. One server-side flake permanently poisoned the session. It's not an account problem — same account + same file works on a later try. This is a doodstream-backend processing flake (empty CDN form, no fn / no st), the same class as a transient network error: don't blacklist, just fail this file cleanly. - doodstream-upload.js: tag the empty-form throw with err.hosterTransient=true (explicit flag, primary signal — matches the err.accountError / err.fileRejected pattern already used elsewhere). - upload-manager.js: new _isHosterTransientError classifier (flag first, message regex as defensive fallback). In the retry loop: break on first hit (server flake won't clear in 3 s, re-uploading the file 4× is pure bandwidth waste). Post-loop: dedicated branch that emits the final error WITHOUT blacklisting the account — same shape as the existing transient-network branch. - Tests: classifier unit tests (flag path, regex path, negatives) + regression test that proves the account is NOT added to _failedAccounts and mark-failed does NOT fire. Drops the hoster-transient test from ~19 s to ~1.5 ms, confirming the in-loop fast-break works. We now fail fast on this error class instead of retrying — the next-batch manual retry is the recovery path, and the account stays usable for it. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
583 lines
22 KiB
JavaScript
583 lines
22 KiB
JavaScript
const fs = require('fs');
|
|
const path = require('path');
|
|
const crypto = require('crypto');
|
|
const { request } = require('undici');
|
|
|
|
const BASE_URL = 'https://doodstream.com';
|
|
const USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36';
|
|
const UPLOAD_TIMEOUT = 1800000; // 30 min
|
|
|
|
// Cap doodstream's per-hoster debug log alongside the main log files so
|
|
// dev-mode sessions don't accumulate gigabytes of upload trace.
|
|
const { maybeRotateLogFile } = require('./log-rotation');
|
|
const _DOODSTREAM_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
const _DOODSTREAM_LOG_MAX_BACKUPS = 1;
|
|
|
|
// Resolve the log path at write-time. In a packaged build __dirname lives
|
|
// inside app.asar (read-only) — writing there fails silently and we lose every
|
|
// production trace. Prefer Electron's writable userData dir, fall back to the
|
|
// repo root only when running outside Electron (tests / plain node).
|
|
function _doodstreamLogPath() {
|
|
try {
|
|
const { app } = require('electron');
|
|
if (app && typeof app.getPath === 'function') {
|
|
return path.join(app.getPath('userData'), 'doodstream-debug.log');
|
|
}
|
|
} catch { /* not running under Electron */ }
|
|
return path.join(__dirname, '..', 'doodstream-debug.log');
|
|
}
|
|
|
|
function _debugLog(msg) {
|
|
try {
|
|
const logPath = _doodstreamLogPath();
|
|
maybeRotateLogFile(logPath, _DOODSTREAM_LOG_MAX_BYTES, _DOODSTREAM_LOG_MAX_BACKUPS);
|
|
const ts = new Date().toISOString();
|
|
fs.appendFileSync(logPath, `[${ts}] ${msg}\n`);
|
|
} catch {}
|
|
}
|
|
|
|
class DoodstreamUploader {
|
|
constructor() {
|
|
this.cookies = new Map();
|
|
this.sessId = '';
|
|
}
|
|
|
|
_cookieHeader() {
|
|
return Array.from(this.cookies.entries())
|
|
.map(([k, v]) => `${k}=${v}`)
|
|
.join('; ');
|
|
}
|
|
|
|
_parseCookiesFromHeaders(headers) {
|
|
let setCookies;
|
|
if (typeof headers.getSetCookie === 'function') {
|
|
setCookies = headers.getSetCookie();
|
|
} else if (headers['set-cookie']) {
|
|
setCookies = Array.isArray(headers['set-cookie']) ? headers['set-cookie'] : [headers['set-cookie']];
|
|
} else {
|
|
return;
|
|
}
|
|
for (const raw of setCookies) {
|
|
const pair = raw.split(';')[0];
|
|
const eq = pair.indexOf('=');
|
|
if (eq > 0) {
|
|
this.cookies.set(pair.substring(0, eq).trim(), pair.substring(eq + 1).trim());
|
|
}
|
|
}
|
|
}
|
|
|
|
async _fetch(url, opts = {}, _redirectCount = 0) {
|
|
const MAX_REDIRECTS = 10;
|
|
const headers = {
|
|
'User-Agent': USER_AGENT,
|
|
...(opts.headers || {})
|
|
};
|
|
if (this.cookies.size > 0) {
|
|
headers['Cookie'] = this._cookieHeader();
|
|
}
|
|
|
|
// The small discovery/result requests that bookend a multi-minute upload
|
|
// occasionally hit a transient blip ("fetch failed", ECONNRESET, a hung TLS
|
|
// handshake). A blip here shouldn't throw away the whole upload, so retry a
|
|
// few times with short backoff. Each attempt gets its own 20s timeout —
|
|
// Node's fetch has none by default, and a hung socket would otherwise stall
|
|
// the attempt for minutes. The big file upload (undici) is retried at the
|
|
// upload-manager level, not here.
|
|
let res;
|
|
for (let attempt = 1; ; attempt++) {
|
|
const timeoutSignal = AbortSignal.timeout(20000);
|
|
const signal = opts.signal ? AbortSignal.any([opts.signal, timeoutSignal]) : timeoutSignal;
|
|
try {
|
|
res = await fetch(url, { ...opts, headers, redirect: 'manual', signal });
|
|
break;
|
|
} catch (err) {
|
|
if (opts.signal && opts.signal.aborted) throw err; // caller abort: don't retry
|
|
if (attempt >= 3) throw err;
|
|
_debugLog(`_fetch transient (${attempt}/3) ${url}: ${err && err.message}; retry`);
|
|
await new Promise(r => setTimeout(r, 400 * attempt));
|
|
}
|
|
}
|
|
|
|
this._parseCookiesFromHeaders(res.headers);
|
|
|
|
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
try { await res.text(); } catch {}
|
|
if (_redirectCount >= MAX_REDIRECTS) throw new Error('Zu viele Redirects');
|
|
const location = res.headers.get('location');
|
|
if (location) {
|
|
const nextUrl = new URL(location, url).href;
|
|
return this._fetch(nextUrl, { ...opts, method: 'GET', body: undefined }, _redirectCount + 1);
|
|
}
|
|
}
|
|
|
|
return res;
|
|
}
|
|
|
|
/**
|
|
* Login to DoodStream via web form
|
|
*/
|
|
async login(username, password, otp) {
|
|
// GET homepage first to collect cookies
|
|
const homeRes = await this._fetch(BASE_URL);
|
|
await homeRes.text();
|
|
|
|
// POST login via AJAX (op in body, XHR header required for JSON response)
|
|
const loginData = new URLSearchParams({
|
|
op: 'login_ajax',
|
|
login: username,
|
|
password: password,
|
|
loginotp: otp || ''
|
|
});
|
|
|
|
// Use raw fetch with redirect: 'manual' to detect success redirects
|
|
const headers = {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Referer': BASE_URL + '/',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
'User-Agent': USER_AGENT
|
|
};
|
|
if (this.cookies.size > 0) {
|
|
headers['Cookie'] = this._cookieHeader();
|
|
}
|
|
|
|
const res = await fetch(BASE_URL + '/', {
|
|
method: 'POST',
|
|
body: loginData.toString(),
|
|
headers,
|
|
redirect: 'manual'
|
|
});
|
|
|
|
this._parseCookiesFromHeaders(res.headers);
|
|
|
|
// On successful login, server may redirect (3xx) to dashboard
|
|
if ([301, 302, 303, 307, 308].includes(res.status)) {
|
|
try { await res.text(); } catch {}
|
|
// Redirect means login succeeded
|
|
} else {
|
|
const body = await res.text();
|
|
let json;
|
|
try { json = JSON.parse(body); } catch { json = null; }
|
|
|
|
if (json && json.status === 'success') {
|
|
// Explicit success response
|
|
} else if (json && json.message && /otp/i.test(json.message)) {
|
|
// OTP required — signal caller to collect OTP from user
|
|
const err = new Error(`Doodstream Login: ${json.message}`);
|
|
err.otpRequired = true;
|
|
throw err;
|
|
} else if (json && json.status === 'fail') {
|
|
throw new Error(`Doodstream Login: ${json.message || 'Login fehlgeschlagen'}`);
|
|
} else if (body.includes('Dashboard')) {
|
|
// Got dashboard HTML directly — login worked
|
|
} else {
|
|
const msg = (json && json.message) || 'Login fehlgeschlagen';
|
|
throw new Error(`Doodstream Login: ${msg}`);
|
|
}
|
|
}
|
|
|
|
// Extract sess_id from the upload page
|
|
await this._extractSessId();
|
|
}
|
|
|
|
async _extractSessId() {
|
|
const res = await this._fetch(BASE_URL + '/?op=upload');
|
|
const html = await res.text();
|
|
|
|
// Hidden input: <input type="hidden" name="sess_id" value="xxx">
|
|
const hiddenMatch = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
|
|
if (hiddenMatch) {
|
|
this.sessId = hiddenMatch[1];
|
|
return;
|
|
}
|
|
|
|
// Vue component prop or JS: sess_id: "xxx" or sess_id="xxx"
|
|
const sessMatch = html.match(/sess_id['":\s]+['"]([a-zA-Z0-9]+)['"]/);
|
|
if (sessMatch) {
|
|
this.sessId = sessMatch[1];
|
|
return;
|
|
}
|
|
|
|
// Assignment: sess_id = 'xxx'
|
|
const altMatch = html.match(/sess_id\s*=\s*['"]([a-zA-Z0-9]+)['"]/);
|
|
if (altMatch) {
|
|
this.sessId = altMatch[1];
|
|
return;
|
|
}
|
|
|
|
throw new Error('Doodstream: sess_id nicht gefunden nach Login');
|
|
}
|
|
|
|
/**
|
|
* Get upload server URL from web interface
|
|
*/
|
|
async _getUploadServer() {
|
|
// Use the standard upload server endpoint
|
|
const res = await this._fetch(BASE_URL + '/?op=upload_server');
|
|
const text = await res.text();
|
|
const ctype = (res.headers && res.headers.get) ? (res.headers.get('content-type') || '') : '';
|
|
_debugLog(`upload_server: status=${res.status} ctype=${ctype} body(800)=${(text || '').slice(0, 800)}`);
|
|
let json;
|
|
try { json = JSON.parse(text); } catch { json = null; }
|
|
|
|
if (json && json.result && /^https?:\/\//i.test(json.result)) {
|
|
return json.result;
|
|
}
|
|
|
|
// Fallback: try fetching from upload page HTML
|
|
const pageRes = await this._fetch(BASE_URL + '/?op=upload');
|
|
const html = await pageRes.text();
|
|
|
|
// Current doodstream format: the upload server is the action of the
|
|
// multipart upload form, e.g.
|
|
// <form name="file" enctype="multipart/form-data"
|
|
// action="https://xxx.cloudatacdn.com/upload/01?SESSID" ...>
|
|
// <input type="hidden" name="sess_id" value="SESSID">
|
|
// The node is assigned per page-load and the action carries a session token
|
|
// in its query string that matches the page's hidden sess_id. We refresh
|
|
// this.sessId from THIS page so the multipart sess_id field matches the node
|
|
// URL — login-time and node tokens otherwise diverge and the upload comes
|
|
// back with an empty filecode.
|
|
const actionMatch = html.match(/action=["'](https?:\/\/[^"']+\/upload\/[^"']*)["']/i);
|
|
if (actionMatch) {
|
|
const url = actionMatch[1].replace(/&/g, '&'); // un-escape HTML entities in query
|
|
const freshSess = html.match(/name=["']sess_id["'][^>]*value=["']([a-zA-Z0-9]+)["']/);
|
|
if (freshSess) {
|
|
this.sessId = freshSess[1];
|
|
} else {
|
|
_debugLog('upload_server: form action found but no sess_id on page; keeping existing sessId');
|
|
}
|
|
_debugLog(`upload_server: using form action node=${url} sess=${this.sessId}`);
|
|
return url;
|
|
}
|
|
|
|
// Legacy fallback: srv_url JS variable (older doodstream theme).
|
|
const srvMatch = html.match(/srv_url['":\s]+['"]?(https?:\/\/[^'">\s]+)['"]?/i);
|
|
if (srvMatch) return srvMatch[1];
|
|
|
|
// No upload server could be extracted. We MUST NOT silently fall back to a
|
|
// hardcoded node: that node is stale and accepts the bytes but returns an
|
|
// empty form (no filecode) — so the user wastes ~90s uploading 95 MB into a
|
|
// dead end and gets a cryptic "kein Filecode" 90s later. Fail fast and put
|
|
// the raw responses in the error so the real format change is diagnosable.
|
|
const urlHints = (html.match(/https?:\/\/[^'">\s]+/g) || []).slice(0, 4).join(' , ');
|
|
_debugLog(`upload_server: NO SERVER. upload-page html(2000)=${(html || '').slice(0, 2000)}`);
|
|
throw new Error(
|
|
`Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geaendert?). ` +
|
|
`op=upload_server status=${res.status} ctype=${ctype} body=${(text || '').slice(0, 300)} ` +
|
|
`| upload-page URL-Treffer: ${urlHints || 'keine'}`
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Upload file using web session
|
|
*/
|
|
async upload(filePath, progressCb, signal, throttle) {
|
|
const fileName = path.basename(filePath);
|
|
const fileSize = fs.statSync(filePath).size;
|
|
|
|
// Get upload server
|
|
const uploadUrl = await this._getUploadServer();
|
|
// Remember which CDN node handled this upload so a later parse failure can
|
|
// report it — failures sometimes correlate with a specific node.
|
|
this._lastUploadUrl = uploadUrl;
|
|
|
|
// Build multipart form
|
|
const boundary = `----WebKitFormBoundary${crypto.randomBytes(16).toString('hex')}`;
|
|
|
|
// Build form parts
|
|
let preamble = '';
|
|
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="sess_id"\r\n\r\n${this.sessId}\r\n`;
|
|
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="utype"\r\n\r\nreg\r\n`;
|
|
const safeFileName = fileName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
preamble += `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${safeFileName}"\r\nContent-Type: application/octet-stream\r\n\r\n`;
|
|
|
|
const epilogue = `\r\n--${boundary}--\r\n`;
|
|
const preambleBuf = Buffer.from(preamble, 'utf-8');
|
|
const epilogueBuf = Buffer.from(epilogue, 'utf-8');
|
|
const totalSize = preambleBuf.length + fileSize + epilogueBuf.length;
|
|
|
|
const CHUNK_SIZE = 256 * 1024;
|
|
let bytesRead = 0;
|
|
|
|
async function* generate() {
|
|
yield preambleBuf;
|
|
const fileStream = fs.createReadStream(filePath, { highWaterMark: CHUNK_SIZE });
|
|
for await (const chunk of fileStream) {
|
|
if (signal && signal.aborted) throw new Error('Aborted');
|
|
if (throttle) await throttle.consume(chunk.length, signal);
|
|
bytesRead += chunk.length;
|
|
yield chunk;
|
|
if (progressCb) progressCb(bytesRead, fileSize);
|
|
}
|
|
yield epilogueBuf;
|
|
}
|
|
|
|
let uploadRes;
|
|
try {
|
|
uploadRes = await request(uploadUrl, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
'Content-Length': String(totalSize),
|
|
'User-Agent': USER_AGENT,
|
|
'Cookie': this._cookieHeader()
|
|
},
|
|
body: generate(),
|
|
signal,
|
|
bodyTimeout: UPLOAD_TIMEOUT,
|
|
headersTimeout: 60000
|
|
});
|
|
} catch (err) {
|
|
// Label which phase failed so a future "fetch failed"/"terminated" is
|
|
// attributable to the big upload POST vs the small bookend requests. The
|
|
// original message is preserved as a substring so upload-manager's
|
|
// transient classification still matches. NOTE: undici may surface
|
|
// "terminated"/"other side closed", which are not yet in that transient
|
|
// list — revisit if logs show them.
|
|
const mb = Math.round(bytesRead / 1048576);
|
|
throw new Error(`Doodstream Upload-POST (${mb} MB an ${uploadUrl}): ${err && err.message ? err.message : err}`);
|
|
}
|
|
|
|
const statusCode = uploadRes.statusCode;
|
|
_debugLog(`Upload response status: ${statusCode}`);
|
|
|
|
// Handle redirects from upload server (undici doesn't follow them)
|
|
if ([301, 302, 303, 307, 308].includes(statusCode)) {
|
|
const location = uploadRes.headers['location'];
|
|
try { await uploadRes.body.text(); } catch {}
|
|
_debugLog(`Upload redirect to: ${location}`);
|
|
if (location) {
|
|
return this._handleUploadResult(location);
|
|
}
|
|
}
|
|
|
|
const resText = await uploadRes.body.text();
|
|
_debugLog(`Upload response body (first 500): ${resText.slice(0, 500)}`);
|
|
|
|
if (statusCode >= 400) {
|
|
let payload;
|
|
try { payload = JSON.parse(resText); } catch {}
|
|
const msg = payload && payload.msg ? payload.msg : resText.slice(0, 200);
|
|
throw new Error(`Doodstream Upload HTTP ${statusCode}: ${msg}`);
|
|
}
|
|
|
|
return this._parseUploadResponse(resText);
|
|
}
|
|
|
|
/**
|
|
* Follow a redirect URL from upload server and extract filecode
|
|
*/
|
|
async _handleUploadResult(url) {
|
|
_debugLog(`Following upload result URL: ${url}`);
|
|
const res = await this._fetch(url);
|
|
const html = await res.text();
|
|
_debugLog(`Result page (first 500): ${html.slice(0, 500)}`);
|
|
return this._parseUploadResponse(html);
|
|
}
|
|
|
|
/**
|
|
* Extract hidden form fields from HTML (handles various attribute orders)
|
|
*/
|
|
_extractHiddenFields(html) {
|
|
const fields = {};
|
|
// Textarea fields: <textarea name="op">upload_result</textarea>
|
|
const ta = /<textarea[^>]*name=['"]([^'"]+)['"][^>]*>([\s\S]*?)<\/textarea>/gi;
|
|
let m;
|
|
while ((m = ta.exec(html)) !== null) fields[m[1]] = m[2].trim();
|
|
// Input hidden fields
|
|
const p1 = /<input[^>]*type=['"]hidden['"][^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
|
|
while ((m = p1.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
|
|
const p2 = /<input[^>]*name=['"]([^'"]+)['"][^>]*value=['"]([^'"]*)['"]/gi;
|
|
while ((m = p2.exec(html)) !== null) { if (!fields[m[1]]) fields[m[1]] = m[2]; }
|
|
const p3 = /<input[^>]*value=['"]([^'"]*)['"]\s[^>]*name=['"]([^'"]+)['"]/gi;
|
|
while ((m = p3.exec(html)) !== null) { if (!fields[m[2]]) fields[m[2]] = m[1]; }
|
|
return fields;
|
|
}
|
|
|
|
/**
|
|
* Parse filecode from upload server response (JSON or HTML)
|
|
*/
|
|
async _parseUploadResponse(resText) {
|
|
// 1. Try JSON
|
|
let payload;
|
|
try { payload = JSON.parse(resText); } catch {}
|
|
|
|
if (payload) {
|
|
return this._extractFromJson(payload);
|
|
}
|
|
|
|
// 2. Try filecode directly in HTML
|
|
const code = this._findFilecodeInHtml(resText);
|
|
if (code) {
|
|
_debugLog(`Found filecode in HTML: ${code}`);
|
|
return this._buildResult(code);
|
|
}
|
|
|
|
// 3. Parse HTML form (XFileSharing two-step upload)
|
|
const hiddenFields = this._extractHiddenFields(resText);
|
|
_debugLog(`Hidden fields: ${JSON.stringify(hiddenFields)}`);
|
|
|
|
// Check if filecode is already in hidden fields
|
|
const fnCode = hiddenFields.fn || hiddenFields.filecode || hiddenFields.file_code;
|
|
if (fnCode && fnCode.length >= 8) {
|
|
_debugLog(`Filecode from hidden field 'fn': ${fnCode}`);
|
|
// We still need to submit the form so doodstream registers the file
|
|
// But the filecode is the 'fn' value
|
|
}
|
|
|
|
// XFileSharing standard: form with op=upload_result, fn, st
|
|
// Always submit to doodstream.com, not to CDN
|
|
if (hiddenFields.fn || hiddenFields.op === 'upload_result') {
|
|
// Ensure op=upload_result is set
|
|
if (!hiddenFields.op) hiddenFields.op = 'upload_result';
|
|
|
|
_debugLog(`Submitting upload_result to ${BASE_URL}/ with fields: ${JSON.stringify(hiddenFields)}`);
|
|
const formData = new URLSearchParams(hiddenFields);
|
|
let followText = '';
|
|
try {
|
|
const followRes = await this._fetch(BASE_URL + '/', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Referer': BASE_URL + '/'
|
|
},
|
|
body: formData.toString()
|
|
});
|
|
followText = await followRes.text();
|
|
} catch (err) {
|
|
// The file already uploaded to the CDN; this POST only registers it on
|
|
// doodstream's side. If it fails transiently (even after _fetch's own
|
|
// retries) but we already hold the filecode, the upload succeeded from
|
|
// the user's view — return it rather than discarding a done upload.
|
|
if (fnCode && fnCode.length >= 8) {
|
|
_debugLog(`upload_result submit failed (${err && err.message}); using fn ${fnCode}`);
|
|
return this._buildResult(fnCode);
|
|
}
|
|
throw err;
|
|
}
|
|
_debugLog(`upload_result response (first 500): ${followText.slice(0, 500)}`);
|
|
|
|
// Try to find filecode in result page
|
|
const resultCode = this._findFilecodeInHtml(followText);
|
|
if (resultCode) {
|
|
return this._buildResult(resultCode);
|
|
}
|
|
|
|
// If we had fn from hidden fields, use that as filecode
|
|
if (fnCode && fnCode.length >= 8) {
|
|
return this._buildResult(fnCode);
|
|
}
|
|
|
|
// Try download URL pattern in result page
|
|
const dlMatch = followText.match(/https?:\/\/[a-z0-9.]+\/d\/([a-zA-Z0-9]+)/i);
|
|
if (dlMatch) {
|
|
return this._buildResult(dlMatch[1]);
|
|
}
|
|
|
|
// No filecode anywhere. Surface WHY: XFileSharing puts the real reason
|
|
// in the `st` field (anything other than "OK" means the backend refused
|
|
// the file — copyright/hash match, duplicate, size, quota, …). The
|
|
// download link being empty while the page structure is unchanged points
|
|
// at doodstream's backend, not at a parsing bug on our side.
|
|
const st = hiddenFields.st || '';
|
|
const fnInfo = fnCode ? `"${fnCode}"(len ${fnCode.length})` : 'fehlt/leer';
|
|
const node = this._lastUploadUrl || '?';
|
|
_debugLog(`No filecode. st=${st} fn=${fnInfo} node=${node} CDN-body=${(resText || '').slice(0, 400)}`);
|
|
if (st && st !== 'OK') {
|
|
throw new Error(`Doodstream lehnt Datei ab (Server-Status: ${st}). CDN=${node}`);
|
|
}
|
|
// Empty form (no fn, no st) is a doodstream-side processing flake — same
|
|
// account + same file works on a later attempt. Tag it explicitly so the
|
|
// upload-manager classifies this as a hoster-transient error and does NOT
|
|
// blacklist the account (otherwise one of these flakes poisons the whole
|
|
// session and later batches hit `pre-job-swap-blocked` for no fault of
|
|
// the account). The flag is the primary signal; the message text is a
|
|
// belt-and-suspenders regex fallback in the classifier.
|
|
const emptyLinkErr = new Error(`Doodstream Upload: kein Filecode — Server gab leeren Link zurueck (st=${st || '?'}, fn=${fnInfo}, CDN=${node}). CDN-Antwort: ${(resText || '').slice(0, 200)}`);
|
|
emptyLinkErr.hosterTransient = true;
|
|
throw emptyLinkErr;
|
|
}
|
|
|
|
// 4. Fallback: follow form action as-is (for non-XFS forms)
|
|
const formAction = resText.match(/<form[^>]*action=['"]([^'"]+)['"]/i);
|
|
if (formAction) {
|
|
_debugLog(`Fallback: following form action ${formAction[1]}`);
|
|
const formData = new URLSearchParams(hiddenFields);
|
|
const followRes = await this._fetch(formAction[1], {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'Referer': BASE_URL + '/'
|
|
},
|
|
body: formData.toString()
|
|
});
|
|
const followText = await followRes.text();
|
|
_debugLog(`Fallback response (first 500): ${followText.slice(0, 500)}`);
|
|
|
|
const fallbackCode = this._findFilecodeInHtml(followText);
|
|
if (fallbackCode) return this._buildResult(fallbackCode);
|
|
|
|
// Check if fn was in original hidden fields
|
|
if (fnCode && fnCode.length >= 8) return this._buildResult(fnCode);
|
|
|
|
throw new Error(`Doodstream Upload: Redirect-Antwort ungueltig (${followText.slice(0, 150)})`);
|
|
}
|
|
|
|
throw new Error(`Doodstream Upload: Keine gueltige Antwort (Body: ${resText.slice(0, 150)})`);
|
|
}
|
|
|
|
/**
|
|
* Search for filecode patterns in HTML
|
|
*/
|
|
_findFilecodeInHtml(html) {
|
|
// filecode: "xxx" or filecode = "xxx"
|
|
const m1 = html.match(/filecode['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i);
|
|
if (m1) return m1[1];
|
|
// file_code: "xxx"
|
|
const m2 = html.match(/file_code['":\s]+['"]([a-zA-Z0-9]{8,})['"]/i);
|
|
if (m2) return m2[1];
|
|
// Download URL pattern: /d/FILECODE
|
|
const m3 = html.match(/\/d\/([a-zA-Z0-9]{8,})/);
|
|
if (m3) return m3[1];
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract result from JSON payload
|
|
*/
|
|
_extractFromJson(payload) {
|
|
if (payload.status && Number(payload.status) !== 200 && payload.msg) {
|
|
throw new Error(`Doodstream Upload: ${payload.msg}`);
|
|
}
|
|
|
|
let item = null;
|
|
const result = payload.result;
|
|
if (Array.isArray(result) && result.length > 0) {
|
|
item = result[0];
|
|
} else if (typeof result === 'object' && result) {
|
|
item = result;
|
|
}
|
|
|
|
if (!item) {
|
|
throw new Error(`Doodstream Upload fehlgeschlagen: ${payload.msg || JSON.stringify(payload).slice(0, 150)}`);
|
|
}
|
|
|
|
const fileCode = item.filecode || item.file_code || '';
|
|
return {
|
|
download_url: item.download_url || item.protected_dl || (fileCode ? `https://doodstream.com/d/${fileCode}` : null),
|
|
embed_url: item.protected_embed || (fileCode ? `https://doodstream.com/e/${fileCode}` : null),
|
|
file_code: fileCode
|
|
};
|
|
}
|
|
|
|
_buildResult(fileCode) {
|
|
return {
|
|
download_url: `https://doodstream.com/d/${fileCode}`,
|
|
embed_url: `https://doodstream.com/e/${fileCode}`,
|
|
file_code: fileCode
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = DoodstreamUploader;
|