Skip to main content

atmos/os_lib/web_engine/
duckduckgo.rs

1// src/os_lib/web_engine/duckduckgo.rs - DuckDuckGo URL Parser, Result Parser & Lightweight Generator
2#![allow(dead_code)]
3
4extern crate alloc;
5use alloc::format;
6use alloc::string::{String, ToString};
7use alloc::vec::Vec;
8
9/// DuckDuckGo のフロントエンドバリアント
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum DuckDuckGoVariant {
12    /// 通常版 (duckduckgo.com)
13    Standard,
14    /// HTML 版 (html.duckduckgo.com/html/)
15    Html,
16    /// Lite 版 (lite.duckduckgo.com/lite/)
17    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/// DuckDuckGo の URL 種別
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum DuckDuckGoUrlType {
49    /// トップページ
50    Home(DuckDuckGoVariant),
51    /// 検索結果ページ
52    Search {
53        query: String,
54        offset: usize,
55        variant: DuckDuckGoVariant,
56    },
57    /// リダイレクトリンク (uddg トラッキングリンク)
58    Redirect {
59        target_url: String,
60    },
61}
62
63/// 検索結果 1 件
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub struct SearchResultItem {
66    /// ページのタイトル
67    pub title: String,
68    /// 遷移先の目的地 URL(uddg 解決済み)
69    pub target_url: String,
70    /// 画面に表示する短縮 URL
71    pub display_url: String,
72    /// 検索スニペット(概要テキスト)
73    pub snippet: String,
74}
75
76/// ホスト名が DuckDuckGo 関連か判定
77pub 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
86/// URL デコード(パーセントエンコーディングおよび '+' を空白へ復元、UTF-8マルチバイト対応)
87pub 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
121/// クエリ文字列または POST body から指定キーの値を取り出して URL デコードして返す
122pub 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
142/// URL エンコード
143pub 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
164/// DuckDuckGo のトラッキングリダイレクト URL (`uddg=`) から正規の目的地 URL を抽出
165///
166/// 例:
167/// - `//duckduckgo.com/l/?uddg=https%3A%2F%2Fwww.rust-lang.org%2F&rut=...`
168///   -> `Some("https://www.rust-lang.org/")`
169/// - `/l/?uddg=https%3A%2F%2Fgithub.com%2F`
170///   -> `Some("https://github.com/")`
171pub 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
188/// ホスト名から DuckDuckGo バリアントを判定
189pub 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
201/// DuckDuckGo の URL を解析して URL 種別を返す
202pub fn parse_duckduckgo_url(url: &str) -> Option<DuckDuckGoUrlType> {
203    let clean = url.trim();
204
205    // uddg リダイレクト
206    if let Some(target) = extract_direct_url_from_uddg(clean) {
207        return Some(DuckDuckGoUrlType::Redirect { target_url: target });
208    }
209
210    // ホスト名抽出
211    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    // クエリ文字列の解析
225    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
263/// 検索クエリから DuckDuckGo URL を構築
264pub 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
274/// HTML ソースから検索結果リストをパース抽出する純粋関数
275///
276/// 対応フォーマット:
277/// 1. DuckDuckGo Lite: `<td class="result-link"><a href="...">...</a></td>`
278/// 2. DuckDuckGo HTML: `<a class="result__url" href="...">...</a>` / `<div class="result ...">`
279/// 3. 汎用テーブル・リスト構造
280pub fn parse_duckduckgo_results(html: &str) -> Vec<SearchResultItem> {
281    let mut results = Vec::new();
282    let lower = html.to_ascii_lowercase();
283
284    // 1. Lite 版の <table> ベースの結果パース
285    // 各結果は 通常 3 行(<tr>)に分かれている:
286    // (a) result-link (<a> タグ)
287    // (b) result-snippet
288    // (c) result-url
289    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                        // スニペットと表示 URL を探す
303                        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    // 2. HTML 版 (html.duckduckgo.com) のパース
347    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    // 3. 通常版 (duckduckgo.com: <article data-testid="result"> / data-result="snippet" / data-testid="result-title-a")
401    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            // タイトルとターゲット URL
409            let mut title = String::new();
410            let mut target_url = String::new();
411
412            // data-testid="result-title-a" を最優先、次に h2 内の <a>、最後に最初の <a>
413            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            // スニペット: data-result="snippet" または class="...snippet..."
440            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            // 表示URL: data-testid="result-extras-url-link" または result-extras-url または target_url
451            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
480/// HTML タグ属性値の抽出(例: `href="value"` や `href='value'`)
481fn 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
499/// HTML タグを除去してテキストのみを抽出
500pub 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    // HTML エンティティの簡易デコード
513    out.replace("&amp;", "&")
514        .replace("&lt;", "<")
515        .replace("&gt;", ">")
516        .replace("&quot;", "\"")
517        .replace("&#39;", "'")
518        .replace("&nbsp;", " ")
519        .trim()
520        .to_string()
521}
522
523/// DuckDuckGo ホーム画面の軽量 HTML を生成(AtmOS ブラウザ描画用)
524pub 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
648/// DuckDuckGo 検索結果ページの軽量 HTML を生成(AtmOS ブラウザ描画用)
649pub 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
848/// DuckDuckGo オートコンプリート API URL を構築
849pub 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
854/// DuckDuckGo オートコンプリート JSON レスポンスをパースして候補リストを返す純粋関数
855///
856/// 対応フォーマット:
857/// 1. `[{"phrase":"rust"},{"phrase":"rust programming"},...]`
858/// 2. `["query",["rust","rust programming",...]]`
859pub fn parse_autocomplete_results(json_str: &str) -> Vec<String> {
860    let mut results = Vec::new();
861    let trimmed = json_str.trim();
862
863    // 形式 1: phrase キー抽出
864    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; // past `"phrase"`
868            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        // 形式 2: ["query", ["cand1", "cand2", ...]]
890        // 2つ目のブラケット内を探す
891        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
921/// オフライン環境・入力時即時サジェスト用の模擬補完候補
922pub 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    // 組み込みの検索候補データベース
929    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    // 1. 完全一致・前方一致を優先
971    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    // 2. 部分一致(前方一致で枠が余っている場合)
979    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}