1#![allow(dead_code)]
3
4extern crate alloc;
5use alloc::format;
6use alloc::string::{String, ToString};
7use alloc::vec::Vec;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DuckDuckGoVariant {
12 Standard,
14 Html,
16 Lite,
18}
19
20impl DuckDuckGoVariant {
21 pub fn as_str(&self) -> &'static str {
22 match self {
23 DuckDuckGoVariant::Standard => "Standard",
24 DuckDuckGoVariant::Html => "HTML",
25 DuckDuckGoVariant::Lite => "Lite",
26 }
27 }
28
29 pub fn host(&self) -> &'static str {
30 match self {
31 DuckDuckGoVariant::Standard => "duckduckgo.com",
32 DuckDuckGoVariant::Html => "html.duckduckgo.com",
33 DuckDuckGoVariant::Lite => "lite.duckduckgo.com",
34 }
35 }
36
37 pub fn search_path(&self) -> &'static str {
38 match self {
39 DuckDuckGoVariant::Standard => "/",
40 DuckDuckGoVariant::Html => "/html/",
41 DuckDuckGoVariant::Lite => "/lite/",
42 }
43 }
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum DuckDuckGoUrlType {
49 Home(DuckDuckGoVariant),
51 Search {
53 query: String,
54 offset: usize,
55 variant: DuckDuckGoVariant,
56 },
57 Redirect {
59 target_url: String,
60 },
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SearchResultItem {
66 pub title: String,
68 pub target_url: String,
70 pub display_url: String,
72 pub snippet: String,
74}
75
76pub fn is_duckduckgo_host(host: &str) -> bool {
78 let h = host.to_ascii_lowercase();
79 let clean_h = h.split(':').next().unwrap_or(&h).trim();
80 clean_h == "duckduckgo.com"
81 || clean_h == "www.duckduckgo.com"
82 || clean_h == "lite.duckduckgo.com"
83 || clean_h == "html.duckduckgo.com"
84}
85
86pub fn url_decode(s: &str) -> String {
88 let mut bytes_out = Vec::new();
89 let bytes = s.as_bytes();
90 let mut i = 0;
91 while i < bytes.len() {
92 if bytes[i] == b'%' && i + 2 < bytes.len() {
93 if let Ok(hex_str) = core::str::from_utf8(&bytes[i + 1..i + 3]) {
94 if let Ok(b) = u8::from_str_radix(hex_str, 16) {
95 bytes_out.push(b);
96 i += 3;
97 continue;
98 }
99 }
100 } else if bytes[i] == b'+' {
101 bytes_out.push(b' ');
102 i += 1;
103 continue;
104 }
105 bytes_out.push(bytes[i]);
106 i += 1;
107 }
108 match String::from_utf8(bytes_out) {
109 Ok(s) => s,
110 Err(e) => {
111 let b = e.into_bytes();
112 let mut s = String::new();
113 for chunk in b.utf8_chunks() {
114 s.push_str(chunk.valid());
115 }
116 s
117 }
118 }
119}
120
121pub fn extract_query_param(query_or_body: &str, key: &str) -> Option<String> {
123 let clean = if let Some(idx) = query_or_body.find('?') {
124 query_or_body.get(idx + 1..).unwrap_or("")
125 } else {
126 query_or_body
127 };
128 for pair in clean.split('&') {
129 if pair.is_empty() {
130 continue;
131 }
132 let mut kv = pair.splitn(2, '=');
133 let k = kv.next().unwrap_or("");
134 let v = kv.next().unwrap_or("");
135 if k == key {
136 return Some(url_decode(v));
137 }
138 }
139 None
140}
141
142pub fn url_encode(s: &str) -> String {
144 let mut out = String::new();
145 for b in s.bytes() {
146 if (b >= b'a' && b <= b'z')
147 || (b >= b'A' && b <= b'Z')
148 || (b >= b'0' && b <= b'9')
149 || b == b'-'
150 || b == b'_'
151 || b == b'.'
152 || b == b'~'
153 {
154 out.push(b as char);
155 } else if b == b' ' {
156 out.push('+');
157 } else {
158 out.push_str(&format!("%{:02X}", b));
159 }
160 }
161 out
162}
163
164pub fn extract_direct_url_from_uddg(url: &str) -> Option<String> {
172 let clean = url.trim();
173 if !clean.contains("uddg=") {
174 return None;
175 }
176
177 let pos = clean.find("uddg=")?;
178 let after = clean.get(pos + 5..)?;
179 let raw_encoded = after.split('&').next().unwrap_or(after);
180 let decoded = url_decode(raw_encoded);
181 if decoded.starts_with("http://") || decoded.starts_with("https://") {
182 Some(decoded)
183 } else {
184 None
185 }
186}
187
188pub fn detect_variant_from_host(host: &str) -> DuckDuckGoVariant {
190 let h = host.to_ascii_lowercase();
191 let clean_h = h.split(':').next().unwrap_or(&h).trim();
192 if clean_h.starts_with("lite.") {
193 DuckDuckGoVariant::Lite
194 } else if clean_h.starts_with("html.") {
195 DuckDuckGoVariant::Html
196 } else {
197 DuckDuckGoVariant::Standard
198 }
199}
200
201pub fn parse_duckduckgo_url(url: &str) -> Option<DuckDuckGoUrlType> {
203 let clean = url.trim();
204
205 if let Some(target) = extract_direct_url_from_uddg(clean) {
207 return Some(DuckDuckGoUrlType::Redirect { target_url: target });
208 }
209
210 let without_scheme = clean
212 .strip_prefix("https://")
213 .or_else(|| clean.strip_prefix("http://"))
214 .or_else(|| clean.strip_prefix("//"))
215 .unwrap_or(clean);
216
217 let host = without_scheme.split('/').next().unwrap_or("");
218 if !is_duckduckgo_host(host) && !clean.starts_with('/') {
219 return None;
220 }
221
222 let variant = detect_variant_from_host(host);
223
224 let query_part = if let Some(q_idx) = clean.find('?') {
226 clean.get(q_idx + 1..).unwrap_or("")
227 } else {
228 ""
229 };
230
231 let mut query_val = None;
232 let mut offset_val = 0;
233
234 for pair in query_part.split('&') {
235 if pair.is_empty() {
236 continue;
237 }
238 let mut kv = pair.splitn(2, '=');
239 let key = kv.next().unwrap_or("");
240 let val = kv.next().unwrap_or("");
241 if key == "q" {
242 query_val = Some(url_decode(val));
243 } else if key == "s" {
244 if let Ok(n) = val.parse::<usize>() {
245 offset_val = n;
246 }
247 }
248 }
249
250 if let Some(q) = query_val {
251 if !q.is_empty() {
252 return Some(DuckDuckGoUrlType::Search {
253 query: q,
254 offset: offset_val,
255 variant,
256 });
257 }
258 }
259
260 Some(DuckDuckGoUrlType::Home(variant))
261}
262
263pub fn build_duckduckgo_search_url(query: &str, variant: DuckDuckGoVariant, offset: usize) -> String {
265 let enc_q = url_encode(query);
266 let base = format!("https://{}{}", variant.host(), variant.search_path());
267 if offset > 0 {
268 format!("{}?q={}&s={}", base, enc_q, offset)
269 } else {
270 format!("{}?q={}", base, enc_q)
271 }
272}
273
274pub fn parse_duckduckgo_results(html: &str) -> Vec<SearchResultItem> {
281 let mut results = Vec::new();
282 let lower = html.to_ascii_lowercase();
283
284 if lower.contains("result-link") || lower.contains("result-table") || lower.contains("result_link") {
290 let mut cur_pos = 0;
291 while let Some(a_idx) = html[cur_pos..].find("<a ") {
292 let abs_a = cur_pos + a_idx;
293 if let Some(close_tag) = html[abs_a..].find('>') {
294 let a_tag_open = &html[abs_a..abs_a + close_tag + 1];
295 if a_tag_open.contains("result-link") || a_tag_open.contains("result_link") {
296 if let Some(a_end) = html[abs_a..].find("</a>") {
297 let a_full = &html[abs_a..abs_a + a_end + 4];
298 let href = extract_attribute_value(a_full, "href").unwrap_or_default();
299 let title = strip_tags(a_full);
300 let target_url = extract_direct_url_from_uddg(&href).unwrap_or(href.clone());
301
302 let window_end = (abs_a + a_end + 1200).min(html.len());
304 let snippet_window = &html[abs_a + a_end..window_end];
305 let mut snippet = String::new();
306 if let Some(snip_idx) = snippet_window.find("result-snippet") {
307 if let Some(td_start) = snippet_window[snip_idx..].find('>') {
308 let content_start = snip_idx + td_start + 1;
309 if let Some(td_end) = snippet_window[content_start..].find("</td>") {
310 snippet = strip_tags(&snippet_window[content_start..content_start + td_end]);
311 }
312 }
313 }
314
315 let mut display_url = target_url.clone();
316 if let Some(url_idx) = snippet_window.find("result-url") {
317 if let Some(tag_start) = snippet_window[url_idx..].find('>') {
318 let content_start = url_idx + tag_start + 1;
319 if let Some(tag_end) = snippet_window[content_start..].find("</") {
320 let raw_u = strip_tags(&snippet_window[content_start..content_start + tag_end]);
321 if !raw_u.is_empty() {
322 display_url = raw_u;
323 }
324 }
325 }
326 }
327
328 if !title.is_empty() && !target_url.is_empty() {
329 results.push(SearchResultItem {
330 title,
331 target_url,
332 display_url,
333 snippet,
334 });
335 }
336
337 cur_pos = abs_a + a_end + 4;
338 continue;
339 }
340 }
341 }
342 cur_pos = abs_a + 3;
343 }
344 }
345
346 if results.is_empty() && (lower.contains("result__title") || lower.contains("result__snippet") || lower.contains("result__url")) {
348 let mut cur_pos = 0;
349 while let Some(title_pos) = html[cur_pos..].find("result__title") {
350 let abs_pos = cur_pos + title_pos;
351 if let Some(a_start) = html[abs_pos..].find("<a ") {
352 let a_abs = abs_pos + a_start;
353 if let Some(a_end) = html[a_abs..].find("</a>") {
354 let a_full = &html[a_abs..a_abs + a_end + 4];
355 let href = extract_attribute_value(a_full, "href").unwrap_or_default();
356 let title = strip_tags(a_full);
357 let target_url = extract_direct_url_from_uddg(&href).unwrap_or(href);
358
359 let window = &html[a_abs + a_end..a_abs + a_end + 1200.min(html.len() - (a_abs + a_end))];
360 let mut snippet = String::new();
361 if let Some(s_pos) = window.find("result__snippet") {
362 if let Some(tag_end) = window[s_pos..].find('>') {
363 let start = s_pos + tag_end + 1;
364 if let Some(close) = window[start..].find("</") {
365 snippet = strip_tags(&window[start..start + close]);
366 }
367 }
368 }
369
370 let mut display_url = target_url.clone();
371 if let Some(u_pos) = window.find("result__url") {
372 if let Some(tag_end) = window[u_pos..].find('>') {
373 let start = u_pos + tag_end + 1;
374 if let Some(close) = window[start..].find("</") {
375 let d = strip_tags(&window[start..start + close]);
376 if !d.is_empty() {
377 display_url = d;
378 }
379 }
380 }
381 }
382
383 if !title.is_empty() && !target_url.is_empty() {
384 results.push(SearchResultItem {
385 title,
386 target_url,
387 display_url,
388 snippet,
389 });
390 }
391
392 cur_pos = a_abs + a_end + 4;
393 continue;
394 }
395 }
396 cur_pos = abs_pos + 13;
397 }
398 }
399
400 if results.is_empty() && (lower.contains("data-testid=\"result") || lower.contains("data-result=\"snippet") || lower.contains("<article")) {
402 let mut cur_pos = 0;
403 while let Some(article_start) = html[cur_pos..].find("<article") {
404 let abs_art = cur_pos + article_start;
405 let article_end = html[abs_art..].find("</article>").unwrap_or_else(|| 2500.min(html.len() - abs_art));
406 let art_str = &html[abs_art..abs_art + article_end];
407
408 let mut title = String::new();
410 let mut target_url = String::new();
411
412 let mut target_a_range = None;
414 if let Some(t_pos) = art_str.find("result-title-a") {
415 if let Some(prev_a) = art_str[..t_pos].rfind("<a ") {
416 target_a_range = Some(prev_a);
417 }
418 }
419 if target_a_range.is_none() {
420 if let Some(h2_pos) = art_str.find("<h2") {
421 if let Some(h2_a) = art_str[h2_pos..].find("<a ") {
422 target_a_range = Some(h2_pos + h2_a);
423 }
424 }
425 }
426 if target_a_range.is_none() {
427 target_a_range = art_str.find("<a ");
428 }
429
430 if let Some(a_start) = target_a_range {
431 if let Some(a_close) = art_str[a_start..].find("</a>") {
432 let a_full = &art_str[a_start..a_start + a_close + 4];
433 let href = extract_attribute_value(a_full, "href").unwrap_or_default();
434 title = strip_tags(a_full);
435 target_url = extract_direct_url_from_uddg(&href).unwrap_or(href);
436 }
437 }
438
439 let mut snippet = String::new();
441 if let Some(snip_pos) = art_str.find("data-result=\"snippet\"").or_else(|| art_str.find("result-snippet")) {
442 if let Some(tag_end) = art_str[snip_pos..].find('>') {
443 let start = snip_pos + tag_end + 1;
444 if let Some(close) = art_str[start..].find("</") {
445 snippet = strip_tags(&art_str[start..start + close]);
446 }
447 }
448 }
449
450 let mut display_url = target_url.clone();
452 if let Some(url_pos) = art_str.find("result-extras-url") {
453 if let Some(tag_end) = art_str[url_pos..].find('>') {
454 let start = url_pos + tag_end + 1;
455 if let Some(close) = art_str[start..].find("</") {
456 let d = strip_tags(&art_str[start..start + close]);
457 if !d.is_empty() {
458 display_url = d;
459 }
460 }
461 }
462 }
463
464 if !title.is_empty() && !target_url.is_empty() {
465 results.push(SearchResultItem {
466 title,
467 target_url,
468 display_url,
469 snippet,
470 });
471 }
472
473 cur_pos = abs_art + article_end + 10;
474 }
475 }
476
477 results
478}
479
480fn extract_attribute_value(tag_str: &str, attr_name: &str) -> Option<String> {
482 let lower = tag_str.to_ascii_lowercase();
483 let pattern = format!("{}=", attr_name.to_ascii_lowercase());
484 let pos = lower.find(&pattern)?;
485 let after = &tag_str[pos + pattern.len()..];
486
487 if after.starts_with('"') {
488 let end = after[1..].find('"')?;
489 Some(after[1..1 + end].to_string())
490 } else if after.starts_with('\'') {
491 let end = after[1..].find('\'')?;
492 Some(after[1..1 + end].to_string())
493 } else {
494 let end = after.find(&[' ', '>', '\t', '\r', '\n'][..]).unwrap_or(after.len());
495 Some(after[..end].to_string())
496 }
497}
498
499pub fn strip_tags(html: &str) -> String {
501 let mut out = String::new();
502 let mut inside_tag = false;
503 for c in html.chars() {
504 if c == '<' {
505 inside_tag = true;
506 } else if c == '>' {
507 inside_tag = false;
508 } else if !inside_tag {
509 out.push(c);
510 }
511 }
512 out.replace("&", "&")
514 .replace("<", "<")
515 .replace(">", ">")
516 .replace(""", "\"")
517 .replace("'", "'")
518 .replace(" ", " ")
519 .trim()
520 .to_string()
521}
522
523pub fn generate_duckduckgo_home_html(variant: DuckDuckGoVariant) -> String {
525 let path = variant.search_path();
526 let badge_html = match variant {
527 DuckDuckGoVariant::Standard => String::new(),
528 DuckDuckGoVariant::Html => String::from(r#"<span class="ddg-badge">HTML</span>"#),
529 DuckDuckGoVariant::Lite => String::from(r#"<span class="ddg-badge">Lite</span>"#),
530 };
531
532 format!(
533 r#"<!DOCTYPE html>
534<html>
535<head>
536<meta charset="utf-8">
537<title>DuckDuckGo — プライバシーを重視した検索エンジン (AtmOS)</title>
538<style>
539body {{
540 background-color: #1e1e24;
541 color: #e0e0e0;
542 font-family: sans-serif;
543 margin: 0;
544 padding: 0;
545}}
546.ddg-container {{
547 max-width: 680px;
548 margin: 0 auto;
549 padding: 60px 20px;
550 text-align: center;
551}}
552.ddg-logo-wrap {{
553 margin-bottom: 24px;
554}}
555.ddg-logo-text {{
556 font-size: 38px;
557 font-weight: bold;
558 color: #de5833;
559 text-decoration: none;
560}}
561.ddg-badge {{
562 font-size: 13px;
563 color: #6ba4e8;
564 background-color: #2b2b36;
565 padding: 3px 8px;
566 border-radius: 4px;
567 margin-left: 8px;
568 vertical-align: middle;
569}}
570.ddg-tagline {{
571 font-size: 15px;
572 color: #a0a0b0;
573 margin-bottom: 32px;
574}}
575.ddg-form {{
576 display: flex;
577 justify-content: center;
578 align-items: center;
579 margin-bottom: 30px;
580}}
581.ddg-input-wrap {{
582 flex: 1;
583 max-width: 500px;
584}}
585.ddg-input {{
586 width: 100%;
587 box-sizing: border-box;
588 padding: 12px 16px;
589 font-size: 16px;
590 border-radius: 8px 0 0 8px;
591 border: 1px solid #3d3d4a;
592 background-color: #2a2a35;
593 color: #ffffff;
594}}
595.ddg-btn {{
596 padding: 12px 24px;
597 font-size: 16px;
598 font-weight: bold;
599 border-radius: 0 8px 8px 0;
600 border: 1px solid #de5833;
601 background-color: #de5833;
602 color: #ffffff;
603 cursor: pointer;
604}}
605.ddg-links {{
606 margin-top: 40px;
607 padding-top: 20px;
608 border-top: 1px solid #2f2f3d;
609 font-size: 13px;
610 color: #808090;
611}}
612.ddg-link {{
613 color: #6ba4e8;
614 text-decoration: none;
615 margin: 0 10px;
616}}
617</style>
618</head>
619<body>
620<div class="ddg-container">
621 <div class="ddg-logo-wrap">
622 <span class="ddg-logo-text">🦆 DuckDuckGo</span>
623 {badge_html}
624 </div>
625 <div class="ddg-tagline">個人情報を追跡しない、プライバシー保護検索エンジン</div>
626
627 <form action="{action_path}" method="get" class="ddg-form">
628 <div class="ddg-input-wrap">
629 <input type="text" id="searchbox_input" name="q" class="ddg-input" placeholder="検索キーワードを入力..." autofocus autocomplete="off">
630 </div>
631 <input type="submit" value="検索" class="ddg-btn">
632 </form>
633
634 <div class="ddg-links">
635 <span>クイック検索例:</span>
636 <a href="{action_path}?q=Rust+programming" class="ddg-link">Rust</a>
637 <a href="{action_path}?q=Raspberry+Pi+3B" class="ddg-link">Raspberry Pi</a>
638 <a href="{action_path}?q=AtmOS+Operating+System" class="ddg-link">AtmOS</a>
639 </div>
640</div>
641</body>
642</html>"#,
643 badge_html = badge_html,
644 action_path = path,
645 )
646}
647
648pub fn generate_duckduckgo_results_html(
650 query: &str,
651 results: &[SearchResultItem],
652 offset: usize,
653 variant: DuckDuckGoVariant,
654) -> String {
655 let action_path = variant.search_path();
656
657 let mut results_html = String::new();
658 if results.is_empty() {
659 results_html.push_str(r#"<div class="no-results">検索結果が見つかりませんでした。別のキーワードをお試しください。</div>"#);
660 } else {
661 for (i, item) in results.iter().enumerate() {
662 let item_num = offset + i + 1;
663 results_html.push_str(&format!(
664 r#"<div class="result-card">
665 <div class="result-header">
666 <span class="result-num">#{num}</span>
667 <a href="{url}" class="result-title">{title}</a>
668 </div>
669 <div class="result-url">{display_url}</div>
670 <div class="result-snippet">{snippet}</div>
671</div>"#,
672 num = item_num,
673 url = item.target_url,
674 title = item.title,
675 display_url = item.display_url,
676 snippet = item.snippet,
677 ));
678 }
679 }
680
681 let next_offset = offset + 30;
682 let prev_offset = offset.saturating_sub(30);
683
684 let mut pagination_html = String::new();
685 if offset > 0 {
686 pagination_html.push_str(&format!(
687 r#"<a href="{path}?q={q}&s={prev}" class="page-btn">« 前の30件</a>"#,
688 path = action_path,
689 q = url_encode(query),
690 prev = prev_offset,
691 ));
692 }
693 if !results.is_empty() {
694 pagination_html.push_str(&format!(
695 r#"<a href="{path}?q={q}&s={next}" class="page-btn">次の30件 »</a>"#,
696 path = action_path,
697 q = url_encode(query),
698 next = next_offset,
699 ));
700 }
701
702 format!(
703 r#"<!DOCTYPE html>
704<html>
705<head>
706<meta charset="utf-8">
707<title>{query} - DuckDuckGo 検索 (AtmOS)</title>
708<style>
709body {{
710 background-color: #18181f;
711 color: #e0e0e0;
712 font-family: sans-serif;
713 margin: 0;
714 padding: 0;
715}}
716.header-bar {{
717 background-color: #24242e;
718 border-bottom: 1px solid #33333f;
719 padding: 12px 24px;
720 display: flex;
721 align-items: center;
722}}
723.logo {{
724 font-size: 20px;
725 font-weight: bold;
726 color: #de5833;
727 text-decoration: none;
728 margin-right: 20px;
729}}
730.search-form {{
731 display: flex;
732 flex: 1;
733 max-width: 600px;
734}}
735.search-input {{
736 flex: 1;
737 padding: 8px 12px;
738 font-size: 15px;
739 background-color: #1e1e24;
740 border: 1px solid #3d3d4a;
741 border-radius: 6px 0 0 6px;
742 color: #ffffff;
743}}
744.search-submit {{
745 padding: 8px 18px;
746 font-size: 15px;
747 font-weight: bold;
748 background-color: #de5833;
749 border: 1px solid #de5833;
750 border-radius: 0 6px 6px 0;
751 color: #ffffff;
752 cursor: pointer;
753}}
754.main-content {{
755 max-width: 800px;
756 margin: 0 auto;
757 padding: 24px 20px;
758}}
759.results-summary {{
760 font-size: 13px;
761 color: #8c8c9e;
762 margin-bottom: 20px;
763}}
764.result-card {{
765 background-color: #202029;
766 border: 1px solid #2d2d3a;
767 border-radius: 8px;
768 padding: 16px 20px;
769 margin-bottom: 16px;
770}}
771.result-header {{
772 display: flex;
773 align-items: baseline;
774 margin-bottom: 4px;
775}}
776.result-num {{
777 font-size: 12px;
778 color: #707080;
779 margin-right: 8px;
780}}
781.result-title {{
782 font-size: 18px;
783 font-weight: bold;
784 color: #6ba4e8;
785 text-decoration: none;
786}}
787.result-url {{
788 font-size: 12px;
789 color: #55b072;
790 margin-bottom: 8px;
791 word-break: break-all;
792}}
793.result-snippet {{
794 font-size: 14px;
795 line-height: 1.5;
796 color: #c4c4d0;
797}}
798.pagination {{
799 display: flex;
800 gap: 12px;
801 margin-top: 30px;
802 padding-top: 20px;
803 border-top: 1px solid #2d2d3a;
804}}
805.page-btn {{
806 padding: 10px 20px;
807 background-color: #2b2b36;
808 border: 1px solid #3d3d4a;
809 border-radius: 6px;
810 color: #6ba4e8;
811 text-decoration: none;
812 font-size: 14px;
813 font-weight: bold;
814}}
815.no-results {{
816 padding: 40px;
817 text-align: center;
818 color: #9090a0;
819 font-size: 16px;
820}}
821</style>
822</head>
823<body>
824<div class="header-bar">
825 <a href="{action_path}" class="logo">🦆 DuckDuckGo</a>
826 <form action="{action_path}" method="get" class="search-form">
827 <input type="text" name="q" value="{query}" class="search-input">
828 <input type="submit" value="検索" class="search-submit">
829 </form>
830</div>
831
832<div class="main-content">
833 <div class="results-summary">「{query}」の検索結果</div>
834 {results_html}
835 <div class="pagination">
836 {pagination_html}
837 </div>
838</div>
839</body>
840</html>"#,
841 query = query,
842 action_path = action_path,
843 results_html = results_html,
844 pagination_html = pagination_html,
845 )
846}
847
848pub fn build_autocomplete_url(query: &str) -> String {
850 let enc_q = url_encode(query);
851 format!("https://duckduckgo.com/ac/?q={}&type=list", enc_q)
852}
853
854pub fn parse_autocomplete_results(json_str: &str) -> Vec<String> {
860 let mut results = Vec::new();
861 let trimmed = json_str.trim();
862
863 if trimmed.contains("\"phrase\"") {
865 let mut cur = 0;
866 while let Some(idx) = trimmed[cur..].find("\"phrase\"") {
867 let phrase_idx = cur + idx + 8; if let Some(colon) = trimmed[phrase_idx..].find(':') {
869 let after_colon = phrase_idx + colon + 1;
870 if let Some(q_start) = trimmed[after_colon..].find('"') {
871 let str_start = after_colon + q_start + 1;
872 if let Some(q_end) = trimmed[str_start..].find('"') {
873 let phrase = &trimmed[str_start..str_start + q_end];
874 let clean = phrase
875 .replace("\\\"", "\"")
876 .replace("\\\\", "\\")
877 .replace("\\/", "/");
878 if !clean.is_empty() && !results.contains(&clean) {
879 results.push(clean);
880 }
881 cur = str_start + q_end + 1;
882 continue;
883 }
884 }
885 }
886 cur = phrase_idx;
887 }
888 } else if trimmed.starts_with('[') {
889 if let Some(second_open) = trimmed.find('[') {
892 if let Some(inner_open) = trimmed[second_open + 1..].find('[') {
893 let inner_start = second_open + 1 + inner_open + 1;
894 if let Some(inner_close) = trimmed[inner_start..].find(']') {
895 let list_content = &trimmed[inner_start..inner_start + inner_close];
896 let mut cur = 0;
897 while let Some(q1) = list_content[cur..].find('"') {
898 let s1 = cur + q1 + 1;
899 if let Some(q2) = list_content[s1..].find('"') {
900 let item = &list_content[s1..s1 + q2];
901 let clean = item
902 .replace("\\\"", "\"")
903 .replace("\\\\", "\\")
904 .replace("\\/", "/");
905 if !clean.is_empty() && !results.contains(&clean) {
906 results.push(clean);
907 }
908 cur = s1 + q2 + 1;
909 } else {
910 break;
911 }
912 }
913 }
914 }
915 }
916 }
917
918 results
919}
920
921pub fn get_mock_suggestions(query: &str) -> Vec<String> {
923 let q = query.trim().to_ascii_lowercase();
924 if q.is_empty() {
925 return Vec::new();
926 }
927
928 static DICTIONARY: &[&str] = &[
930 "rust",
931 "rust programming",
932 "rust compiler",
933 "rust language",
934 "rust cargo",
935 "raspberry pi",
936 "raspberry pi 3b",
937 "raspberry pi kernel",
938 "operating system",
939 "os development",
940 "os kernel",
941 "atmos",
942 "atmos operating system",
943 "atmos web engine",
944 "atmos browser",
945 "duckduckgo",
946 "duckduckgo search",
947 "duckduckgo privacy",
948 "duckduckgo lite",
949 "youtube",
950 "youtube music",
951 "github",
952 "kernel.org",
953 "wikipedia",
954 "arch linux",
955 "arm64",
956 "aarch64",
957 "webassembly",
958 "browser engine",
959 "html5 specification",
960 "css3 styling",
961 "javascript engine",
962 "検索",
963 "検索エンジン",
964 "研究室",
965 "計算機科学",
966 ];
967
968 let mut suggestions = Vec::new();
969
970 for &word in DICTIONARY {
972 let w_lower = word.to_ascii_lowercase();
973 if w_lower.starts_with(&q) {
974 suggestions.push(String::from(word));
975 }
976 }
977
978 if suggestions.len() < 6 {
980 for &word in DICTIONARY {
981 let w_lower = word.to_ascii_lowercase();
982 if !w_lower.starts_with(&q) && w_lower.contains(&q) {
983 if !suggestions.iter().any(|s| s.eq_ignore_ascii_case(word)) {
984 suggestions.push(String::from(word));
985 }
986 }
987 }
988 }
989
990 suggestions.truncate(6);
991 suggestions
992}