Compare commits
3 Commits
35314ee3ed
...
6286bca7c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6286bca7c6 | ||
|
|
84c48ad7d6 | ||
|
|
329f768e2b |
@ -40,6 +40,7 @@ class DoodstreamUploader {
|
|||||||
constructor() {
|
constructor() {
|
||||||
this.cookies = new Map();
|
this.cookies = new Map();
|
||||||
this.sessId = '';
|
this.sessId = '';
|
||||||
|
this.apiKey = ''; // optionally derived from the logged-in session (deriveApiKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
_cookieHeader() {
|
_cookieHeader() {
|
||||||
@ -577,6 +578,85 @@ class DoodstreamUploader {
|
|||||||
file_code: fileCode
|
file_code: fileCode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull candidate API-key tokens out of a logged-in settings page. We do NOT
|
||||||
|
* rely on knowing doodstream's exact (cookie-gated, unseen) settings DOM —
|
||||||
|
* instead we gather every plausible long token from form-field values and
|
||||||
|
* element contents, ranked so tokens near an "api" mention are tried first.
|
||||||
|
* The caller validates each against the official API, so a wrong guess is
|
||||||
|
* harmless (it just fails validation). Returned newest-/most-likely-first.
|
||||||
|
*/
|
||||||
|
_extractApiKeyCandidates(html) {
|
||||||
|
if (!html) return [];
|
||||||
|
const cands = new Set();
|
||||||
|
const patterns = [
|
||||||
|
/value=["']([A-Za-z0-9]{20,})["']/gi, // <input value="KEY">
|
||||||
|
/<(?:textarea|code|span|pre|input)[^>]*>\s*([A-Za-z0-9]{20,})\s*</gi, // <textarea>KEY</textarea>
|
||||||
|
/\b(?:api[_-]?key|apikey)\b["':\s=>]*["']?([A-Za-z0-9]{20,})/gi // api_key: "KEY"
|
||||||
|
];
|
||||||
|
for (const re of patterns) {
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(html)) !== null) cands.add(m[1]);
|
||||||
|
}
|
||||||
|
// Rank tokens whose preceding context mentions "api" ahead of the rest.
|
||||||
|
return [...cands]
|
||||||
|
.map(t => {
|
||||||
|
const idx = html.indexOf(t);
|
||||||
|
const ctx = html.slice(Math.max(0, idx - 160), idx).toLowerCase();
|
||||||
|
return { t, near: /api/.test(ctx) ? 0 : 1 };
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.near - b.near)
|
||||||
|
.map(s => s.t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a candidate key against the official API. Only the account's real
|
||||||
|
* key returns status 200, so this is what makes the brute-force extraction
|
||||||
|
* safe regardless of the settings-page markup.
|
||||||
|
*/
|
||||||
|
async _validateApiKey(key) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`https://doodapi.co/api/account/info?key=${encodeURIComponent(key)}`, {
|
||||||
|
method: 'GET', redirect: 'follow', signal: AbortSignal.timeout(15000)
|
||||||
|
});
|
||||||
|
const json = await res.json().catch(() => null);
|
||||||
|
return !!(json && Number(json.status) === 200);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the account's doodapi API key from the logged-in web session, so a
|
||||||
|
* login-only account can upload via the reliable JSON API (which returns the
|
||||||
|
* filecode directly) instead of the fragile web upload form. Best-effort:
|
||||||
|
* returns null if no valid key can be found, and the caller falls back to the
|
||||||
|
* web-form upload. Requires login() to have run first (needs the cookies).
|
||||||
|
*/
|
||||||
|
async deriveApiKey() {
|
||||||
|
if (this.apiKey) return this.apiKey;
|
||||||
|
let html = '';
|
||||||
|
for (const page of ['/?op=my_account', '/settings', '/?op=profile']) {
|
||||||
|
try {
|
||||||
|
const res = await this._fetch(BASE_URL + page);
|
||||||
|
const text = await res.text();
|
||||||
|
if (text && /api[\s_-]?key/i.test(text)) { html = text; break; }
|
||||||
|
if (text && !html) html = text;
|
||||||
|
} catch { /* try next page */ }
|
||||||
|
}
|
||||||
|
const candidates = this._extractApiKeyCandidates(html);
|
||||||
|
// Cap validation calls (rate limit 10/s; settings page yields few tokens).
|
||||||
|
for (const key of candidates.slice(0, 15)) {
|
||||||
|
if (await this._validateApiKey(key)) {
|
||||||
|
this.apiKey = key;
|
||||||
|
_debugLog(`api-key derive: validated key (len ${key.length})`);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_debugLog(`api-key derive: ${candidates.length} candidate(s), none validated. settings html(2500)=${(html || '').slice(0, 2500)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = DoodstreamUploader;
|
module.exports = DoodstreamUploader;
|
||||||
|
|||||||
@ -40,6 +40,7 @@ class UploadManager extends EventEmitter {
|
|||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
this._failedAccounts = new Map(); // hoster -> Set of failed accountIds
|
||||||
this._accountOverrides = new Map(); // hoster -> fallback account object
|
this._accountOverrides = new Map(); // hoster -> fallback account object
|
||||||
|
this._doodApiKeyCache = new Map(); // accountId/username -> derived doodstream API key ('' = tried, none)
|
||||||
}
|
}
|
||||||
|
|
||||||
switchAccount(hoster, fallbackAccount) {
|
switchAccount(hoster, fallbackAccount) {
|
||||||
@ -265,6 +266,7 @@ class UploadManager extends EventEmitter {
|
|||||||
this.activeJobs.clear();
|
this.activeJobs.clear();
|
||||||
this.jobAbortControllers.clear();
|
this.jobAbortControllers.clear();
|
||||||
this.cancelledJobIds.clear();
|
this.cancelledJobIds.clear();
|
||||||
|
this._doodApiKeyCache.clear(); // re-derive doodstream keys fresh each batch
|
||||||
this.semaphores = {};
|
this.semaphores = {};
|
||||||
this.globalSemaphore = null;
|
this.globalSemaphore = null;
|
||||||
this.globalThrottle = null;
|
this.globalThrottle = null;
|
||||||
@ -871,6 +873,18 @@ class UploadManager extends EventEmitter {
|
|||||||
await voe.login(task.username, task.password);
|
await voe.login(task.username, task.password);
|
||||||
return voe.upload(task.file, progressCb, signal, throttle);
|
return voe.upload(task.file, progressCb, signal, throttle);
|
||||||
} else if (task.hoster === 'doodstream.com' && task.username) {
|
} else if (task.hoster === 'doodstream.com' && task.username) {
|
||||||
|
// Login-path reliability fix: the web-form upload returns the filecode in
|
||||||
|
// an HTML form that comes back empty for large files (doodstream backend
|
||||||
|
// registration timeout). Derive the account's API key from the logged-in
|
||||||
|
// session ONCE per batch and upload via the official API instead — it
|
||||||
|
// returns result[0].filecode directly and has no empty-form failure mode.
|
||||||
|
// Falls back to the web-form upload if no valid key can be derived.
|
||||||
|
const apiKey = await this._resolveDoodstreamApiKey(task);
|
||||||
|
if (apiKey) {
|
||||||
|
this._rotLog('doodstream-via-api', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
|
return uploadFile('doodstream.com', task.file, apiKey, progressCb, signal, throttle);
|
||||||
|
}
|
||||||
|
this._rotLog('doodstream-via-web', { accountId: task.accountId, fileName: path.basename(task.file) });
|
||||||
const dood = new DoodstreamUploader();
|
const dood = new DoodstreamUploader();
|
||||||
await dood.login(task.username, task.password);
|
await dood.login(task.username, task.password);
|
||||||
return dood.upload(task.file, progressCb, signal, throttle);
|
return dood.upload(task.file, progressCb, signal, throttle);
|
||||||
@ -882,6 +896,28 @@ class UploadManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve (and cache per batch) the doodstream API key for a login-only
|
||||||
|
// account by logging in once and scraping+validating it from the session.
|
||||||
|
// Returns the key string, or '' when none could be derived (cached either way
|
||||||
|
// so a 40-file batch logs in + derives ONCE, not per file). The empty-string
|
||||||
|
// sentinel distinguishes "tried, none" from "not yet tried" (undefined).
|
||||||
|
async _resolveDoodstreamApiKey(task) {
|
||||||
|
const cacheKey = task.accountId || task.username;
|
||||||
|
const cached = this._doodApiKeyCache.get(cacheKey);
|
||||||
|
if (cached !== undefined) return cached || null;
|
||||||
|
|
||||||
|
let key = '';
|
||||||
|
try {
|
||||||
|
const probe = new DoodstreamUploader();
|
||||||
|
await probe.login(task.username, task.password);
|
||||||
|
key = (await probe.deriveApiKey()) || '';
|
||||||
|
} catch {
|
||||||
|
key = '';
|
||||||
|
}
|
||||||
|
this._doodApiKeyCache.set(cacheKey, key);
|
||||||
|
return key || null;
|
||||||
|
}
|
||||||
|
|
||||||
_emitProgress(uploadId, fileName, hoster, data) {
|
_emitProgress(uploadId, fileName, hoster, data) {
|
||||||
this.emit('progress', { uploadId, fileName, hoster, ...data });
|
this.emit('progress', { uploadId, fileName, hoster, ...data });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "multi-hoster-uploader",
|
"name": "multi-hoster-uploader",
|
||||||
"version": "3.3.30",
|
"version": "3.3.31",
|
||||||
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -47,3 +47,10 @@
|
|||||||
**Verifiziert:** Reale electron-config.json: 4 preview-Jobs, alle 4 Keys im Log -> alte Logik entfernt 4/4. Neue Logik (nur status==='done' droppen) entfernt 0/4.
|
**Verifiziert:** Reale electron-config.json: 4 preview-Jobs, alle 4 Keys im Log -> alte Logik entfernt 4/4. Neue Logik (nur status==='done' droppen) entfernt 0/4.
|
||||||
**Regel:** Auto-Cleanup/Dedup darf NIE pending/actionable User-Arbeit löschen. Nur genuin abgeschlossene ('done') Jobs decluttern. Lifetime-Logs sind Historie, nicht Session-Fortschritt — nicht als "schon erledigt"-Quelle für pending Jobs missbrauchen.
|
**Regel:** Auto-Cleanup/Dedup darf NIE pending/actionable User-Arbeit löschen. Nur genuin abgeschlossene ('done') Jobs decluttern. Lifetime-Logs sind Historie, nicht Session-Fortschritt — nicht als "schon erledigt"-Quelle für pending Jobs missbrauchen.
|
||||||
**Wie anwenden:** Bei jeder Filter/Remove-Logik auf User-State: nach Status gaten, nicht nur nach Identitäts-Match gegen historische Daten.
|
**Wie anwenden:** Bei jeder Filter/Remove-Logik auf User-State: nach Status gaten, nicht nur nach Identitäts-Match gegen historische Daten.
|
||||||
|
|
||||||
|
## 2026-05-28 — Doodstream "kein Filecode": Web-Scraping ist die falsche Ebene, API ist der Fix
|
||||||
|
**Symptom:** Wiederkehrend "kein Filecode — Server gab leeren Link zurueck" bei großen Dateien (~1GB/7min Upload), trotz 3.3.26-3.3.29. Queue voll roter Fehler.
|
||||||
|
**Root cause (recherchiert + verifiziert):** Der Web-Upload holt den Filecode aus einem XFileSharing-HTML-Formular. Bei langen Uploads kommt das Formular leer zurück, weil (a) der per-Seitenaufruf sess_id-Token über den 7min-Upload altert UND (b) der server-seitige File-Registration-Callback (cgi-bin/fs.cgi-Äquivalent) unter Last timeoutet → kein file_code gemintet. Wichtig: Das ist KEIN async-delay — die Datei taucht NICHT später in der Liste auf (die Registrierung, die sie listen würde, ist genau das was failt). File-list-Polling (wie Byse) hilft hier also kaum.
|
||||||
|
**Fix:** Die offizielle doodapi.co JSON-API nutzen, wenn ein API-Key da ist — sie liefert result[0].filecode DIREKT in JSON (kein HTML-Formular) und nutzt einen persistenten api_key (kein alternder sess_id). Git-Historie: die API war der ORIGINAL-Pfad (initial commit); Web-Login kam später nur "als Alternative zum API-Key" — Key-Bevorzugung stellt also den gedachten Primärpfad wieder her, kämpft nicht gegen eine bewusste Entscheidung.
|
||||||
|
**Regel:** Bei Hoster-Integrationen die offizielle API der Web-Scraping-Ebene vorziehen wo möglich. Empty-form/codeless-2xx = Hoster-Backend-Flake (hosterTransient), Account NICHT als tot markieren — auf BEIDEN Pfaden (Web + API) gleich klassifizieren.
|
||||||
|
**Voraussetzung:** Engagiert nur wenn der Doodstream-Account einen gültigen API-Key hat (doodstream.com/settings). Keyless-Accounts bleiben beim Web-Pfad.
|
||||||
|
|||||||
@ -64,6 +64,54 @@ test('happy path: link in result page wins', async () => {
|
|||||||
assert.equal(res.file_code, 'jjsuhr931ds9');
|
assert.equal(res.file_code, 'jjsuhr931ds9');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- deriveApiKey: pull + validate the account API key from the web session ---
|
||||||
|
test('_extractApiKeyCandidates finds the key in an input value and ranks api-context first', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
const html = `
|
||||||
|
<input type="text" name="csrf" value="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa">
|
||||||
|
<div class="panel">API Key <input readonly value="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"></div>
|
||||||
|
`;
|
||||||
|
const cands = up._extractApiKeyCandidates(html);
|
||||||
|
// The token whose preceding context mentions "API" must rank first.
|
||||||
|
assert.equal(cands[0], 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb');
|
||||||
|
assert.ok(cands.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('_extractApiKeyCandidates handles textarea + api_key: "x" shapes and empty input', () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
assert.deepEqual(up._extractApiKeyCandidates(''), []);
|
||||||
|
const ta = up._extractApiKeyCandidates('<textarea id="k">cccccccccccccccccccccccccccccccc</textarea>');
|
||||||
|
assert.ok(ta.includes('cccccccccccccccccccccccccccccccc'));
|
||||||
|
const js = up._extractApiKeyCandidates('var x = {"api_key":"dddddddddddddddddddddddddddddddd"};');
|
||||||
|
assert.ok(js.includes('dddddddddddddddddddddddddddddddd'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey returns the candidate that validates against the API', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async () => ({ text: async () => '<div>API Key <input value="REALKEY1234567890abcdefGHIJK"></div><input value="notthekey000000000000000000">' });
|
||||||
|
up._validateApiKey = async (key) => key === 'REALKEY1234567890abcdefGHIJK';
|
||||||
|
const key = await up.deriveApiKey();
|
||||||
|
assert.equal(key, 'REALKEY1234567890abcdefGHIJK');
|
||||||
|
assert.equal(up.apiKey, 'REALKEY1234567890abcdefGHIJK'); // cached on the instance
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey returns null when no candidate validates (→ caller uses web fallback)', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up._fetch = async () => ({ text: async () => '<input value="bogustoken0000000000000000000">' });
|
||||||
|
up._validateApiKey = async () => false;
|
||||||
|
assert.equal(await up.deriveApiKey(), null);
|
||||||
|
assert.equal(up.apiKey, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveApiKey short-circuits when a key is already set', async () => {
|
||||||
|
const up = new DoodstreamUploader();
|
||||||
|
up.apiKey = 'PRESET';
|
||||||
|
let fetched = false;
|
||||||
|
up._fetch = async () => { fetched = true; return { text: async () => '' }; };
|
||||||
|
assert.equal(await up.deriveApiKey(), 'PRESET');
|
||||||
|
assert.equal(fetched, false);
|
||||||
|
});
|
||||||
|
|
||||||
// --- _fetch: transient network blips on the small requests self-heal ---
|
// --- _fetch: transient network blips on the small requests self-heal ---
|
||||||
test('_fetch retries a transient network failure then succeeds', async () => {
|
test('_fetch retries a transient network failure then succeeds', async () => {
|
||||||
const up = new DoodstreamUploader();
|
const up = new DoodstreamUploader();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user