@mmmbuto/masix 0.4.2 → 0.4.3
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 +2 -2
- package/package.json +1 -1
- package/packages/plugin-base/discovery/0.2.4/manifest.json +7 -0
- package/packages/plugin-base/discovery/0.3.0/SHA256SUMS +2 -0
- package/packages/plugin-base/discovery/0.3.0/discovery-android-aarch64-termux.pkg +0 -0
- package/packages/plugin-base/discovery/0.3.0/discovery-linux-x86_64.pkg +0 -0
- package/packages/plugin-base/discovery/0.3.0/manifest.json +34 -0
- package/packages/plugin-base/discovery/CHANGELOG.md +7 -0
- package/packages/plugin-base/discovery/README.md +19 -10
- package/packages/plugin-base/discovery/source/Cargo.toml +3 -2
- package/packages/plugin-base/discovery/source/plugin.manifest.json +9 -4
- package/packages/plugin-base/discovery/source/src/doh.rs +103 -0
- package/packages/plugin-base/discovery/source/src/magnet_cache.rs +113 -0
- package/packages/plugin-base/discovery/source/src/main.rs +769 -49
- package/packages/plugin-base/discovery/source/src/torrent.rs +701 -0
- package/packages/plugin-base/discovery/source/src/transport.rs +112 -0
- package/prebuilt/masix +0 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
use crate::doh::DohResolver;
|
|
2
|
+
use crate::magnet_cache::MagnetCache;
|
|
3
|
+
use crate::transport::TransportLayer;
|
|
4
|
+
use anyhow::{anyhow, Result};
|
|
5
|
+
use scraper::{Html, Selector};
|
|
6
|
+
use serde::Serialize;
|
|
7
|
+
use std::collections::HashSet;
|
|
8
|
+
|
|
9
|
+
#[derive(Debug, Clone)]
|
|
10
|
+
pub struct TorrentProvider {
|
|
11
|
+
pub id: &'static str,
|
|
12
|
+
pub display_name: &'static str,
|
|
13
|
+
pub clearnet_domains: &'static [&'static str],
|
|
14
|
+
pub proxy_urls: &'static [&'static str],
|
|
15
|
+
pub onion_urls: &'static [&'static str],
|
|
16
|
+
parser: ProviderParser,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
#[derive(Debug, Clone, Copy)]
|
|
20
|
+
enum ProviderParser {
|
|
21
|
+
GenericPath(&'static str),
|
|
22
|
+
PirateBay,
|
|
23
|
+
YtsJson,
|
|
24
|
+
EztvPath,
|
|
25
|
+
SolidTorrents,
|
|
26
|
+
Bt4g,
|
|
27
|
+
Torrentz2,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
#[derive(Debug, Clone, Serialize)]
|
|
31
|
+
pub struct ProviderStatus {
|
|
32
|
+
pub id: String,
|
|
33
|
+
pub name: String,
|
|
34
|
+
pub supports_clearnet: bool,
|
|
35
|
+
pub supports_proxy: bool,
|
|
36
|
+
pub supports_onion: bool,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Debug, Clone)]
|
|
40
|
+
pub struct MirrorTorrentResult {
|
|
41
|
+
pub title: String,
|
|
42
|
+
pub url: String,
|
|
43
|
+
pub content: String,
|
|
44
|
+
pub provider: String,
|
|
45
|
+
pub via: String,
|
|
46
|
+
pub source_url: String,
|
|
47
|
+
pub magnet_links: Vec<String>,
|
|
48
|
+
pub seeds: Option<u32>,
|
|
49
|
+
pub leeches: Option<u32>,
|
|
50
|
+
pub size: Option<String>,
|
|
51
|
+
pub uploader: Option<String>,
|
|
52
|
+
pub category: Option<String>,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
pub fn bundled_providers() -> Vec<TorrentProvider> {
|
|
56
|
+
vec![
|
|
57
|
+
TorrentProvider {
|
|
58
|
+
id: "1337x",
|
|
59
|
+
display_name: "1337x",
|
|
60
|
+
clearnet_domains: &[
|
|
61
|
+
"https://1337x.to",
|
|
62
|
+
"https://1337x.st",
|
|
63
|
+
"https://x1337x.se",
|
|
64
|
+
"https://1337xx.to",
|
|
65
|
+
],
|
|
66
|
+
proxy_urls: &[
|
|
67
|
+
"https://unblockit.bz/1337x",
|
|
68
|
+
"https://unblockit.id/1337x",
|
|
69
|
+
"https://nocensor.lol/1337x",
|
|
70
|
+
],
|
|
71
|
+
onion_urls: &[],
|
|
72
|
+
parser: ProviderParser::GenericPath("/search/{query}/1/"),
|
|
73
|
+
},
|
|
74
|
+
TorrentProvider {
|
|
75
|
+
id: "thepiratebay",
|
|
76
|
+
display_name: "ThePirateBay",
|
|
77
|
+
clearnet_domains: &[
|
|
78
|
+
"https://thepiratebay.org",
|
|
79
|
+
"https://thepiratesbay.com",
|
|
80
|
+
"https://tpb.party",
|
|
81
|
+
],
|
|
82
|
+
proxy_urls: &["https://unblockit.bz/tpb", "https://nocensor.lol/tpb"],
|
|
83
|
+
onion_urls: &[],
|
|
84
|
+
parser: ProviderParser::PirateBay,
|
|
85
|
+
},
|
|
86
|
+
TorrentProvider {
|
|
87
|
+
id: "yts",
|
|
88
|
+
display_name: "YTS",
|
|
89
|
+
clearnet_domains: &["https://yts.mx", "https://yts.lt", "https://yts.am"],
|
|
90
|
+
proxy_urls: &[],
|
|
91
|
+
onion_urls: &[],
|
|
92
|
+
parser: ProviderParser::YtsJson,
|
|
93
|
+
},
|
|
94
|
+
TorrentProvider {
|
|
95
|
+
id: "torrentgalaxy",
|
|
96
|
+
display_name: "TorrentGalaxy",
|
|
97
|
+
clearnet_domains: &["https://torrentgalaxy.to", "https://torrentgalaxy.mx"],
|
|
98
|
+
proxy_urls: &[],
|
|
99
|
+
onion_urls: &[],
|
|
100
|
+
parser: ProviderParser::GenericPath("/torrents.php?search={query}"),
|
|
101
|
+
},
|
|
102
|
+
TorrentProvider {
|
|
103
|
+
id: "eztv",
|
|
104
|
+
display_name: "EZTV",
|
|
105
|
+
clearnet_domains: &["https://eztv.re", "https://eztv.ag", "https://eztv.io"],
|
|
106
|
+
proxy_urls: &[],
|
|
107
|
+
onion_urls: &[],
|
|
108
|
+
parser: ProviderParser::EztvPath,
|
|
109
|
+
},
|
|
110
|
+
TorrentProvider {
|
|
111
|
+
id: "kickass",
|
|
112
|
+
display_name: "Kickass",
|
|
113
|
+
clearnet_domains: &[
|
|
114
|
+
"https://kickasstorrents.to",
|
|
115
|
+
"https://katcr.to",
|
|
116
|
+
"https://kat.sx",
|
|
117
|
+
],
|
|
118
|
+
proxy_urls: &[],
|
|
119
|
+
onion_urls: &[],
|
|
120
|
+
parser: ProviderParser::GenericPath("/usearch/{query}/"),
|
|
121
|
+
},
|
|
122
|
+
TorrentProvider {
|
|
123
|
+
id: "limetorrents",
|
|
124
|
+
display_name: "LimeTorrents",
|
|
125
|
+
clearnet_domains: &[
|
|
126
|
+
"https://www.limetorrents.lol",
|
|
127
|
+
"https://www.limetorrents.info",
|
|
128
|
+
],
|
|
129
|
+
proxy_urls: &[],
|
|
130
|
+
onion_urls: &[],
|
|
131
|
+
parser: ProviderParser::GenericPath("/search/all/{query}/"),
|
|
132
|
+
},
|
|
133
|
+
TorrentProvider {
|
|
134
|
+
id: "solidtorrents",
|
|
135
|
+
display_name: "SolidTorrents",
|
|
136
|
+
clearnet_domains: &["https://solidtorrents.to"],
|
|
137
|
+
proxy_urls: &[],
|
|
138
|
+
onion_urls: &[],
|
|
139
|
+
parser: ProviderParser::SolidTorrents,
|
|
140
|
+
},
|
|
141
|
+
TorrentProvider {
|
|
142
|
+
id: "bt4g",
|
|
143
|
+
display_name: "BT4G",
|
|
144
|
+
clearnet_domains: &["https://bt4gprx.com", "https://bt4g.org"],
|
|
145
|
+
proxy_urls: &[],
|
|
146
|
+
onion_urls: &[],
|
|
147
|
+
parser: ProviderParser::Bt4g,
|
|
148
|
+
},
|
|
149
|
+
TorrentProvider {
|
|
150
|
+
id: "torrentz2",
|
|
151
|
+
display_name: "Torrentz2",
|
|
152
|
+
clearnet_domains: &["https://torrentz2eu.org"],
|
|
153
|
+
proxy_urls: &[],
|
|
154
|
+
onion_urls: &[],
|
|
155
|
+
parser: ProviderParser::Torrentz2,
|
|
156
|
+
},
|
|
157
|
+
]
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
pub fn provider_statuses(providers: &[TorrentProvider]) -> Vec<ProviderStatus> {
|
|
161
|
+
providers
|
|
162
|
+
.iter()
|
|
163
|
+
.map(|provider| ProviderStatus {
|
|
164
|
+
id: provider.id.to_string(),
|
|
165
|
+
name: provider.display_name.to_string(),
|
|
166
|
+
supports_clearnet: !provider.clearnet_domains.is_empty(),
|
|
167
|
+
supports_proxy: !provider.proxy_urls.is_empty(),
|
|
168
|
+
supports_onion: !provider.onion_urls.is_empty(),
|
|
169
|
+
})
|
|
170
|
+
.collect()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
pub async fn search_mirror_catalog(
|
|
174
|
+
providers: &[TorrentProvider],
|
|
175
|
+
transport: &TransportLayer,
|
|
176
|
+
doh: &DohResolver,
|
|
177
|
+
cache: Option<&MagnetCache>,
|
|
178
|
+
query: &str,
|
|
179
|
+
max_results: usize,
|
|
180
|
+
allowed: Option<&HashSet<String>>,
|
|
181
|
+
use_tor: bool,
|
|
182
|
+
) -> Result<Vec<MirrorTorrentResult>> {
|
|
183
|
+
let mut output = Vec::new();
|
|
184
|
+
let normalized_allowed = allowed.map(|items| {
|
|
185
|
+
items
|
|
186
|
+
.iter()
|
|
187
|
+
.map(|value| value.trim().to_lowercase())
|
|
188
|
+
.collect::<HashSet<_>>()
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
for provider in providers {
|
|
192
|
+
if let Some(allowed) = &normalized_allowed {
|
|
193
|
+
if !allowed.contains(&provider.id.to_lowercase())
|
|
194
|
+
&& !allowed.contains(&provider.display_name.to_lowercase())
|
|
195
|
+
{
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
match search_single_provider(provider, transport, doh, cache, query, max_results, use_tor)
|
|
201
|
+
.await
|
|
202
|
+
{
|
|
203
|
+
Ok(mut results) => output.append(&mut results),
|
|
204
|
+
Err(_) => continue,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if output.is_empty() {
|
|
209
|
+
Err(anyhow!("mirror catalog returned no results"))
|
|
210
|
+
} else {
|
|
211
|
+
Ok(output)
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async fn search_single_provider(
|
|
216
|
+
provider: &TorrentProvider,
|
|
217
|
+
transport: &TransportLayer,
|
|
218
|
+
doh: &DohResolver,
|
|
219
|
+
cache: Option<&MagnetCache>,
|
|
220
|
+
query: &str,
|
|
221
|
+
max_results: usize,
|
|
222
|
+
use_tor: bool,
|
|
223
|
+
) -> Result<Vec<MirrorTorrentResult>> {
|
|
224
|
+
let mut routes = Vec::new();
|
|
225
|
+
if use_tor && transport.tor_available() {
|
|
226
|
+
for onion in provider.onion_urls {
|
|
227
|
+
routes.push(((*onion).to_string(), "onion".to_string(), true));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
for proxy in provider.proxy_urls {
|
|
231
|
+
routes.push(((*proxy).to_string(), "proxy".to_string(), false));
|
|
232
|
+
}
|
|
233
|
+
for domain in provider.clearnet_domains {
|
|
234
|
+
let _ = doh.resolve_with_fallback(extract_host(domain)).await;
|
|
235
|
+
routes.push(((*domain).to_string(), "clearnet".to_string(), false));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let mut last_error: Option<anyhow::Error> = None;
|
|
239
|
+
for (base, via, prefer_tor) in routes {
|
|
240
|
+
match fetch_provider_results(
|
|
241
|
+
provider,
|
|
242
|
+
transport,
|
|
243
|
+
cache,
|
|
244
|
+
query,
|
|
245
|
+
max_results,
|
|
246
|
+
&base,
|
|
247
|
+
&via,
|
|
248
|
+
prefer_tor,
|
|
249
|
+
)
|
|
250
|
+
.await
|
|
251
|
+
{
|
|
252
|
+
Ok(results) if !results.is_empty() => return Ok(results),
|
|
253
|
+
Ok(_) => continue,
|
|
254
|
+
Err(err) => last_error = Some(err),
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
Err(last_error.unwrap_or_else(|| anyhow!("provider {} returned no results", provider.id)))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async fn fetch_provider_results(
|
|
262
|
+
provider: &TorrentProvider,
|
|
263
|
+
transport: &TransportLayer,
|
|
264
|
+
cache: Option<&MagnetCache>,
|
|
265
|
+
query: &str,
|
|
266
|
+
max_results: usize,
|
|
267
|
+
base_url: &str,
|
|
268
|
+
via: &str,
|
|
269
|
+
prefer_tor: bool,
|
|
270
|
+
) -> Result<Vec<MirrorTorrentResult>> {
|
|
271
|
+
match provider.parser {
|
|
272
|
+
ProviderParser::YtsJson => {
|
|
273
|
+
fetch_yts(
|
|
274
|
+
provider,
|
|
275
|
+
transport,
|
|
276
|
+
query,
|
|
277
|
+
max_results,
|
|
278
|
+
base_url,
|
|
279
|
+
via,
|
|
280
|
+
prefer_tor,
|
|
281
|
+
)
|
|
282
|
+
.await
|
|
283
|
+
}
|
|
284
|
+
parser => {
|
|
285
|
+
fetch_html(
|
|
286
|
+
provider,
|
|
287
|
+
parser,
|
|
288
|
+
transport,
|
|
289
|
+
cache,
|
|
290
|
+
query,
|
|
291
|
+
max_results,
|
|
292
|
+
base_url,
|
|
293
|
+
via,
|
|
294
|
+
prefer_tor,
|
|
295
|
+
)
|
|
296
|
+
.await
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async fn fetch_html(
|
|
302
|
+
provider: &TorrentProvider,
|
|
303
|
+
parser: ProviderParser,
|
|
304
|
+
transport: &TransportLayer,
|
|
305
|
+
cache: Option<&MagnetCache>,
|
|
306
|
+
query: &str,
|
|
307
|
+
max_results: usize,
|
|
308
|
+
base_url: &str,
|
|
309
|
+
via: &str,
|
|
310
|
+
prefer_tor: bool,
|
|
311
|
+
) -> Result<Vec<MirrorTorrentResult>> {
|
|
312
|
+
let search_url = build_search_url(base_url, parser, query);
|
|
313
|
+
let (client, resolved_via) = transport.client_for_preference(prefer_tor);
|
|
314
|
+
let response = client.get(&search_url).send().await?;
|
|
315
|
+
if !response.status().is_success() {
|
|
316
|
+
return Err(anyhow!(
|
|
317
|
+
"provider {} returned HTTP {}",
|
|
318
|
+
provider.id,
|
|
319
|
+
response.status()
|
|
320
|
+
));
|
|
321
|
+
}
|
|
322
|
+
let html = response.text().await?;
|
|
323
|
+
parse_html_results(
|
|
324
|
+
provider,
|
|
325
|
+
parser,
|
|
326
|
+
cache,
|
|
327
|
+
client,
|
|
328
|
+
&html,
|
|
329
|
+
base_url,
|
|
330
|
+
&search_url,
|
|
331
|
+
via_or(resolved_via, via),
|
|
332
|
+
max_results,
|
|
333
|
+
)
|
|
334
|
+
.await
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async fn fetch_yts(
|
|
338
|
+
provider: &TorrentProvider,
|
|
339
|
+
transport: &TransportLayer,
|
|
340
|
+
query: &str,
|
|
341
|
+
max_results: usize,
|
|
342
|
+
base_url: &str,
|
|
343
|
+
via: &str,
|
|
344
|
+
prefer_tor: bool,
|
|
345
|
+
) -> Result<Vec<MirrorTorrentResult>> {
|
|
346
|
+
let url = format!(
|
|
347
|
+
"{}/api/v2/list_movies.json?query_term={}&limit={}",
|
|
348
|
+
base_url.trim_end_matches('/'),
|
|
349
|
+
url_encode(query),
|
|
350
|
+
max_results.min(20).max(1)
|
|
351
|
+
);
|
|
352
|
+
let (client, resolved_via) = transport.client_for_preference(prefer_tor);
|
|
353
|
+
let response = client.get(&url).send().await?;
|
|
354
|
+
if !response.status().is_success() {
|
|
355
|
+
return Err(anyhow!(
|
|
356
|
+
"provider {} returned HTTP {}",
|
|
357
|
+
provider.id,
|
|
358
|
+
response.status()
|
|
359
|
+
));
|
|
360
|
+
}
|
|
361
|
+
let payload: serde_json::Value = response.json().await?;
|
|
362
|
+
let mut output = Vec::new();
|
|
363
|
+
for movie in payload
|
|
364
|
+
.get("data")
|
|
365
|
+
.and_then(|v| v.get("movies"))
|
|
366
|
+
.and_then(|v| v.as_array())
|
|
367
|
+
.into_iter()
|
|
368
|
+
.flatten()
|
|
369
|
+
.take(max_results.min(20))
|
|
370
|
+
{
|
|
371
|
+
let title = movie
|
|
372
|
+
.get("title_long")
|
|
373
|
+
.and_then(|v| v.as_str())
|
|
374
|
+
.unwrap_or("YTS result");
|
|
375
|
+
let source_url = movie
|
|
376
|
+
.get("url")
|
|
377
|
+
.and_then(|v| v.as_str())
|
|
378
|
+
.unwrap_or(base_url);
|
|
379
|
+
let summary = movie
|
|
380
|
+
.get("summary")
|
|
381
|
+
.and_then(|v| v.as_str())
|
|
382
|
+
.unwrap_or("YTS API result");
|
|
383
|
+
let mut magnets = Vec::new();
|
|
384
|
+
let mut seeds = None;
|
|
385
|
+
let mut size = None;
|
|
386
|
+
if let Some(torrents) = movie.get("torrents").and_then(|v| v.as_array()) {
|
|
387
|
+
if let Some(first) = torrents.first() {
|
|
388
|
+
if let Some(hash) = first.get("hash").and_then(|v| v.as_str()) {
|
|
389
|
+
magnets.push(format!("magnet:?xt=urn:btih:{hash}"));
|
|
390
|
+
}
|
|
391
|
+
seeds = first
|
|
392
|
+
.get("seeds")
|
|
393
|
+
.and_then(|v| v.as_u64())
|
|
394
|
+
.map(|v| v as u32);
|
|
395
|
+
size = first
|
|
396
|
+
.get("size")
|
|
397
|
+
.and_then(|v| v.as_str())
|
|
398
|
+
.map(|v| v.to_string());
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
output.push(MirrorTorrentResult {
|
|
402
|
+
title: title.to_string(),
|
|
403
|
+
url: source_url.to_string(),
|
|
404
|
+
content: truncate_text(summary, 700),
|
|
405
|
+
provider: provider.id.to_string(),
|
|
406
|
+
via: via_or(resolved_via, via).to_string(),
|
|
407
|
+
source_url: url.clone(),
|
|
408
|
+
magnet_links: magnets,
|
|
409
|
+
seeds,
|
|
410
|
+
leeches: None,
|
|
411
|
+
size,
|
|
412
|
+
uploader: None,
|
|
413
|
+
category: None,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
Ok(output)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async fn parse_html_results(
|
|
420
|
+
provider: &TorrentProvider,
|
|
421
|
+
parser: ProviderParser,
|
|
422
|
+
cache: Option<&MagnetCache>,
|
|
423
|
+
client: &reqwest::Client,
|
|
424
|
+
html: &str,
|
|
425
|
+
base_url: &str,
|
|
426
|
+
source_url: &str,
|
|
427
|
+
via: &str,
|
|
428
|
+
max_results: usize,
|
|
429
|
+
) -> Result<Vec<MirrorTorrentResult>> {
|
|
430
|
+
let document = Html::parse_document(html);
|
|
431
|
+
let row_selector = match parser {
|
|
432
|
+
ProviderParser::PirateBay => "table#searchResult tr",
|
|
433
|
+
ProviderParser::EztvPath => {
|
|
434
|
+
"table.forum_header_border tr.forum_header_border, tr.forum_header_border"
|
|
435
|
+
}
|
|
436
|
+
ProviderParser::SolidTorrents => "div.card.search-result, div.search-result",
|
|
437
|
+
ProviderParser::Bt4g => "div.result-item, div.one_result, li",
|
|
438
|
+
ProviderParser::Torrentz2 => "dl, div.results dl",
|
|
439
|
+
_ => "table tbody tr, tr.odd, tr.even, div.result, article, li",
|
|
440
|
+
};
|
|
441
|
+
let row_selector =
|
|
442
|
+
Selector::parse(row_selector).map_err(|e| anyhow!("Invalid row selector: {}", e))?;
|
|
443
|
+
let link_selector =
|
|
444
|
+
Selector::parse("a").map_err(|e| anyhow!("Invalid link selector: {}", e))?;
|
|
445
|
+
let magnet_selector = Selector::parse("a[href^=\"magnet:?\"]")
|
|
446
|
+
.map_err(|e| anyhow!("Invalid magnet selector: {}", e))?;
|
|
447
|
+
|
|
448
|
+
let mut output = Vec::new();
|
|
449
|
+
let mut seen = HashSet::new();
|
|
450
|
+
|
|
451
|
+
for row in document.select(&row_selector) {
|
|
452
|
+
if output.len() >= max_results.min(20) {
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
let row_text = compact_text(&row.text().collect::<Vec<_>>().join(" "));
|
|
456
|
+
if row_text.is_empty() {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
let mut title = String::new();
|
|
461
|
+
let mut detail_url = None;
|
|
462
|
+
for link in row.select(&link_selector) {
|
|
463
|
+
let text = compact_text(&link.text().collect::<Vec<_>>().join(" "));
|
|
464
|
+
let href = link.value().attr("href").unwrap_or("").trim();
|
|
465
|
+
if href.is_empty() || text.len() < 3 {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if href.starts_with("magnet:?") {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
title = text;
|
|
472
|
+
detail_url = Some(resolve_href(base_url, href));
|
|
473
|
+
break;
|
|
474
|
+
}
|
|
475
|
+
let Some(detail_url) = detail_url else {
|
|
476
|
+
continue;
|
|
477
|
+
};
|
|
478
|
+
if !seen.insert(detail_url.clone()) {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let mut magnet_links = Vec::new();
|
|
483
|
+
for magnet in row.select(&magnet_selector) {
|
|
484
|
+
if let Some(href) = magnet.value().attr("href") {
|
|
485
|
+
let cleaned = sanitize_magnet(href);
|
|
486
|
+
if cleaned.starts_with("magnet:?") {
|
|
487
|
+
magnet_links.push(cleaned);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
magnet_links.truncate(3);
|
|
492
|
+
|
|
493
|
+
if magnet_links.is_empty() {
|
|
494
|
+
if let Some(cache) = cache {
|
|
495
|
+
if let Some(cached) = cache.get(&detail_url).await {
|
|
496
|
+
magnet_links.push(cached);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if magnet_links.is_empty() {
|
|
502
|
+
let fetched = fetch_detail_magnets(client, &detail_url, 3)
|
|
503
|
+
.await
|
|
504
|
+
.unwrap_or_default();
|
|
505
|
+
if let Some(first) = fetched.first() {
|
|
506
|
+
if let Some(cache) = cache {
|
|
507
|
+
let _ = cache.set(&detail_url, first).await;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
magnet_links = fetched;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let seeds = extract_number(&row_text);
|
|
514
|
+
output.push(MirrorTorrentResult {
|
|
515
|
+
title: if title.is_empty() {
|
|
516
|
+
format!("{} result", provider.display_name)
|
|
517
|
+
} else {
|
|
518
|
+
title
|
|
519
|
+
},
|
|
520
|
+
url: detail_url,
|
|
521
|
+
content: truncate_text(&row_text, 700),
|
|
522
|
+
provider: provider.id.to_string(),
|
|
523
|
+
via: via.to_string(),
|
|
524
|
+
source_url: source_url.to_string(),
|
|
525
|
+
magnet_links,
|
|
526
|
+
seeds,
|
|
527
|
+
leeches: extract_second_number(&row_text, seeds),
|
|
528
|
+
size: extract_size(&row_text),
|
|
529
|
+
uploader: None,
|
|
530
|
+
category: None,
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
Ok(output)
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async fn fetch_detail_magnets(
|
|
538
|
+
client: &reqwest::Client,
|
|
539
|
+
url: &str,
|
|
540
|
+
max_links: usize,
|
|
541
|
+
) -> Result<Vec<String>> {
|
|
542
|
+
let response = client.get(url).send().await?;
|
|
543
|
+
if !response.status().is_success() {
|
|
544
|
+
return Ok(Vec::new());
|
|
545
|
+
}
|
|
546
|
+
let html = response.text().await.unwrap_or_default();
|
|
547
|
+
Ok(extract_magnets_from_text(&html, max_links))
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
fn extract_magnets_from_text(text: &str, max_links: usize) -> Vec<String> {
|
|
551
|
+
let mut links = Vec::new();
|
|
552
|
+
let mut seen = HashSet::new();
|
|
553
|
+
let mut idx = 0usize;
|
|
554
|
+
while idx < text.len() {
|
|
555
|
+
let Some(found) = text[idx..].find("magnet:?") else {
|
|
556
|
+
break;
|
|
557
|
+
};
|
|
558
|
+
let start = idx + found;
|
|
559
|
+
let remainder = &text[start..];
|
|
560
|
+
let end_rel = remainder
|
|
561
|
+
.find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>'))
|
|
562
|
+
.unwrap_or(remainder.len());
|
|
563
|
+
let raw = &remainder[..end_rel];
|
|
564
|
+
let cleaned = sanitize_magnet(raw);
|
|
565
|
+
if cleaned.starts_with("magnet:?") && seen.insert(cleaned.clone()) {
|
|
566
|
+
links.push(cleaned);
|
|
567
|
+
}
|
|
568
|
+
if links.len() >= max_links {
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
idx = start + end_rel;
|
|
572
|
+
}
|
|
573
|
+
links
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
fn build_search_url(base_url: &str, parser: ProviderParser, query: &str) -> String {
|
|
577
|
+
let encoded = url_encode(query);
|
|
578
|
+
let base = base_url.trim_end_matches('/');
|
|
579
|
+
match parser {
|
|
580
|
+
ProviderParser::GenericPath(pattern) => {
|
|
581
|
+
format!("{base}{}", pattern.replace("{query}", &encoded))
|
|
582
|
+
}
|
|
583
|
+
ProviderParser::PirateBay => format!("{base}/search/{encoded}/1/99/0"),
|
|
584
|
+
ProviderParser::EztvPath => format!("{base}/search/{encoded}"),
|
|
585
|
+
ProviderParser::SolidTorrents => format!("{base}/search?q={encoded}"),
|
|
586
|
+
ProviderParser::Bt4g => format!("{base}/search?q={encoded}"),
|
|
587
|
+
ProviderParser::Torrentz2 => format!("{base}/search?f={encoded}"),
|
|
588
|
+
ProviderParser::YtsJson => format!("{base}/api/v2/list_movies.json?query_term={encoded}"),
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
fn resolve_href(base_url: &str, href: &str) -> String {
|
|
593
|
+
if href.starts_with("http://") || href.starts_with("https://") || href.starts_with("magnet:?") {
|
|
594
|
+
href.to_string()
|
|
595
|
+
} else {
|
|
596
|
+
format!(
|
|
597
|
+
"{}/{}",
|
|
598
|
+
base_url.trim_end_matches('/'),
|
|
599
|
+
href.trim_start_matches('/')
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
fn compact_text(value: &str) -> String {
|
|
605
|
+
value.split_whitespace().collect::<Vec<_>>().join(" ")
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
fn extract_number(text: &str) -> Option<u32> {
|
|
609
|
+
text.split(|c: char| !c.is_ascii_digit())
|
|
610
|
+
.find_map(|chunk| chunk.parse::<u32>().ok())
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/// Extract the second standalone number from row text (typically leechers, after seeds).
|
|
614
|
+
fn extract_second_number(text: &str, first: Option<u32>) -> Option<u32> {
|
|
615
|
+
let first_val = first?;
|
|
616
|
+
let mut found_first = false;
|
|
617
|
+
for chunk in text.split(|c: char| !c.is_ascii_digit()) {
|
|
618
|
+
if let Ok(n) = chunk.parse::<u32>() {
|
|
619
|
+
if !found_first && n == first_val {
|
|
620
|
+
found_first = true;
|
|
621
|
+
} else if found_first && n != first_val {
|
|
622
|
+
return Some(n);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
None
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
fn extract_size(text: &str) -> Option<String> {
|
|
630
|
+
for token in text.split_whitespace() {
|
|
631
|
+
let lower = token.to_lowercase();
|
|
632
|
+
if lower.ends_with("gb")
|
|
633
|
+
|| lower.ends_with("mb")
|
|
634
|
+
|| lower.ends_with("kb")
|
|
635
|
+
|| lower.ends_with("tb")
|
|
636
|
+
{
|
|
637
|
+
return Some(
|
|
638
|
+
token
|
|
639
|
+
.trim_matches(|c: char| c == ',' || c == ';')
|
|
640
|
+
.to_string(),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
None
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
fn sanitize_magnet(value: &str) -> String {
|
|
648
|
+
value
|
|
649
|
+
.trim()
|
|
650
|
+
.trim_matches('"')
|
|
651
|
+
.trim_matches('\'')
|
|
652
|
+
.replace("&", "&")
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
fn truncate_text(value: &str, max_chars: usize) -> String {
|
|
656
|
+
if value.chars().count() <= max_chars {
|
|
657
|
+
return value.to_string();
|
|
658
|
+
}
|
|
659
|
+
let mut out = String::new();
|
|
660
|
+
for (idx, ch) in value.chars().enumerate() {
|
|
661
|
+
if idx >= max_chars {
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
out.push(ch);
|
|
665
|
+
}
|
|
666
|
+
out.push_str("...");
|
|
667
|
+
out
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
fn extract_host(url: &str) -> &str {
|
|
671
|
+
url.trim_start_matches("https://")
|
|
672
|
+
.trim_start_matches("http://")
|
|
673
|
+
.split('/')
|
|
674
|
+
.next()
|
|
675
|
+
.unwrap_or(url)
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
fn url_encode(value: &str) -> String {
|
|
679
|
+
let mut out = String::new();
|
|
680
|
+
for b in value.as_bytes() {
|
|
681
|
+
let is_unreserved = matches!(
|
|
682
|
+
*b,
|
|
683
|
+
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~'
|
|
684
|
+
);
|
|
685
|
+
if is_unreserved {
|
|
686
|
+
out.push(*b as char);
|
|
687
|
+
} else {
|
|
688
|
+
use std::fmt::Write as _;
|
|
689
|
+
let _ = write!(&mut out, "%{:02X}", b);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
out
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
fn via_or<'a>(resolved: &'a str, fallback: &'a str) -> &'a str {
|
|
696
|
+
if resolved == "clearnet" {
|
|
697
|
+
fallback
|
|
698
|
+
} else {
|
|
699
|
+
resolved
|
|
700
|
+
}
|
|
701
|
+
}
|