@paiavaliable/scrape-lib 1.0.0 → 1.1.0
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/README.md +107 -2
- package/index.js +16 -1
- package/lib/anilist.js +136 -0
- package/lib/bypassSfl.js +135 -0
- package/lib/hdImage.js +12 -3
- package/lib/sakanaAI.js +257 -0
- package/lib/ttRepost.js +109 -0
- package/lib/xDownloader.js +107 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -62,7 +62,7 @@ console.log(res);
|
|
|
62
62
|
---
|
|
63
63
|
|
|
64
64
|
### `hdImage(input, opts?)`
|
|
65
|
-
Upscale gambar ke HD.
|
|
65
|
+
Upscale gambar ke HD. Return `{ success, dataUrl, buffer }` — `buffer` udah siap kirim langsung (Baileys `sendMessage({ image: buffer })`), nggak perlu decode base64 manual.
|
|
66
66
|
|
|
67
67
|
```js
|
|
68
68
|
// dari path file lokal
|
|
@@ -72,7 +72,9 @@ const res = await scrape.hdImage('./image.png');
|
|
|
72
72
|
const buffer = await someMessage.download();
|
|
73
73
|
const res = await scrape.hdImage(buffer, { filename: 'photo.jpg' });
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
if (res.success) {
|
|
76
|
+
await sock.sendMessage(chatId, { image: res.buffer });
|
|
77
|
+
}
|
|
76
78
|
```
|
|
77
79
|
|
|
78
80
|
---
|
|
@@ -92,6 +94,109 @@ console.log(res.url); // URL gambar hasil filter
|
|
|
92
94
|
|
|
93
95
|
---
|
|
94
96
|
|
|
97
|
+
### `getXInfo(url)`
|
|
98
|
+
Ambil metadata post X/Twitter (title, thumbnail, videoUrl, mediaType).
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
const info = await scrape.getXInfo('https://x.com/user/status/xxxxxxxxxxx');
|
|
102
|
+
console.log(info);
|
|
103
|
+
// { platform, title, description, videoUrl, thumbnail, mediaType, originalUrl }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### `downloadX(url, outputDir?)`
|
|
107
|
+
Download video dari post X/Twitter ke disk. Kalo cuma butuh URL video buat dikirim ulang (misal via WA), cukup pake `getXInfo(url).videoUrl` langsung tanpa nge-download.
|
|
108
|
+
|
|
109
|
+
```js
|
|
110
|
+
const res = await scrape.downloadX('https://x.com/user/status/xxxxxxxxxxx');
|
|
111
|
+
console.log(res.filePath);
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
---
|
|
115
|
+
|
|
116
|
+
### `SakanaAI`
|
|
117
|
+
Unofficial client buat chat.sakana.ai (auto bikin sesi anonim, streaming chat).
|
|
118
|
+
|
|
119
|
+
```js
|
|
120
|
+
const { SakanaAI } = require('@yourname/scrape-lib');
|
|
121
|
+
|
|
122
|
+
const ai = new SakanaAI();
|
|
123
|
+
const models = await ai.getModels(); // ['namazu', 'sakana', ...]
|
|
124
|
+
const res = await ai.chat('Halo, apa kabar?', {
|
|
125
|
+
model: 'namazu',
|
|
126
|
+
needSearch: 0, // 1 buat aktifin web search
|
|
127
|
+
thinking: 0 // 1 buat aktifin reasoning mode (nggak bisa bareng needSearch)
|
|
128
|
+
});
|
|
129
|
+
console.log(res.text, res.conversationId);
|
|
130
|
+
|
|
131
|
+
// Lanjutin percakapan yang sama:
|
|
132
|
+
const res2 = await ai.chat('Terus gimana?', {
|
|
133
|
+
conversationId: res.conversationId,
|
|
134
|
+
parentMessageId: res.parentMessageId
|
|
135
|
+
});
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
### `anilistPopuler()`
|
|
141
|
+
Ambil daftar anime trending, populer, upcoming, top dari homepage AniList.
|
|
142
|
+
|
|
143
|
+
```js
|
|
144
|
+
const data = await scrape.anilistPopuler();
|
|
145
|
+
console.log(data.trending, data.top);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### `anilistSearch(query)`
|
|
149
|
+
Cari anime di AniList.
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
const results = await scrape.anilistSearch('one piece');
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
### `anilistDetail(url)`
|
|
156
|
+
Detail lengkap anime (translated ke ID) dari URL halaman AniList.
|
|
157
|
+
|
|
158
|
+
```js
|
|
159
|
+
const detail = await scrape.anilistDetail('https://anilist.co/anime/21/ONE-PIECE/');
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
### `translate(text, lang?)`
|
|
163
|
+
Translate teks pendek (default ke `id`).
|
|
164
|
+
|
|
165
|
+
```js
|
|
166
|
+
const res = await scrape.translate('Hello world');
|
|
167
|
+
console.log(res.result.tr);
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
### `ambilRepost(username, jumlah?, cursor?)`
|
|
173
|
+
Ambil satu halaman video repost dari akun TikTok (default 6 video).
|
|
174
|
+
|
|
175
|
+
```js
|
|
176
|
+
const res = await scrape.ambilRepost('dimeh493');
|
|
177
|
+
console.log(res.videos, res.adaLagi, res.cursor);
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### `ambilSemuaRepost(username, maxHalaman?)`
|
|
181
|
+
Ambil semua repost sampe `maxHalaman` halaman (default 5) atau sampe habis.
|
|
182
|
+
|
|
183
|
+
```js
|
|
184
|
+
const res = await scrape.ambilSemuaRepost('dimeh493', 3);
|
|
185
|
+
console.log(res.totalVideo);
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
### `bypassSflGl(url)`
|
|
191
|
+
Bypass link shortener `sfl.gl`.
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
const res = await scrape.bypassSflGl('https://sfl.gl/xxxxxxx');
|
|
195
|
+
console.log(res.result); // URL asli
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
95
200
|
## Catatan
|
|
96
201
|
|
|
97
202
|
Semua fungsi di sini nge-hit API/service pihak ketiga (bukan self-hosted), jadi:
|
package/index.js
CHANGED
|
@@ -4,6 +4,11 @@ const { aioDownload } = require('./lib/aioDownload');
|
|
|
4
4
|
const { getToken, bypassLink } = require('./lib/bypassLink');
|
|
5
5
|
const { hdImage } = require('./lib/hdImage');
|
|
6
6
|
const { toHitam } = require('./lib/toHitam');
|
|
7
|
+
const { getXInfo, downloadX } = require('./lib/xDownloader');
|
|
8
|
+
const { SakanaAI } = require('./lib/sakanaAI');
|
|
9
|
+
const { anilistPopuler, anilistSearch, anilistDetail, translate } = require('./lib/anilist');
|
|
10
|
+
const { ambilRepost, ambilSemuaRepost } = require('./lib/ttRepost');
|
|
11
|
+
const { bypassSflGl } = require('./lib/bypassSfl');
|
|
7
12
|
|
|
8
13
|
module.exports = {
|
|
9
14
|
searchSongs,
|
|
@@ -12,5 +17,15 @@ module.exports = {
|
|
|
12
17
|
getToken,
|
|
13
18
|
bypassLink,
|
|
14
19
|
hdImage,
|
|
15
|
-
toHitam
|
|
20
|
+
toHitam,
|
|
21
|
+
getXInfo,
|
|
22
|
+
downloadX,
|
|
23
|
+
SakanaAI,
|
|
24
|
+
anilistPopuler,
|
|
25
|
+
anilistSearch,
|
|
26
|
+
anilistDetail,
|
|
27
|
+
translate,
|
|
28
|
+
ambilRepost,
|
|
29
|
+
ambilSemuaRepost,
|
|
30
|
+
bypassSflGl
|
|
16
31
|
};
|
package/lib/anilist.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ANILIST SCRAPER
|
|
3
|
+
* base: https://anilist.co
|
|
4
|
+
* Scrape trending/populer/upcoming/top anime, search, detail (dengan translate ke ID).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const axios = require('axios');
|
|
8
|
+
const cheerio = require('cheerio');
|
|
9
|
+
|
|
10
|
+
const cleanText = (text) => (text || '').replace(/\n\s+/g, ' ').trim();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Translate teks pendek pake Google Translate (unofficial endpoint).
|
|
14
|
+
*/
|
|
15
|
+
async function translate(text, lang = 'id') {
|
|
16
|
+
try {
|
|
17
|
+
const res = await fetch(`https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${lang}&dt=t&q=${encodeURIComponent(text)}`);
|
|
18
|
+
const data = await res.json();
|
|
19
|
+
const hasil = data[0][0][0];
|
|
20
|
+
return { status: true, result: { tr: hasil } };
|
|
21
|
+
} catch {
|
|
22
|
+
return { status: false, result: { tr: text } };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Ambil daftar anime trending, populer, upcoming, dan top dari homepage AniList.
|
|
28
|
+
*/
|
|
29
|
+
async function anilistPopuler() {
|
|
30
|
+
const url = 'https://anilist.co';
|
|
31
|
+
const response = await axios.get(url);
|
|
32
|
+
const $ = cheerio.load(response.data);
|
|
33
|
+
|
|
34
|
+
const mapCards = (selector, withRank = false) =>
|
|
35
|
+
$(selector)
|
|
36
|
+
.map((_, element) => {
|
|
37
|
+
const title = $(element).find('.title').text().trim();
|
|
38
|
+
const link = url + $(element).find('a.cover').attr('href');
|
|
39
|
+
const image = $(element).find('img.image').attr('src');
|
|
40
|
+
if (withRank) {
|
|
41
|
+
const rank = $(element).find('.rank').text().trim();
|
|
42
|
+
return { rank, title, link, image };
|
|
43
|
+
}
|
|
44
|
+
return { title, link, image };
|
|
45
|
+
})
|
|
46
|
+
.get();
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
trending: mapCards('.landing-section.trending .results .media-card'),
|
|
50
|
+
populer: mapCards('.landing-section.season .results .media-card'),
|
|
51
|
+
upcoming: mapCards('.landing-section.nextSeason .results .media-card'),
|
|
52
|
+
top: mapCards('.landing-section.top .results .media-card', true)
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cari anime di AniList berdasarkan query.
|
|
58
|
+
*/
|
|
59
|
+
async function anilistSearch(query) {
|
|
60
|
+
const { data } = await axios.get(`https://anilist.co/search/anime?query=${encodeURIComponent(query)}`);
|
|
61
|
+
const $ = cheerio.load(data);
|
|
62
|
+
|
|
63
|
+
const results = [];
|
|
64
|
+
$('.media-card').each((_, element) => {
|
|
65
|
+
const title = $(element).find('.title').text().trim();
|
|
66
|
+
const imageUrl = $(element).find('.image').attr('src');
|
|
67
|
+
const link = $(element).find('.cover').attr('href');
|
|
68
|
+
|
|
69
|
+
if (title && imageUrl && link) {
|
|
70
|
+
results.push({ title, imageUrl, link: `https://anilist.co${link}` });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
return results;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Ambil detail lengkap anime dari URL halaman AniList-nya, sekalian translate ke ID.
|
|
79
|
+
*/
|
|
80
|
+
async function anilistDetail(url) {
|
|
81
|
+
const { data } = await axios.get(url);
|
|
82
|
+
const $ = cheerio.load(data);
|
|
83
|
+
|
|
84
|
+
const safeTranslate = async (text) => (await translate(text)).result.tr;
|
|
85
|
+
|
|
86
|
+
const descriptionText = cleanText($('.description.content-wrap').text());
|
|
87
|
+
const descriptionParagraphs = descriptionText.split('\n').filter(p => p.trim() !== '');
|
|
88
|
+
const translatedParagraphs = await Promise.all(descriptionParagraphs.map(safeTranslate));
|
|
89
|
+
|
|
90
|
+
const titleRomaji = cleanText($('.content h1').first().text());
|
|
91
|
+
const titleEnglish = cleanText($('div.data-set:contains("English") .value').text());
|
|
92
|
+
const titleNative = cleanText($('div.data-set:contains("Native") .value').text());
|
|
93
|
+
|
|
94
|
+
const genresOriginal = $('div.data-set:contains("Genres") .value a').map((i, el) => cleanText($(el).text())).get();
|
|
95
|
+
const studiosOriginal = $('div.data-set:contains("Studios") .value a').map((i, el) => cleanText($(el).text())).get();
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
title: {
|
|
99
|
+
romaji: titleRomaji,
|
|
100
|
+
english: titleEnglish,
|
|
101
|
+
native: titleNative,
|
|
102
|
+
translated: {
|
|
103
|
+
romaji: await safeTranslate(titleRomaji),
|
|
104
|
+
english: await safeTranslate(titleEnglish),
|
|
105
|
+
native: await safeTranslate(titleNative)
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
description: {
|
|
109
|
+
original: descriptionText,
|
|
110
|
+
translated: translatedParagraphs.join('\n\n'),
|
|
111
|
+
paragraphs: { original: descriptionParagraphs, translated: translatedParagraphs }
|
|
112
|
+
},
|
|
113
|
+
cover: $('.cover-wrap-inner .cover').attr('src'),
|
|
114
|
+
banner: $('.banner').css('background-image')
|
|
115
|
+
? $('.banner').css('background-image').replace(/^url\(\s*['"]?|['"]?\s*\)$/g, '')
|
|
116
|
+
: null,
|
|
117
|
+
details: {
|
|
118
|
+
format: cleanText($('div.data-set:contains("Format") .value').text()),
|
|
119
|
+
episodes: cleanText($('div.data-set:contains("Episodes") .value').text()),
|
|
120
|
+
status: cleanText($('div.data-set:contains("Status") .value').text()),
|
|
121
|
+
season: cleanText($('div.data-set:contains("Season") .value').text()),
|
|
122
|
+
averageScore: cleanText($('div.data-set:contains("Average Score") .value').text()),
|
|
123
|
+
popularity: cleanText($('div.data-set:contains("Popularity") .value').text())
|
|
124
|
+
},
|
|
125
|
+
genres: {
|
|
126
|
+
original: genresOriginal.join(', '),
|
|
127
|
+
translated: await Promise.all(genresOriginal.map(safeTranslate))
|
|
128
|
+
},
|
|
129
|
+
studios: {
|
|
130
|
+
original: studiosOriginal,
|
|
131
|
+
translated: await Promise.all(studiosOriginal.map(safeTranslate))
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { anilistPopuler, anilistSearch, anilistDetail, translate };
|
package/lib/bypassSfl.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SFL.GL BYPASS
|
|
3
|
+
* base : https://sfl.gl (via app.khaddavi.net)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36';
|
|
7
|
+
|
|
8
|
+
function parseCookies(res, existing = {}) {
|
|
9
|
+
const map = { ...existing };
|
|
10
|
+
(res.headers.getSetCookie?.() || []).forEach(c => {
|
|
11
|
+
const [pair] = c.split(';');
|
|
12
|
+
const idx = pair.indexOf('=');
|
|
13
|
+
if (idx > 0) map[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
14
|
+
});
|
|
15
|
+
return map;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const cookieStr = map => Object.entries(map).map(([k, v]) => `${k}=${v}`).join('; ');
|
|
19
|
+
|
|
20
|
+
async function fetchFollowManual(url, opts = {}, cookieMap = {}) {
|
|
21
|
+
let currentUrl = url;
|
|
22
|
+
let cookies = { ...cookieMap };
|
|
23
|
+
let res;
|
|
24
|
+
|
|
25
|
+
for (let i = 0; i < 10; i++) {
|
|
26
|
+
res = await fetch(currentUrl, {
|
|
27
|
+
...opts,
|
|
28
|
+
headers: { ...(opts.headers || {}), 'cookie': cookieStr(cookies) },
|
|
29
|
+
redirect: 'manual'
|
|
30
|
+
});
|
|
31
|
+
cookies = parseCookies(res, cookies);
|
|
32
|
+
|
|
33
|
+
if (res.status >= 300 && res.status < 400) {
|
|
34
|
+
const loc = res.headers.get('location');
|
|
35
|
+
if (!loc) break;
|
|
36
|
+
currentUrl = loc.startsWith('http') ? loc : new URL(loc, currentUrl).href;
|
|
37
|
+
} else {
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return { res, finalUrl: currentUrl, cookies };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Bypass link shortener sfl.gl, balikin URL asli tujuannya.
|
|
47
|
+
*/
|
|
48
|
+
async function bypassSflGl(targetUrl) {
|
|
49
|
+
const startTime = Date.now();
|
|
50
|
+
const t = () => `${((Date.now() - startTime) / 1000).toFixed(2)}s`;
|
|
51
|
+
let cookies = {};
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const { res: res1, cookies: c1 } = await fetchFollowManual(targetUrl, {
|
|
55
|
+
headers: { 'user-agent': UA, 'accept': 'text/html,*/*', 'accept-language': 'en-US,en;q=0.9' }
|
|
56
|
+
}, cookies);
|
|
57
|
+
cookies = c1;
|
|
58
|
+
const html1 = await res1.text();
|
|
59
|
+
|
|
60
|
+
const rayId = html1.match(/name="ray_id"\s+value="([^"]+)"/)?.[1];
|
|
61
|
+
const alias = html1.match(/name="alias"\s+value="([^"]+)"/)?.[1];
|
|
62
|
+
if (!rayId || !alias) throw new Error(`ray_id/alias tidak ditemukan. Snippet: ${html1.slice(0, 300)}`);
|
|
63
|
+
|
|
64
|
+
const khaddaviBase = 'https://app.khaddavi.net';
|
|
65
|
+
const { finalUrl: pageUrl, cookies: c2 } = await fetchFollowManual(
|
|
66
|
+
`${khaddaviBase}/redirect.php?ray_id=${rayId}&alias=${alias}`,
|
|
67
|
+
{ headers: { 'user-agent': UA, 'accept': 'text/html,*/*', 'referer': targetUrl } },
|
|
68
|
+
cookies
|
|
69
|
+
);
|
|
70
|
+
cookies = c2;
|
|
71
|
+
|
|
72
|
+
const xsrf = cookies['XSRF-TOKEN'] ? decodeURIComponent(cookies['XSRF-TOKEN']) : '';
|
|
73
|
+
|
|
74
|
+
const apiHeaders = {
|
|
75
|
+
'user-agent': UA,
|
|
76
|
+
'content-type': 'application/json',
|
|
77
|
+
'accept': 'application/json, */*',
|
|
78
|
+
'accept-language': 'en-US,en;q=0.9',
|
|
79
|
+
'origin': khaddaviBase,
|
|
80
|
+
'referer': pageUrl,
|
|
81
|
+
'x-xsrf-token': xsrf,
|
|
82
|
+
'sec-ch-ua': '"Chromium";v="127", "Not)A;Brand";v="99", "Google Chrome";v="127"',
|
|
83
|
+
'sec-ch-ua-mobile': '?0',
|
|
84
|
+
'sec-ch-ua-platform': '"Windows"',
|
|
85
|
+
'sec-fetch-site': 'same-origin',
|
|
86
|
+
'sec-fetch-mode': 'cors',
|
|
87
|
+
'sec-fetch-dest': 'empty'
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const res3 = await fetch(`${khaddaviBase}/api/verify`, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { ...apiHeaders, 'cookie': cookieStr(cookies) },
|
|
93
|
+
body: JSON.stringify({ _a: 0 })
|
|
94
|
+
});
|
|
95
|
+
cookies = parseCookies(res3, cookies);
|
|
96
|
+
const verifyData = await res3.json();
|
|
97
|
+
if (!verifyData.target) throw new Error(`verify gagal: ${JSON.stringify(verifyData)}`);
|
|
98
|
+
|
|
99
|
+
const k = Math.floor(Math.random() * 1000);
|
|
100
|
+
const r = Math.random().toString(16).slice(2);
|
|
101
|
+
const size = `${(1920 + k) * 2}.${(1080 + k) * 2}`;
|
|
102
|
+
|
|
103
|
+
const res6 = await fetch(`${khaddaviBase}/api/go`, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: {
|
|
106
|
+
...apiHeaders,
|
|
107
|
+
'x-requested-with': 'XMLHttpRequest',
|
|
108
|
+
'idempotency-key': r,
|
|
109
|
+
'cookie': cookieStr(cookies)
|
|
110
|
+
},
|
|
111
|
+
body: JSON.stringify({ key: k, size, _dvc: r })
|
|
112
|
+
});
|
|
113
|
+
const goData = await res6.json();
|
|
114
|
+
|
|
115
|
+
const readyUrl = goData.url;
|
|
116
|
+
if (!readyUrl) throw new Error(`URL tidak ditemukan: ${JSON.stringify(goData)}`);
|
|
117
|
+
|
|
118
|
+
const { res: res7 } = await fetchFollowManual(readyUrl, {
|
|
119
|
+
headers: { 'user-agent': UA, 'accept': 'text/html,*/*', 'referer': khaddaviBase }
|
|
120
|
+
});
|
|
121
|
+
const html7 = await res7.text();
|
|
122
|
+
|
|
123
|
+
const finalMatch = html7.match(/window\.location\.href\s*=\s*"([^"]+)"/);
|
|
124
|
+
if (!finalMatch) throw new Error(`Final URL tidak ditemukan. Snippet: ${html7.slice(0, 300)}`);
|
|
125
|
+
|
|
126
|
+
const finalUrl = finalMatch[1].replace(/\\\//g, '/');
|
|
127
|
+
|
|
128
|
+
return { status: 'success', result: finalUrl, time_taken: t() };
|
|
129
|
+
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return { status: 'failed', error: err.message, time_taken: t() };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { bypassSflGl };
|
package/lib/hdImage.js
CHANGED
|
@@ -36,11 +36,20 @@ async function hdImage(input, opts = {}) {
|
|
|
36
36
|
timeout: 300000
|
|
37
37
|
});
|
|
38
38
|
|
|
39
|
-
if (data) {
|
|
40
|
-
return { success: true, result: data };
|
|
41
|
-
} else {
|
|
39
|
+
if (!data) {
|
|
42
40
|
return { success: false, error: "Empty response from server" };
|
|
43
41
|
}
|
|
42
|
+
|
|
43
|
+
const dataUrl = data.result?.dataUrl || data.dataUrl;
|
|
44
|
+
if (!dataUrl || !dataUrl.startsWith('data:')) {
|
|
45
|
+
// API nggak selalu balikin dataUrl (kadang mungkin url biasa) — balikin mentah aja
|
|
46
|
+
return { success: true, result: data };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const base64 = dataUrl.split(',')[1];
|
|
50
|
+
const buffer = Buffer.from(base64, 'base64');
|
|
51
|
+
|
|
52
|
+
return { success: true, dataUrl, buffer, raw: data };
|
|
44
53
|
} catch (error) {
|
|
45
54
|
const errorMsg = error.response && error.response.data ? error.response.data : error.message;
|
|
46
55
|
return { success: false, error: errorMsg };
|
package/lib/sakanaAI.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SAKANA AI CHAT
|
|
3
|
+
* Unofficial client buat chat.sakana.ai — bikin sesi anonim (Firebase signup + login),
|
|
4
|
+
* lalu streaming chat ke salah satu model yang tersedia.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const axios = require('axios');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
const FormData = require('form-data');
|
|
10
|
+
|
|
11
|
+
const USER_AGENTS = [
|
|
12
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
13
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
14
|
+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
15
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
|
16
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; rv:125.0) Gecko/20100101 Firefox/125.0',
|
|
17
|
+
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0',
|
|
18
|
+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0',
|
|
19
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const BASE = 'https://chat.sakana.ai';
|
|
23
|
+
const FIREBASE_KEY = 'AIzaSyBIJuyUokxGiETY0Nu3hQNC1dMadHyf_I4';
|
|
24
|
+
const MODELS = ['namazu', 'sakana', 'namazu-v2', 'namazu-pro', 'llama'];
|
|
25
|
+
|
|
26
|
+
function randomUA() {
|
|
27
|
+
return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function request(method, url, options = {}, retries = 3) {
|
|
31
|
+
let lastError;
|
|
32
|
+
for (let i = 0; i < retries; i++) {
|
|
33
|
+
try {
|
|
34
|
+
const headers = {
|
|
35
|
+
'User-Agent': randomUA(),
|
|
36
|
+
'Accept': 'application/json, text/plain, */*',
|
|
37
|
+
'Accept-Language': 'id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
38
|
+
'Accept-Encoding': 'gzip, deflate, br',
|
|
39
|
+
'Connection': 'keep-alive',
|
|
40
|
+
...(options.headers || {})
|
|
41
|
+
};
|
|
42
|
+
const config = {
|
|
43
|
+
method,
|
|
44
|
+
url,
|
|
45
|
+
headers,
|
|
46
|
+
timeout: 30000,
|
|
47
|
+
maxRedirects: 5,
|
|
48
|
+
...options
|
|
49
|
+
};
|
|
50
|
+
if (options.data && options.data.pipe) {
|
|
51
|
+
config.data = options.data;
|
|
52
|
+
}
|
|
53
|
+
const response = await axios(config);
|
|
54
|
+
return response;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
lastError = error;
|
|
57
|
+
if (error.response && error.response.status === 403) {
|
|
58
|
+
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
|
|
62
|
+
await new Promise(r => setTimeout(r, 1000 * (i + 1)));
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
throw lastError;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class SakanaAI {
|
|
72
|
+
constructor() {
|
|
73
|
+
this.cookie = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async _getCookie() {
|
|
77
|
+
if (this.cookie) return this.cookie;
|
|
78
|
+
const signup = await request('POST', `https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=${FIREBASE_KEY}`, {
|
|
79
|
+
data: { returnSecureToken: true, tenantId: 'sakana-talk-prd-pvl72' }
|
|
80
|
+
});
|
|
81
|
+
const login = await request('POST', `${BASE}/api/auth/login`, {
|
|
82
|
+
data: new URLSearchParams({ idToken: signup.data.idToken }).toString(),
|
|
83
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: BASE, Referer: BASE }
|
|
84
|
+
});
|
|
85
|
+
const cookieHeader = login.headers['set-cookie'] || [];
|
|
86
|
+
this.cookie = cookieHeader.find(c => c.startsWith('sakana-chat='))?.split(';')[0];
|
|
87
|
+
if (!this.cookie) throw new Error('Failed to get session cookie');
|
|
88
|
+
return this.cookie;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async getModels() {
|
|
92
|
+
const cookie = await this._getCookie();
|
|
93
|
+
const res = await request('GET', `${BASE}/api/agents`, { headers: { Cookie: cookie } });
|
|
94
|
+
return res.data.map(a => a.id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async getConversations(limit = 20) {
|
|
98
|
+
const cookie = await this._getCookie();
|
|
99
|
+
try {
|
|
100
|
+
const res = await request('GET', `${BASE}/api/conversations?limit=${limit}`, { headers: { Cookie: cookie } });
|
|
101
|
+
return res.data;
|
|
102
|
+
} catch {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async deleteConversation(id) {
|
|
108
|
+
const cookie = await this._getCookie();
|
|
109
|
+
try {
|
|
110
|
+
const res = await request('DELETE', `${BASE}/conversation/${id}`, { headers: { Cookie: cookie } });
|
|
111
|
+
return res.data;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
cleanText(text) {
|
|
118
|
+
return text
|
|
119
|
+
.replace(/\*\*([^*]+)\*\*/g, '$1')
|
|
120
|
+
.replace(/\*([^*]+)\*/g, '$1')
|
|
121
|
+
.replace(/_([^_]+)_/g, '$1')
|
|
122
|
+
.replace(/^-\s+/gm, '')
|
|
123
|
+
.replace(/^\d+\.\s+/gm, '')
|
|
124
|
+
.replace(/#{1,6}\s*/g, '')
|
|
125
|
+
.replace(/\n/g, ' ')
|
|
126
|
+
.replace(/\s+/g, ' ')
|
|
127
|
+
.trim();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async chat(question, options = {}) {
|
|
131
|
+
const {
|
|
132
|
+
model = 'namazu',
|
|
133
|
+
conversationId = null,
|
|
134
|
+
parentMessageId = null,
|
|
135
|
+
needSearch = 0,
|
|
136
|
+
thinking = 0,
|
|
137
|
+
toneMode = 'default',
|
|
138
|
+
streamOutput = false
|
|
139
|
+
} = options;
|
|
140
|
+
|
|
141
|
+
if (!question) throw new Error('Question is required.');
|
|
142
|
+
if (thinking && needSearch) throw new Error('Thinking and Web Search cannot be used together.');
|
|
143
|
+
if (!MODELS.includes(model)) throw new Error(`Model not found. Available: ${MODELS.join(', ')}`);
|
|
144
|
+
|
|
145
|
+
const cookie = await this._getCookie();
|
|
146
|
+
let convId = conversationId;
|
|
147
|
+
let parentId = parentMessageId;
|
|
148
|
+
|
|
149
|
+
if (!convId) {
|
|
150
|
+
const create = await request('POST', `${BASE}/conversation`, {
|
|
151
|
+
data: {
|
|
152
|
+
inputs: question,
|
|
153
|
+
enableThinking: thinking === 1,
|
|
154
|
+
toneMode,
|
|
155
|
+
webSearchEnabled: needSearch === 1,
|
|
156
|
+
agentId: model
|
|
157
|
+
},
|
|
158
|
+
headers: { Cookie: cookie, Origin: BASE, Referer: BASE }
|
|
159
|
+
});
|
|
160
|
+
convId = create.data.conversationId;
|
|
161
|
+
parentId = create.data.systemMessageId;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const fd = new FormData();
|
|
165
|
+
fd.append('data', JSON.stringify({
|
|
166
|
+
inputs: question,
|
|
167
|
+
id: parentId,
|
|
168
|
+
is_retry: false,
|
|
169
|
+
is_continue: false,
|
|
170
|
+
enableThinking: thinking === 1,
|
|
171
|
+
toneMode,
|
|
172
|
+
webSearchEnabled: needSearch === 1,
|
|
173
|
+
userMessageId: crypto.randomUUID()
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
const res = await request('POST', `${BASE}/conversation/${convId}`, {
|
|
177
|
+
data: fd,
|
|
178
|
+
headers: {
|
|
179
|
+
Cookie: cookie,
|
|
180
|
+
Origin: BASE,
|
|
181
|
+
Referer: BASE,
|
|
182
|
+
'x-requested-with': 'com.xbrowser.play',
|
|
183
|
+
...fd.getHeaders()
|
|
184
|
+
},
|
|
185
|
+
responseType: 'stream'
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return new Promise((resolve, reject) => {
|
|
189
|
+
let fullText = '';
|
|
190
|
+
let messageId = null;
|
|
191
|
+
let buf = '';
|
|
192
|
+
let processedIndex = 0;
|
|
193
|
+
|
|
194
|
+
res.data.on('data', chunk => {
|
|
195
|
+
buf += chunk.toString('utf8');
|
|
196
|
+
const lines = buf.split('\n');
|
|
197
|
+
buf = lines.pop();
|
|
198
|
+
for (const line of lines) {
|
|
199
|
+
const trimmed = line.trim();
|
|
200
|
+
if (!trimmed) continue;
|
|
201
|
+
try {
|
|
202
|
+
const parsed = JSON.parse(trimmed);
|
|
203
|
+
if (parsed.type === 'createdMessage') messageId = parsed.messageId;
|
|
204
|
+
if (parsed.type === 'stream' && parsed.token) {
|
|
205
|
+
fullText += parsed.token.replace(/\0/g, '');
|
|
206
|
+
if (streamOutput) {
|
|
207
|
+
while (processedIndex < fullText.length) {
|
|
208
|
+
const rem = fullText.substring(processedIndex);
|
|
209
|
+
if (rem.startsWith('<plan>') || rem.startsWith('<think>')) {
|
|
210
|
+
processedIndex += rem.startsWith('<plan>') ? 6 : 7;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (rem.startsWith('</plan>') || rem.startsWith('</think>')) {
|
|
214
|
+
processedIndex += rem.startsWith('</plan>') ? 7 : 8;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (rem.startsWith('<answer>') || rem.startsWith('</answer>')) {
|
|
218
|
+
processedIndex += rem.startsWith('<answer>') ? 8 : 9;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (rem.startsWith('<source-chip')) {
|
|
222
|
+
const close = rem.indexOf('/>');
|
|
223
|
+
if (close !== -1) {
|
|
224
|
+
processedIndex += close + 2;
|
|
225
|
+
continue;
|
|
226
|
+
} else break;
|
|
227
|
+
}
|
|
228
|
+
if (rem.startsWith('<') && !rem.includes('>')) break;
|
|
229
|
+
processedIndex++;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} catch {}
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
res.data.on('end', () => {
|
|
238
|
+
const cleaned = this.cleanText(
|
|
239
|
+
fullText
|
|
240
|
+
.replace(/<plan>[\s\S]*?<\/plan>/g, '')
|
|
241
|
+
.replace(/<think>[\s\S]*?<\/think>/g, '')
|
|
242
|
+
.replace(/<source-chip[^>]*\/>/g, '')
|
|
243
|
+
.replace(/<\/?[a-zA-Z0-9_-]+[^>]*>/g, '')
|
|
244
|
+
);
|
|
245
|
+
resolve({
|
|
246
|
+
text: cleaned,
|
|
247
|
+
conversationId: convId,
|
|
248
|
+
parentMessageId: messageId
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
res.data.on('error', reject);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { SakanaAI };
|
package/lib/ttRepost.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TIKTOK REPOST VIEWER
|
|
3
|
+
* base: https://petdii.com
|
|
4
|
+
* Ambil daftar video yang di-repost oleh sebuah akun TikTok.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const axios = require('axios');
|
|
8
|
+
|
|
9
|
+
const BASE_URL = 'https://petdii.com';
|
|
10
|
+
|
|
11
|
+
const HEADERS = {
|
|
12
|
+
'accept': 'application/json, text/plain, */*',
|
|
13
|
+
'accept-language': 'id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
14
|
+
'cache-control': 'no-cache',
|
|
15
|
+
'content-type': 'application/json',
|
|
16
|
+
'pragma': 'no-cache',
|
|
17
|
+
'referer': 'https://petdii.com/tiktok-repost-viewer',
|
|
18
|
+
'user-agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36'
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function kirimRequest(username, jumlah, cursor) {
|
|
22
|
+
const { data: respons } = await axios.post(
|
|
23
|
+
`${BASE_URL}/api/tiktok/get-reposts`,
|
|
24
|
+
{ unique_id: username, count: jumlah, cursor },
|
|
25
|
+
{ headers: HEADERS }
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (respons.code !== 0) throw new Error(respons.msg || 'Gagal ambil data');
|
|
29
|
+
|
|
30
|
+
return respons;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ambil satu halaman repost (default 6 video per halaman).
|
|
35
|
+
*/
|
|
36
|
+
async function ambilRepost(username, jumlah = 6, cursor = 0) {
|
|
37
|
+
const respons = await kirimRequest(username, jumlah, cursor);
|
|
38
|
+
const { videos, cursor: cursorBaru, hasMore } = respons.data;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
username,
|
|
42
|
+
cursor: cursorBaru,
|
|
43
|
+
adaLagi: hasMore,
|
|
44
|
+
total: videos.length,
|
|
45
|
+
videos: videos.map((v) => ({
|
|
46
|
+
video_id: v.video_id,
|
|
47
|
+
aweme_id: v.aweme_id,
|
|
48
|
+
judul: v.title,
|
|
49
|
+
region: v.region,
|
|
50
|
+
durasi: v.duration,
|
|
51
|
+
cover: v.cover,
|
|
52
|
+
play_tanpa_watermark: v.play,
|
|
53
|
+
play_dengan_watermark: v.wmplay,
|
|
54
|
+
ukuran_bytes: v.size,
|
|
55
|
+
dibuat: new Date(v.create_time * 1000).toISOString(),
|
|
56
|
+
stats: {
|
|
57
|
+
ditonton: v.play_count,
|
|
58
|
+
disukai: v.digg_count,
|
|
59
|
+
komentar: v.comment_count,
|
|
60
|
+
dibagikan: v.share_count,
|
|
61
|
+
diunduh: v.download_count,
|
|
62
|
+
disimpan: v.collect_count,
|
|
63
|
+
},
|
|
64
|
+
pembuat: {
|
|
65
|
+
id: v.author.id,
|
|
66
|
+
username: v.author.unique_id,
|
|
67
|
+
nama: v.author.nickname,
|
|
68
|
+
foto: v.author.avatar,
|
|
69
|
+
},
|
|
70
|
+
musik_info: {
|
|
71
|
+
id: v.music_info.id,
|
|
72
|
+
judul: v.music_info.title,
|
|
73
|
+
artis: v.music_info.author,
|
|
74
|
+
play: v.music_info.play,
|
|
75
|
+
cover: v.music_info.cover,
|
|
76
|
+
durasi: v.music_info.duration,
|
|
77
|
+
original: v.music_info.original,
|
|
78
|
+
},
|
|
79
|
+
})),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Ambil semua repost sampe `maxHalaman` halaman (atau sampe habis, mana yang duluan).
|
|
85
|
+
*/
|
|
86
|
+
async function ambilSemuaRepost(username, maxHalaman = 5) {
|
|
87
|
+
let cursor = 0;
|
|
88
|
+
let adaLagi = true;
|
|
89
|
+
let halamanKe = 0;
|
|
90
|
+
let semuaVideo = [];
|
|
91
|
+
|
|
92
|
+
while (adaLagi && halamanKe < maxHalaman) {
|
|
93
|
+
const hasil = await ambilRepost(username, 6, cursor);
|
|
94
|
+
semuaVideo = semuaVideo.concat(hasil.videos);
|
|
95
|
+
cursor = hasil.cursor;
|
|
96
|
+
adaLagi = hasil.adaLagi;
|
|
97
|
+
halamanKe++;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
username,
|
|
102
|
+
totalVideo: semuaVideo.length,
|
|
103
|
+
halamanDiambil: halamanKe,
|
|
104
|
+
adaLagiDiServer: adaLagi,
|
|
105
|
+
videos: semuaVideo,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { ambilRepost, ambilSemuaRepost };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* X / TWITTER DOWNLOADER
|
|
3
|
+
* Scrape metadata + video URL dari post X/Twitter (og:tags + inline script parsing).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const axios = require('axios');
|
|
7
|
+
const cheerio = require('cheerio');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const HEADERS = {
|
|
12
|
+
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36',
|
|
13
|
+
'Accept-Language': 'id-ID,id;q=0.9,en;q=0.8',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Ambil metadata (title, thumbnail, videoUrl, mediaType) dari URL post X/Twitter.
|
|
18
|
+
*/
|
|
19
|
+
async function getXInfo(url) {
|
|
20
|
+
const response = await axios.get(url, {
|
|
21
|
+
headers: HEADERS,
|
|
22
|
+
maxRedirects: 10
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const html = response.data;
|
|
26
|
+
const $ = cheerio.load(html);
|
|
27
|
+
|
|
28
|
+
const title = $('meta[property="og:title"]').attr('content') || $('title').text().trim();
|
|
29
|
+
const description = $('meta[property="og:description"]').attr('content');
|
|
30
|
+
const thumbnail = $('meta[property="og:image"]').attr('content');
|
|
31
|
+
|
|
32
|
+
let videoUrl = $('meta[property="og:video"]').attr('content') ||
|
|
33
|
+
$('meta[property="og:video:url"]').attr('content');
|
|
34
|
+
|
|
35
|
+
if (!videoUrl) {
|
|
36
|
+
$('script').each((_, el) => {
|
|
37
|
+
const content = $(el).html() || '';
|
|
38
|
+
|
|
39
|
+
let match = content.match(/"video_url":"(https:\/\/[^"]+\.mp4[^"]*)"/) ||
|
|
40
|
+
content.match(/"(https:\/\/video\.twimg\.com\/[^"]+\.mp4[^"]*)"/) ||
|
|
41
|
+
content.match(/"content_url":"(https:\/\/[^"]+\.mp4[^"]*)"/);
|
|
42
|
+
|
|
43
|
+
if (match) {
|
|
44
|
+
videoUrl = match[1].replace(/\\u0026/g, '&');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!videoUrl && content.includes('amplify_video')) {
|
|
48
|
+
const ampMatch = content.match(/"url":"(https:\/\/[^"]+amplify_video[^"]+\.mp4[^"]*)"/);
|
|
49
|
+
if (ampMatch) videoUrl = ampMatch[1].replace(/\\u0026/g, '&');
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let mediaType = 'text';
|
|
55
|
+
if (videoUrl) mediaType = 'video';
|
|
56
|
+
else if (thumbnail && (thumbnail.includes('media') || thumbnail.includes('twimg'))) {
|
|
57
|
+
mediaType = 'image';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
platform: 'X/Twitter',
|
|
62
|
+
title: title ? title.substring(0, 200) : 'Twitter Post',
|
|
63
|
+
description: description || '',
|
|
64
|
+
videoUrl: videoUrl || null,
|
|
65
|
+
thumbnail: thumbnail || null,
|
|
66
|
+
mediaType,
|
|
67
|
+
originalUrl: url
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Download video dari post X/Twitter ke disk (opsional, buat dipake di luar konteks bot
|
|
73
|
+
* kalo emang butuh nyimpen file lokal — kalo cuma mau kirim ulang via WA, cukup pake
|
|
74
|
+
* getXInfo().videoUrl langsung tanpa nge-download ke disk).
|
|
75
|
+
*/
|
|
76
|
+
async function downloadX(url, outputDir = './downloads_x') {
|
|
77
|
+
const info = await getXInfo(url);
|
|
78
|
+
|
|
79
|
+
if (!info.videoUrl) {
|
|
80
|
+
throw new Error('Tidak menemukan video di tweet ini (bisa jadi hanya gambar atau teks).');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
|
|
84
|
+
|
|
85
|
+
const cleanTitle = info.title.replace(/[^a-zA-Z0-9\s]/g, '').trim().substring(0, 60) || 'x_video';
|
|
86
|
+
const fileName = `${cleanTitle}.mp4`;
|
|
87
|
+
const outputPath = path.join(outputDir, fileName);
|
|
88
|
+
|
|
89
|
+
const response = await axios({
|
|
90
|
+
method: 'GET',
|
|
91
|
+
url: info.videoUrl,
|
|
92
|
+
responseType: 'stream',
|
|
93
|
+
headers: { 'User-Agent': HEADERS['User-Agent'] }
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const writer = fs.createWriteStream(outputPath);
|
|
97
|
+
response.data.pipe(writer);
|
|
98
|
+
|
|
99
|
+
await new Promise((resolve, reject) => {
|
|
100
|
+
writer.on('finish', resolve);
|
|
101
|
+
writer.on('error', reject);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return { ...info, filePath: outputPath };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
module.exports = { getXInfo, downloadX };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paiavaliable/scrape-lib",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Kumpulan scraper/downloader utility: YT Music search, YT downloader, AIO social media downloader, link bypass, HD image upscaler, black & white image filter.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"axios": "^1.7.2",
|
|
25
|
+
"cheerio": "^1.0.0",
|
|
25
26
|
"form-data": "^4.0.0"
|
|
26
27
|
},
|
|
27
28
|
"files": [
|