@mmmbuto/masix 0.4.1 → 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.
@@ -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("&amp;", "&")
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
+ }