ani-auto 1.3.3 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.qwen/settings.json +9 -0
- package/package.json +6 -3
- package/src/cli.js +12 -2
- package/src/cloudflare.js +91 -0
- package/src/config.js +4 -3
- package/src/daemon.js +5 -13
- package/src/engine.js +34 -8
- package/src/notify.js +33 -14
- package/src/scraper.js +80 -28
- package/utils.js +49 -13
- package/CHANGELOG.md +0 -82
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ani-auto",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.1",
|
|
4
|
+
"description": "automatic anime episode downloader",
|
|
5
5
|
"bin": {
|
|
6
|
-
"ani-auto": "
|
|
6
|
+
"ani-auto": "src/cli.js"
|
|
7
7
|
},
|
|
8
8
|
"scripts": {
|
|
9
9
|
"start": "node src/daemon.js",
|
|
@@ -17,6 +17,9 @@
|
|
|
17
17
|
"inquirer": "^8.2.6",
|
|
18
18
|
"node-fetch": "^2.7.0",
|
|
19
19
|
"ora": "^5.4.1",
|
|
20
|
+
"puppeteer": "^24.40.0",
|
|
21
|
+
"puppeteer-extra": "^3.3.6",
|
|
22
|
+
"puppeteer-extra-plugin-stealth": "^2.11.2",
|
|
20
23
|
"sql.js": "^1.12.0"
|
|
21
24
|
}
|
|
22
25
|
}
|
package/src/cli.js
CHANGED
|
@@ -731,6 +731,11 @@ program
|
|
|
731
731
|
break;
|
|
732
732
|
}
|
|
733
733
|
case 'start': {
|
|
734
|
+
const cfgStart = loadConfig();
|
|
735
|
+
if (cfgStart.daemonEnabled === false) {
|
|
736
|
+
console.log(chalk.red('\n ✖ daemon is not enabled — run ani-auto daemon enable first\n'));
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
734
739
|
const r = run(`systemctl --user start ${SERVICE}`);
|
|
735
740
|
if (r.code === 0) console.log(chalk.green('\n Done Daemon started.\n'));
|
|
736
741
|
else console.log(chalk.red(`\n Failed to start: ${r.err}\n Try: ani-auto daemon install\n`));
|
|
@@ -743,6 +748,11 @@ program
|
|
|
743
748
|
break;
|
|
744
749
|
}
|
|
745
750
|
case 'restart': {
|
|
751
|
+
const cfgRestart = loadConfig();
|
|
752
|
+
if (cfgRestart.daemonEnabled === false) {
|
|
753
|
+
console.log(chalk.red('\n ✖ daemon is not enabled — run ani-auto daemon enable first\n'));
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
746
756
|
const r = run(`systemctl --user restart ${SERVICE}`);
|
|
747
757
|
if (r.code === 0) console.log(chalk.green('\n Done Daemon restarted.\n'));
|
|
748
758
|
else console.log(chalk.red(`\n Failed to restart: ${r.err}\n`));
|
|
@@ -768,8 +778,8 @@ program
|
|
|
768
778
|
updateConfig({ daemonEnabled: true });
|
|
769
779
|
console.log(chalk.green('\n Done Daemon enabled.'));
|
|
770
780
|
console.log(chalk.gray(' Starting systemd service...\n'));
|
|
771
|
-
const
|
|
772
|
-
if (
|
|
781
|
+
const re = run(`systemctl --user start ${SERVICE}`);
|
|
782
|
+
if (re.code === 0) {
|
|
773
783
|
console.log(chalk.green(' Daemon is now running.\n'));
|
|
774
784
|
} else {
|
|
775
785
|
console.log(chalk.yellow(' Could not start systemd service (is it installed?).'));
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const puppeteer = require('puppeteer-extra');
|
|
4
|
+
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
|
|
5
|
+
puppeteer.use(StealthPlugin());
|
|
6
|
+
|
|
7
|
+
const chalk = require('chalk');
|
|
8
|
+
|
|
9
|
+
let cachedCookie = null;
|
|
10
|
+
let cachedAt = null;
|
|
11
|
+
const COOKIE_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
12
|
+
|
|
13
|
+
function log(msg) {
|
|
14
|
+
const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
|
|
15
|
+
console.log(`${chalk.gray(ts)} ${chalk.magenta('☁')} ${msg}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function solveCloudflare(downloadUrl) {
|
|
19
|
+
log('Launching browser to solve Cloudflare challenge...');
|
|
20
|
+
const browser = await puppeteer.launch({
|
|
21
|
+
headless: 'new',
|
|
22
|
+
args: [
|
|
23
|
+
'--no-sandbox',
|
|
24
|
+
'--disable-setuid-sandbox',
|
|
25
|
+
'--disable-blink-features=AutomationControlled',
|
|
26
|
+
],
|
|
27
|
+
});
|
|
28
|
+
const page = await browser.newPage();
|
|
29
|
+
|
|
30
|
+
await page.setViewport({ width: 1920, height: 1080 });
|
|
31
|
+
await page.setUserAgent(
|
|
32
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// Navigate to the download URL — Cloudflare should intercept with challenge
|
|
37
|
+
log(`Navigating to ${new URL(downloadUrl).hostname}...`);
|
|
38
|
+
await page.goto(downloadUrl, { waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {});
|
|
39
|
+
|
|
40
|
+
// Poll for cf_clearance cookie — Turnstile auto-solves in ~10-30s
|
|
41
|
+
const challengeTimeout = 60000;
|
|
42
|
+
const pollInterval = 2000;
|
|
43
|
+
let cookie = null;
|
|
44
|
+
const startTime = Date.now();
|
|
45
|
+
|
|
46
|
+
while (Date.now() - startTime < challengeTimeout) {
|
|
47
|
+
const cookies = await page.cookies();
|
|
48
|
+
cookie = cookies.find(c => c.name === 'cf_clearance');
|
|
49
|
+
if (cookie) {
|
|
50
|
+
log('cf_clearance cookie found!');
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
log('Waiting for Turnstile to solve...');
|
|
54
|
+
await new Promise(r => setTimeout(r, pollInterval));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!cookie) {
|
|
58
|
+
// Take a screenshot for debugging
|
|
59
|
+
const ssPath = '/tmp/cf_debug.png';
|
|
60
|
+
await page.screenshot({ path: ssPath });
|
|
61
|
+
log(`Screenshot saved to ${ssPath}`);
|
|
62
|
+
throw new Error('cf_clearance cookie not found — challenge may have failed');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
cachedCookie = cookie.value;
|
|
66
|
+
cachedAt = Date.now();
|
|
67
|
+
log('Got cf_clearance cookie (valid ~30 min).');
|
|
68
|
+
return cookie.value;
|
|
69
|
+
} finally {
|
|
70
|
+
await browser.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function getCachedCookie(url) {
|
|
75
|
+
// Check if cached cookie is still valid
|
|
76
|
+
if (cachedCookie && cachedAt && (Date.now() - cachedAt) < COOKIE_TTL_MS) {
|
|
77
|
+
log('Using cached cf_clearance cookie.');
|
|
78
|
+
return cachedCookie;
|
|
79
|
+
}
|
|
80
|
+
// Expired or missing — solve again
|
|
81
|
+
if (cachedCookie) log('Cookie expired, re-solving Cloudflare challenge...');
|
|
82
|
+
return solveCloudflare(url);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function invalidateCache() {
|
|
86
|
+
cachedCookie = null;
|
|
87
|
+
cachedAt = null;
|
|
88
|
+
log('Cache invalidated.');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { getCachedCookie, invalidateCache, solveCloudflare };
|
package/src/config.js
CHANGED
|
@@ -13,17 +13,18 @@ const DEFAULTS = {
|
|
|
13
13
|
watchStatuses: ['CURRENT'],
|
|
14
14
|
manualList: [],
|
|
15
15
|
quality: '1080p',
|
|
16
|
-
language: 'sub',
|
|
16
|
+
language: 'sub',
|
|
17
17
|
concurrency: 2,
|
|
18
18
|
outputDir: path.join(os.homedir(), 'anime'),
|
|
19
19
|
maxRetries: 3,
|
|
20
20
|
daemonIntervalMinutes: 120,
|
|
21
21
|
skipExisting: true,
|
|
22
22
|
maxEpisodesPerRun: 50,
|
|
23
|
-
telegramChatId: null,
|
|
24
|
-
telegramNotify: true,
|
|
23
|
+
telegramChatId: null,
|
|
24
|
+
telegramNotify: true,
|
|
25
25
|
airingBufferMinutes: 5,
|
|
26
26
|
daemonEnabled: true, // set to false via `ani-auto daemon disable`
|
|
27
|
+
apiBaseUrl: 'https://aor-rex-anikuro-api.hf.space',
|
|
27
28
|
};
|
|
28
29
|
|
|
29
30
|
function ensureConfigDir() {
|
package/src/daemon.js
CHANGED
|
@@ -13,7 +13,6 @@ let running = false;
|
|
|
13
13
|
|
|
14
14
|
function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
|
|
15
15
|
|
|
16
|
-
function log(msg) { console.log(`${chalk.gray(ts())} ${msg}`); }
|
|
17
16
|
function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
|
|
18
17
|
function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${msg}`); }
|
|
19
18
|
|
|
@@ -89,17 +88,11 @@ async function startDaemon() {
|
|
|
89
88
|
|
|
90
89
|
// ── Enabled gate ──────────────────────────────────────────────────────
|
|
91
90
|
if (config.daemonEnabled === false) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
│ To re-enable, run: │
|
|
98
|
-
│ │
|
|
99
|
-
│ ani-auto daemon enable │
|
|
100
|
-
└─────────────────────────────────────────────────┘
|
|
101
|
-
`));
|
|
102
|
-
process.exit(0);
|
|
91
|
+
process.stderr.write(chalk.red(`\n ✖ [${ts()}] error: daemon is disabled\n`));
|
|
92
|
+
process.stderr.write(chalk.gray(` startup blocked — daemon was disabled via 'ani-auto daemon disable'\n`));
|
|
93
|
+
process.stderr.write(chalk.gray(` this applies to all startups: boot, network restore, and manual start\n`));
|
|
94
|
+
process.stderr.write(`\n to re-enable: ${chalk.bold.cyan('ani-auto daemon enable')}\n\n`);
|
|
95
|
+
process.exit(1);
|
|
103
96
|
}
|
|
104
97
|
|
|
105
98
|
const intervalMs = (config.daemonIntervalMinutes || 30) * 60 * 1000;
|
|
@@ -143,7 +136,6 @@ async function startDaemon() {
|
|
|
143
136
|
await scheduleSmartChecks(); // Refresh smart timers each interval
|
|
144
137
|
}, intervalMs);
|
|
145
138
|
|
|
146
|
-
|
|
147
139
|
console.log(chalk.green(` Daemon running. Press Ctrl+C to stop.\n`));
|
|
148
140
|
|
|
149
141
|
process.on('SIGINT', async () => {
|
package/src/engine.js
CHANGED
|
@@ -13,6 +13,7 @@ const scraper = require('./scraper');
|
|
|
13
13
|
const { version } = require('../package.json');
|
|
14
14
|
const notify = require('./notify');
|
|
15
15
|
|
|
16
|
+
// ── Logging ────────────────────────────────────────────────────────────────
|
|
16
17
|
|
|
17
18
|
function info(msg) { console.log(`${chalk.gray(ts())} ${chalk.cyan('ℹ')} ${msg}`); }
|
|
18
19
|
function ok(msg) { console.log(`${chalk.gray(ts())} ${chalk.green('✔')} ${msg}`); }
|
|
@@ -20,6 +21,7 @@ function warn(msg) { console.log(`${chalk.gray(ts())} ${chalk.yellow('⚠')} ${
|
|
|
20
21
|
function err(msg) { console.log(`${chalk.gray(ts())} ${chalk.red('✖')} ${msg}`); }
|
|
21
22
|
function ts() { return new Date().toISOString().replace('T', ' ').slice(0, 19); }
|
|
22
23
|
|
|
24
|
+
// ── Filename helpers ───────────────────────────────────────────────────────
|
|
23
25
|
|
|
24
26
|
function buildFilename(title, episodeNum, season = null, displayEpisodeNum = null) {
|
|
25
27
|
const s = season ? `Season ${season} ` : '';
|
|
@@ -43,6 +45,7 @@ function detectSeason(title) {
|
|
|
43
45
|
return m ? parseInt(m[1], 10) : null;
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
// ── Watch list ─────────────────────────────────────────────────────────────
|
|
46
49
|
|
|
47
50
|
async function resolveWatchList(config) {
|
|
48
51
|
const items = [];
|
|
@@ -100,6 +103,7 @@ async function resolveWatchList(config) {
|
|
|
100
103
|
});
|
|
101
104
|
}
|
|
102
105
|
|
|
106
|
+
// ── Anime selector prompt ──────────────────────────────────────────────────
|
|
103
107
|
|
|
104
108
|
async function promptAnimeSelection(watchList) {
|
|
105
109
|
console.log(chalk.bold('\n Available anime:'));
|
|
@@ -130,6 +134,7 @@ async function promptAnimeSelection(watchList) {
|
|
|
130
134
|
return indices.map(i => watchList[i]);
|
|
131
135
|
}
|
|
132
136
|
|
|
137
|
+
// ── Per-anime download ─────────────────────────────────────────────────────
|
|
133
138
|
|
|
134
139
|
async function processAnime(workItem, config, multiBar, auto = false, runQuality = null, runLanguage = null, range = null) {
|
|
135
140
|
// Per-anime quality: run override > DB override > workItem > global config
|
|
@@ -146,24 +151,41 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
146
151
|
return { downloaded: 0, skipped: 0, failed: 0 };
|
|
147
152
|
}
|
|
148
153
|
|
|
149
|
-
|
|
154
|
+
// Priority 1: Match by AniList ID (exact, most reliable)
|
|
155
|
+
if (workItem.anilistId) {
|
|
156
|
+
const idMatch = siteResults.find(r => r.anilistId === String(workItem.anilistId));
|
|
157
|
+
if (idMatch) {
|
|
158
|
+
siteAnime = idMatch;
|
|
159
|
+
info(`[${workItem.title}] Matched by AniList ID: ${chalk.bold(siteAnime.title)} (anilist:${siteAnime.anilistId})`);
|
|
160
|
+
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
161
|
+
} else {
|
|
162
|
+
warn(`[${workItem.title}] AniList ID ${workItem.anilistId} not found in search results — falling back to title match.`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Priority 2: Saved site match
|
|
167
|
+
if (!siteAnime && existingRow?.confirmed_site_title) {
|
|
150
168
|
const savedMatch = siteResults.find(r =>
|
|
151
169
|
r.title?.toLowerCase() === existingRow.confirmed_site_title.toLowerCase()
|
|
152
170
|
) || siteResults[0];
|
|
153
171
|
siteAnime = savedMatch;
|
|
154
172
|
info(`[${workItem.title}] Using saved site match: ${chalk.bold(siteAnime.title)}`);
|
|
155
173
|
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
156
|
-
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Priority 3: Auto mode — title matching fallback (no AniList ID, no saved match)
|
|
177
|
+
if (!siteAnime && auto) {
|
|
157
178
|
const normalize = s => s.toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
158
179
|
const normTitle = normalize(workItem.title);
|
|
159
|
-
const season = detectSeason(workItem.title);
|
|
160
180
|
const bestMatch = siteResults.find(r => normalize(r.title) === normTitle)
|
|
161
|
-
|| (season ? siteResults.find(r => detectSeason(r.title) === season) : null)
|
|
162
181
|
|| siteResults[0];
|
|
163
182
|
siteAnime = bestMatch;
|
|
164
183
|
db.saveConfirmedSiteMatch(workItem.title, siteAnime.session, siteAnime.title, siteAnime.poster || null);
|
|
165
184
|
info(`[${workItem.title}] Auto-matched to: ${chalk.bold(siteAnime.title)}`);
|
|
166
|
-
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Priority 4: Interactive mode — user picks
|
|
188
|
+
if (!siteAnime) {
|
|
167
189
|
const topResults = siteResults.slice(0, 5);
|
|
168
190
|
const { pick } = await inquirer.prompt([{
|
|
169
191
|
type: 'list',
|
|
@@ -171,7 +193,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
171
193
|
message: `Site results for "${workItem.title}" — select correct match:`,
|
|
172
194
|
choices: [
|
|
173
195
|
...topResults.map(r => ({
|
|
174
|
-
name: `${r.title} ${chalk.gray(`(${r.year || '?'}) — ${r.episodes || '?'} eps`)}`,
|
|
196
|
+
name: `${r.title} ${chalk.gray(`(${r.year || '?'}) — ${r.episodes || '?'} eps`)}${r.anilistId ? chalk.gray(` [AL:${r.anilistId}]`) : ''}`,
|
|
175
197
|
value: r,
|
|
176
198
|
})),
|
|
177
199
|
{ name: chalk.yellow('Skip this anime'), value: null },
|
|
@@ -270,6 +292,9 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
270
292
|
|
|
271
293
|
info(`[${workItem.title}] ${toDownload.length} unwatched episode(s) to download (Q:${q} L:${l}, skipping first ${watched}).`);
|
|
272
294
|
|
|
295
|
+
const posterUrl = db.getAnimeBySiteTitle(workItem.title)?.poster_url || null;
|
|
296
|
+
await notify.notifyDownloadStart(cleanTitle, toDownload.length, q, posterUrl);
|
|
297
|
+
|
|
273
298
|
const queue = [...toDownload];
|
|
274
299
|
const results = { downloaded: 0, skipped: 0, failed: 0 };
|
|
275
300
|
const concurrency = Math.min(config.concurrency || 2, queue.length);
|
|
@@ -295,7 +320,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
295
320
|
while (!success && attempts < MAX_RETRIES) {
|
|
296
321
|
attempts++;
|
|
297
322
|
try {
|
|
298
|
-
const links = await scraper.getDownloadLinks(siteAnime.session, ep.session);
|
|
323
|
+
const links = await scraper.getDownloadLinks(siteAnime.session, ep.session, q);
|
|
299
324
|
const link = scraper.pickLink(links, q, l);
|
|
300
325
|
if (!link?.mp4Url) throw new Error('No suitable download link found');
|
|
301
326
|
|
|
@@ -328,6 +353,7 @@ async function processAnime(workItem, config, multiBar, auto = false, runQuality
|
|
|
328
353
|
return results;
|
|
329
354
|
}
|
|
330
355
|
|
|
356
|
+
// ── Main cycle ─────────────────────────────────────────────────────────────
|
|
331
357
|
|
|
332
358
|
async function runCycle({ auto = false, quality = null, language = null, range = null, statuses = null } = {}) {
|
|
333
359
|
await db.initDb();
|
|
@@ -394,4 +420,4 @@ async function runCycle({ auto = false, quality = null, language = null, range =
|
|
|
394
420
|
|
|
395
421
|
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
396
422
|
|
|
397
|
-
module.exports = { runCycle, resolveWatchList };
|
|
423
|
+
module.exports = { runCycle, resolveWatchList };
|
package/src/notify.js
CHANGED
|
@@ -39,16 +39,35 @@ async function sendDesktop(title, body, urgency = 'normal', posterUrl = null) {
|
|
|
39
39
|
} catch (e) {}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
async function notifyDownloadStart(animeTitle, episodeCount, quality, posterUrl) {
|
|
43
|
+
const caption =
|
|
44
|
+
`<b>${animeTitle}</b>\n` +
|
|
45
|
+
`<code>━━━━━━━━━━━━━━━</code>\n` +
|
|
46
|
+
`▶ download started\n` +
|
|
47
|
+
`📥 episodes: ${episodeCount}\n` +
|
|
48
|
+
`🎞 quality: ${quality || 'default'}`;
|
|
49
|
+
const p1 = posterUrl
|
|
50
|
+
? sendTelegram({ photo: posterUrl, caption })
|
|
51
|
+
: sendTelegram({ text: caption });
|
|
52
|
+
const p2 = sendDesktop(
|
|
53
|
+
animeTitle,
|
|
54
|
+
`downloading ${episodeCount} episode${episodeCount !== 1 ? 's' : ''} · ${quality || 'default'}`,
|
|
55
|
+
'normal',
|
|
56
|
+
posterUrl
|
|
57
|
+
);
|
|
58
|
+
await Promise.allSettled([p1, p2]);
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
async function notifyEpisode(animeTitle, episode, quality, posterUrl) {
|
|
43
62
|
const caption =
|
|
44
63
|
`<b>${animeTitle}</b>\n` +
|
|
45
64
|
`<code>━━━━━━━━━━━━━━━</code>\n` +
|
|
46
|
-
`✅
|
|
47
|
-
`🎞
|
|
65
|
+
`✅ episode ${episode} downloaded\n` +
|
|
66
|
+
`🎞 quality: ${quality || 'default'}`;
|
|
48
67
|
const p1 = posterUrl
|
|
49
68
|
? sendTelegram({ photo: posterUrl, caption })
|
|
50
69
|
: sendTelegram({ text: `🎬 ${caption}` });
|
|
51
|
-
const p2 = sendDesktop(animeTitle, `
|
|
70
|
+
const p2 = sendDesktop(animeTitle, `episode ${episode} downloaded`, 'normal', posterUrl);
|
|
52
71
|
await Promise.allSettled([p1, p2]);
|
|
53
72
|
}
|
|
54
73
|
|
|
@@ -56,11 +75,11 @@ async function notifyFailed(animeTitle, episode, error) {
|
|
|
56
75
|
const text =
|
|
57
76
|
`❌ <b>${animeTitle}</b>\n` +
|
|
58
77
|
`<code>━━━━━━━━━━━━━━━</code>\n` +
|
|
59
|
-
`
|
|
78
|
+
`episode ${episode} failed\n` +
|
|
60
79
|
`<i>${error}</i>`;
|
|
61
80
|
await Promise.allSettled([
|
|
62
81
|
sendTelegram({ text }),
|
|
63
|
-
sendDesktop(animeTitle, `
|
|
82
|
+
sendDesktop(animeTitle, `episode ${episode} failed`, 'critical')
|
|
64
83
|
]);
|
|
65
84
|
}
|
|
66
85
|
|
|
@@ -69,12 +88,12 @@ async function notifySummary(totals) {
|
|
|
69
88
|
const text =
|
|
70
89
|
`📊 <b>ani-auto run complete</b>\n` +
|
|
71
90
|
`<code>━━━━━━━━━━━━━━━</code>\n` +
|
|
72
|
-
`✅
|
|
73
|
-
`⏭
|
|
74
|
-
`❌
|
|
91
|
+
`✅ downloaded: ${totals.downloaded}\n` +
|
|
92
|
+
`⏭ skipped: ${totals.skipped}\n` +
|
|
93
|
+
`❌ failed: ${totals.failed}`;
|
|
75
94
|
await Promise.allSettled([
|
|
76
95
|
sendTelegram({ text }),
|
|
77
|
-
sendDesktop('ani-auto run complete', `
|
|
96
|
+
sendDesktop('ani-auto run complete', `downloaded: ${totals.downloaded} failed: ${totals.failed}`)
|
|
78
97
|
]);
|
|
79
98
|
}
|
|
80
99
|
|
|
@@ -83,12 +102,12 @@ async function notifyDaemonStart(intervalMinutes, downloadingCount) {
|
|
|
83
102
|
const text =
|
|
84
103
|
`🚀 <b>ani-auto daemon started</b>\n` +
|
|
85
104
|
`<code>━━━━━━━━━━━━━━━</code>\n` +
|
|
86
|
-
`📦
|
|
87
|
-
`📺
|
|
88
|
-
`⏱
|
|
105
|
+
`📦 version: v${version}\n` +
|
|
106
|
+
`📺 downloading: ${downloadingCount} anime\n` +
|
|
107
|
+
`⏱ fallback check every ${intervalMinutes} minutes`;
|
|
89
108
|
await Promise.allSettled([
|
|
90
109
|
sendTelegram({ text }),
|
|
91
|
-
sendDesktop(`ani-auto v${version} started`, `
|
|
110
|
+
sendDesktop(`ani-auto v${version} started`, `downloading ${downloadingCount} anime · every ${intervalMinutes} min`)
|
|
92
111
|
]);
|
|
93
112
|
}
|
|
94
113
|
|
|
@@ -99,4 +118,4 @@ async function notifyDaemonStop() {
|
|
|
99
118
|
]);
|
|
100
119
|
}
|
|
101
120
|
|
|
102
|
-
module.exports = { notifyEpisode, notifyFailed, notifySummary, notifyDaemonStart, notifyDaemonStop };
|
|
121
|
+
module.exports = { notifyDownloadStart, notifyEpisode, notifyFailed, notifySummary, notifyDaemonStart, notifyDaemonStop };
|
package/src/scraper.js
CHANGED
|
@@ -3,18 +3,7 @@
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
const fetch = require('node-fetch');
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
let _utils = null;
|
|
9
|
-
function utils() {
|
|
10
|
-
if (_utils) return _utils;
|
|
11
|
-
try {
|
|
12
|
-
_utils = require(UTILS_PATH);
|
|
13
|
-
} catch (e) {
|
|
14
|
-
throw new Error(`Cannot find utils at "${UTILS_PATH}". Set KANIME_UTILS_PATH env var.\n${e.message}`);
|
|
15
|
-
}
|
|
16
|
-
return _utils;
|
|
17
|
-
}
|
|
6
|
+
const { SEARCH_API, RELEASES_API, USER_AGENT, downloadFile } = require('../utils');
|
|
18
7
|
|
|
19
8
|
async function searchAnime(title) {
|
|
20
9
|
const results = await searchAll(title);
|
|
@@ -25,13 +14,39 @@ async function searchAnime(title) {
|
|
|
25
14
|
}
|
|
26
15
|
|
|
27
16
|
async function searchAll(title) {
|
|
28
|
-
const { SEARCH_API, USER_AGENT } = utils();
|
|
29
17
|
const cleaned = title.replace(/[-\u2013\u2014~:!?()[\]{}<>]/g, ' ').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
30
18
|
const trySearch = async (q) => {
|
|
31
19
|
const res = await fetch(`${SEARCH_API}?q=${encodeURIComponent(q)}`, { headers: { 'User-Agent': USER_AGENT } });
|
|
32
|
-
if (!res.ok) throw new Error(`API returned ${res.status}`);
|
|
20
|
+
if (!res.ok) throw new Error(`Search API returned ${res.status}`);
|
|
33
21
|
const data = await res.json();
|
|
34
|
-
|
|
22
|
+
const items = Array.isArray(data)
|
|
23
|
+
? data
|
|
24
|
+
: (Array.isArray(data?.data) ? data.data : []);
|
|
25
|
+
|
|
26
|
+
const results = items.map(item => ({
|
|
27
|
+
...item,
|
|
28
|
+
session: item.session || item.id,
|
|
29
|
+
title: item.title,
|
|
30
|
+
year: item.year,
|
|
31
|
+
episodes: item.episodes,
|
|
32
|
+
poster: item.poster,
|
|
33
|
+
anilistId: null, // populated below
|
|
34
|
+
})).filter(item => item.session);
|
|
35
|
+
|
|
36
|
+
// Fetch anilistId for each result in parallel (detail endpoint has ids.anilist)
|
|
37
|
+
await Promise.allSettled(results.map(async (r) => {
|
|
38
|
+
try {
|
|
39
|
+
const detailRes = await fetch(`${RELEASES_API}/${encodeURIComponent(r.session)}`, {
|
|
40
|
+
headers: { 'User-Agent': USER_AGENT }
|
|
41
|
+
});
|
|
42
|
+
if (detailRes.ok) {
|
|
43
|
+
const detail = await detailRes.json();
|
|
44
|
+
r.anilistId = detail.ids?.anilist ? String(detail.ids.anilist) : null;
|
|
45
|
+
}
|
|
46
|
+
} catch {}
|
|
47
|
+
}));
|
|
48
|
+
|
|
49
|
+
return results;
|
|
35
50
|
};
|
|
36
51
|
let results = await trySearch(cleaned);
|
|
37
52
|
if (!results.length && cleaned !== title.toLowerCase()) results = await trySearch(title);
|
|
@@ -39,36 +54,73 @@ async function searchAll(title) {
|
|
|
39
54
|
}
|
|
40
55
|
|
|
41
56
|
async function getEpisodes(animeId) {
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
57
|
+
const allEpisodes = [];
|
|
58
|
+
let page = 1;
|
|
59
|
+
let lastPage = 1;
|
|
60
|
+
|
|
61
|
+
do {
|
|
62
|
+
const url = page === 1
|
|
63
|
+
? `${RELEASES_API}/${encodeURIComponent(animeId)}/releases`
|
|
64
|
+
: `${RELEASES_API}/${encodeURIComponent(animeId)}/releases?page=${page}`;
|
|
65
|
+
const res = await fetch(url, { headers: { 'User-Agent': USER_AGENT } });
|
|
66
|
+
if (!res.ok) throw new Error(`Releases API returned ${res.status} for ${animeId}`);
|
|
67
|
+
const data = await res.json();
|
|
68
|
+
lastPage = data.paginationInfo?.lastPage || 1;
|
|
69
|
+
const items = data.data || [];
|
|
70
|
+
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
const num = Number(item.episode);
|
|
73
|
+
if (!isNaN(num)) {
|
|
74
|
+
allEpisodes.push({
|
|
75
|
+
episode: num,
|
|
76
|
+
session: item.session,
|
|
77
|
+
disc: item.disc || null,
|
|
78
|
+
audio: item.audio || null,
|
|
79
|
+
snapshot: item.snapshot || null,
|
|
80
|
+
filler: item.filler || null,
|
|
81
|
+
duration: item.duration || null,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
page++;
|
|
86
|
+
} while (page <= lastPage);
|
|
87
|
+
|
|
88
|
+
return allEpisodes.sort((a, b) => a.episode - b.episode);
|
|
47
89
|
}
|
|
48
90
|
|
|
49
|
-
async function getDownloadLinks(animeId,
|
|
50
|
-
const { DOWNLOAD_API, USER_AGENT } = utils();
|
|
91
|
+
async function getDownloadLinks(animeId, session, quality = null) {
|
|
51
92
|
const res = await fetch(
|
|
52
|
-
`${
|
|
93
|
+
`${RELEASES_API}/${encodeURIComponent(animeId)}/${encodeURIComponent(session)}`,
|
|
53
94
|
{ headers: { 'User-Agent': USER_AGENT } }
|
|
54
95
|
);
|
|
55
96
|
if (!res.ok) return [];
|
|
56
97
|
const data = await res.json();
|
|
57
|
-
|
|
98
|
+
|
|
99
|
+
const sources = data.sources || [];
|
|
100
|
+
if (!sources.length) return [];
|
|
101
|
+
|
|
102
|
+
return sources.map(src => ({
|
|
103
|
+
mp4Url: src.download || null,
|
|
104
|
+
resolution: src.resolution || null,
|
|
105
|
+
isDub: src.isDub || false,
|
|
106
|
+
fanSub: src.fanSub || null,
|
|
107
|
+
}));
|
|
58
108
|
}
|
|
59
109
|
|
|
60
110
|
function pickLink(links, quality, language) {
|
|
61
111
|
if (!links.length) return null;
|
|
112
|
+
const wantDub = language === 'dub';
|
|
113
|
+
const targetRes = quality.replace('p', ''); // "1080p" → "1080"
|
|
62
114
|
return (
|
|
63
|
-
links.find(l => l.resolution ===
|
|
64
|
-
links.find(l => l.resolution ===
|
|
115
|
+
links.find(l => l.resolution === targetRes && l.isDub === wantDub) ||
|
|
116
|
+
links.find(l => l.resolution === targetRes) ||
|
|
117
|
+
links.find(l => l.isDub === wantDub) ||
|
|
65
118
|
links[0]
|
|
66
119
|
);
|
|
67
120
|
}
|
|
68
121
|
|
|
69
122
|
async function downloadEpisode(url, filename, outputDir, multiBar, episodeNum) {
|
|
70
|
-
const { downloadFile } = utils();
|
|
71
123
|
await downloadFile(url, filename, outputDir, multiBar, episodeNum);
|
|
72
124
|
}
|
|
73
125
|
|
|
74
|
-
module.exports = { searchAnime, searchAll, getEpisodes, getDownloadLinks, pickLink, downloadEpisode };
|
|
126
|
+
module.exports = { searchAnime, searchAll, getEpisodes, getDownloadLinks, pickLink, downloadEpisode };
|
package/utils.js
CHANGED
|
@@ -4,10 +4,21 @@ const os = require('os');
|
|
|
4
4
|
const chalk = require('chalk');
|
|
5
5
|
|
|
6
6
|
// --- Configuration ---
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
const CONFIG_DIR = path.join(os.homedir(), '.config', 'ani-auto');
|
|
8
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
9
|
+
|
|
10
|
+
function loadConfig() {
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
13
|
+
} catch {
|
|
14
|
+
return {};
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const config = loadConfig();
|
|
19
|
+
const API_DOMAIN = config.apiBaseUrl || 'https://aor-rex-anikuro-api.hf.space';
|
|
20
|
+
const SEARCH_API = `${API_DOMAIN}/api/anime/search`;
|
|
21
|
+
const RELEASES_API = `${API_DOMAIN}/api/anime`;
|
|
11
22
|
const USER_AGENT = 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36';
|
|
12
23
|
|
|
13
24
|
// --- Helpers ---
|
|
@@ -43,14 +54,35 @@ async function downloadFile(url, filename, folder, multiBar, episode) {
|
|
|
43
54
|
}) : null;
|
|
44
55
|
|
|
45
56
|
try {
|
|
46
|
-
|
|
57
|
+
// Strategy: try direct with Referer first; if 403, fall back to cookie extraction
|
|
58
|
+
let response = await _fetchWithCookie(url, {
|
|
47
59
|
'User-Agent': USER_AGENT,
|
|
48
|
-
'Referer': 'https://kwik.cx/'
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
60
|
+
'Referer': 'https://kwik.cx/',
|
|
61
|
+
'Origin': 'https://kwik.cx',
|
|
62
|
+
}, fileExists ? downloadedBytes : null);
|
|
63
|
+
|
|
64
|
+
if (response.status === 403) {
|
|
65
|
+
// Cloudflare Turnstile — solve with Puppeteer, retry with cookie
|
|
66
|
+
if (progressBar) progressBar.update(0, { episode, speed: 'solving CF...', value_formatted: '0 B', total_formatted: 'Unknown' });
|
|
67
|
+
const { getCachedCookie, invalidateCache } = require('./src/cloudflare');
|
|
68
|
+
const cookie = await getCachedCookie(url);
|
|
69
|
+
response = await _fetchWithCookie(url, {
|
|
70
|
+
'User-Agent': USER_AGENT,
|
|
71
|
+
'Referer': 'https://kwik.cx/',
|
|
72
|
+
'Cookie': `cf_clearance=${cookie}`,
|
|
73
|
+
}, fileExists ? downloadedBytes : null);
|
|
74
|
+
|
|
75
|
+
// If still 403 after cookie, invalidate and retry once
|
|
76
|
+
if (response.status === 403) {
|
|
77
|
+
invalidateCache();
|
|
78
|
+
const newCookie = await getCachedCookie(url);
|
|
79
|
+
response = await _fetchWithCookie(url, {
|
|
80
|
+
'User-Agent': USER_AGENT,
|
|
81
|
+
'Referer': 'https://kwik.cx/',
|
|
82
|
+
'Cookie': `cf_clearance=${newCookie}`,
|
|
83
|
+
}, fileExists ? downloadedBytes : null);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
54
86
|
|
|
55
87
|
if (response.status === 416) {
|
|
56
88
|
if (progressBar) { progressBar.update(100, { episode, value_formatted: 'Complete', total_formatted: 'Complete', speed: '0 B/s' }); progressBar.stop(); }
|
|
@@ -106,10 +138,14 @@ async function downloadFile(url, filename, folder, multiBar, episode) {
|
|
|
106
138
|
}
|
|
107
139
|
}
|
|
108
140
|
|
|
141
|
+
async function _fetchWithCookie(url, headers, resumeFrom) {
|
|
142
|
+
if (resumeFrom) headers['Range'] = `bytes=${resumeFrom}-`;
|
|
143
|
+
return fetch(url, { headers });
|
|
144
|
+
}
|
|
145
|
+
|
|
109
146
|
module.exports = {
|
|
110
147
|
SEARCH_API,
|
|
111
|
-
|
|
112
|
-
DOWNLOAD_API,
|
|
148
|
+
RELEASES_API,
|
|
113
149
|
USER_AGENT,
|
|
114
150
|
sleep,
|
|
115
151
|
getTerminalWidth,
|
package/CHANGELOG.md
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## [1.3.3]
|
|
4
|
-
### added
|
|
5
|
-
- daemon can be fully enabled or disabled — blocks all startup on boot and network restore when disabled
|
|
6
|
-
|
|
7
|
-
---
|
|
8
|
-
|
|
9
|
-
## [1.3.1]
|
|
10
|
-
### fixed
|
|
11
|
-
- stability patch
|
|
12
|
-
|
|
13
|
-
### added
|
|
14
|
-
- poster images included in telegram and desktop notifications
|
|
15
|
-
- daemon and notification toggle
|
|
16
|
-
|
|
17
|
-
---
|
|
18
|
-
|
|
19
|
-
## [1.3.0]
|
|
20
|
-
### added
|
|
21
|
-
- toggle to enable/disable the daemon without uninstalling it
|
|
22
|
-
|
|
23
|
-
---
|
|
24
|
-
|
|
25
|
-
## [1.2.4]
|
|
26
|
-
### fixed
|
|
27
|
-
- removed node.js warnings on startup
|
|
28
|
-
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## [1.2.3]
|
|
32
|
-
### added
|
|
33
|
-
- check for updates and install latest version in one step
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
## [1.2.2]
|
|
38
|
-
### added
|
|
39
|
-
- anilist watch list status selection on each download run (current, planning, paused, repeating)
|
|
40
|
-
|
|
41
|
-
---
|
|
42
|
-
|
|
43
|
-
## [1.2.1]
|
|
44
|
-
### fixed
|
|
45
|
-
- banner design fix
|
|
46
|
-
|
|
47
|
-
---
|
|
48
|
-
|
|
49
|
-
## [1.2.0]
|
|
50
|
-
### added
|
|
51
|
-
- desktop notifications (kde/linux)
|
|
52
|
-
- telegram notifications — episode downloaded, failed, run summary, daemon start/stop
|
|
53
|
-
- poster images in notifications
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## [1.1.0]
|
|
58
|
-
### added
|
|
59
|
-
- quality prompt on each download run
|
|
60
|
-
- pause downloads per anime while keeping history
|
|
61
|
-
- skips anime that already exist in your anilist list when adding manually
|
|
62
|
-
|
|
63
|
-
### fixed
|
|
64
|
-
- session handling fix
|
|
65
|
-
|
|
66
|
-
---
|
|
67
|
-
|
|
68
|
-
## [1.0.4]
|
|
69
|
-
### fixed
|
|
70
|
-
- error name fix
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
## [1.0.2]
|
|
75
|
-
### fixed
|
|
76
|
-
- daemon bug fix
|
|
77
|
-
- major bug fix
|
|
78
|
-
|
|
79
|
-
---
|
|
80
|
-
|
|
81
|
-
## [1.0.0]
|
|
82
|
-
- initial release
|