[C#] 免費 Web Search 實作:HttpClient 抓 Bing 搜尋結果並轉成 JSON

  • 15
  • 0

最近在開發 Web Agent,一直想弄個免費的 Web Search 備援

原本第一個想到 Google Search API,結果查了一下官方文件,Google 的 Custom Search JSON API 已經停止接受新用戶,既有用戶也得在 2027 年 1 月 1 日前完成遷移

以前好歹還有每天 100 次免費搜尋,現在連申請的機會都沒了 :'(


 

之前研究過 Exa Web Search API,直接回傳 JSON,還能取得網頁內容,確實方便,但免費額度用完還是得花錢

原本以為 DuckDuckGo 可以取代 Google,畢竟有純 HTML 搜尋頁面,用 C# HttpClient 抓下來 Parse 一下應該就搞定了

結果本機連續測試三次,全部 HTTP 403,沒想到 DuckDuckGo 防爬蟲也這麼兇...

 

後來把腦筋動到 Bing 頭上,測試發現一般搜尋頁面還能直接抓 HTML,不需要 Playwright,也不用啟動 Chromium

乾脆把 Bing 加進自己的 Web Agent,當作 DuckDuckGo 的 Fallback

用 C# HttpClient 直接抓 Bing

沒裝額外 NuGet,直接用 HttpClient + Regex,連 HTML Parsing 一起做完

using System.Net;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;

internal class Program
{
    static async Task Main(string[] args)
    {
        var queryText = "台北 甜點店";
        var limit = 10;

        using var handler = new HttpClientHandler
        {
            AutomaticDecompression =
                DecompressionMethods.GZip | DecompressionMethods.Deflate
        };

        using var client = new HttpClient(handler);

        client.DefaultRequestHeaders.UserAgent.ParseAdd(
            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0 Safari/537.36");

        client.DefaultRequestHeaders.AcceptLanguage.ParseAdd(
            "zh-TW,zh;q=0.9");

        var query = Uri.EscapeDataString(queryText);

        // Prefer Taiwan and Traditional Chinese results.
        var url = $"https://www.bing.com/search?q={query}&cc=TW&setlang=zh-Hant";

        using var response = await client.GetAsync(url);
        response.EnsureSuccessStatusCode();

        var html = await response.Content.ReadAsStringAsync();
        var results = new List<object>();

        var matches = Regex.Matches(
            html,
            @"<li class=""b_algo""[^>]*>(.*?)</li>",
            RegexOptions.Singleline | RegexOptions.IgnoreCase);

        foreach (Match m in matches)
        {
            if (results.Count >= limit) break;

            var block = m.Groups[1].Value;

            var aMatch = Regex.Match(
                block,
                @"<h2[^>]*>\s*<a[^>]*href=""([^""]+)""[^>]*>(.*?)</a>\s*</h2>",
                RegexOptions.Singleline | RegexOptions.IgnoreCase);

            if (!aMatch.Success) continue;

            var rawUrl = WebUtility.HtmlDecode(aMatch.Groups[1].Value);
            var title = StripHtmlTags(aMatch.Groups[2].Value);
            var finalUrl = ResolveBingUrl(rawUrl);

            var snippetMatch = Regex.Match(
                block,
                @"<div class=""b_caption""[^>]*>.*?<p[^>]*>(.*?)</p>",
                RegexOptions.Singleline | RegexOptions.IgnoreCase);

            var snippet = snippetMatch.Success
                ? StripHtmlTags(snippetMatch.Groups[1].Value)
                : string.Empty;

            results.Add(new
            {
                title,
                url = finalUrl,
                snippet
            });
        }

        var output = new
        {
            success = true,
            engine = "bing",
            results
        };

        var options = new JsonSerializerOptions
        {
            WriteIndented = true,
            Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
        };

        Console.WriteLine(JsonSerializer.Serialize(output, options));
    }

    static string ResolveBingUrl(string url)
    {
        var match = Regex.Match(url, @"[?&]u=a1([^&]+)");
        if (match.Success)
        {
            try
            {
                var base64 = match.Groups[1].Value
                    .Replace('-', '+')
                    .Replace('_', '/');

                var padding = (4 - (base64.Length % 4)) % 4;
                base64 = base64.PadRight(base64.Length + padding, '=');

                var bytes = Convert.FromBase64String(base64);
                return System.Text.Encoding.UTF8.GetString(bytes);
            }
            catch
            {
                return url;
            }
        }

        return url;
    }

    static string StripHtmlTags(string input)
    {
        var decoded = WebUtility.HtmlDecode(input);
        var text = Regex.Replace(decoded, @"<[^>]+>", string.Empty);
        return Regex.Replace(text, @"\s+", " ").Trim();
    }
}



/* Result 

{
  "success": true,
  "engine": "bing",
  "results": [
    {
      "title": "2026 台北甜點推薦:12 間下午茶與甜點店,滿足你的甜食控靈 …",
      "url": "https://blog.ocard.co/life/dessert-time-taipei/",
      "snippet": "2026年3月13日 · 台北甜點、蛋糕推薦名單就在這! 想跟朋友相約吃下午茶、甜點,或是購買生日蛋糕、母親節蛋糕、父親節蛋糕等, …"
    },
    {
      "title": "2026 【台北甜點】推薦11間台北巷弄甜點店,高評價手工現 …",
      "url": "https://gofunit.com/%E5%8F%B0%E5%8C%97%E7%94%9C%E9%BB%9E/",
      "snippet": "2025年12月31日 · 喜歡吃甜點的女孩來到這堪稱點心王國的城市,一定不能錯過接下來小編要介紹的10間超夯台北甜點店! 有專職千 …"
    },
    {
      "title": "2026台北甜點店|15間Dcard推薦台北蛋糕下午茶名單,精緻平 …",
      "url": "https://mymarcy.tw/taipei-patisserie/",
      "snippet": "2026年5月29日 · 本台北甜點推薦文收集dcard、threads眾網友分享的台北下午茶、台北蛋糕推薦,親自走訪分享過濾,還有不少令我 …"
    },
    {
      "title": "【2026台北甜點推薦】精選11間高CP值平價甜點:學生小資族 …",
      "url": "https://dalang.tw/%E5%8F%B0%E5%8C%97%E7%94%9C%E9%BB%9E%E6%87%B6%E4%BA%BA%E5%8C%85/",
      "snippet": "2026年9月2日 · 獨家分享告訴你11間評分最高的台北甜點推薦,Dcard與PTT人氣推薦必吃的台北甜點,想找台北甜點伴手禮、台北平 …"
    },
    {
      "title": "台北必吃甜點店|精選40家下午茶懶人包、台北好吃甜點推薦、i…",
      "url": "https://angelababy0822.com/post-466959821/",
      "snippet": "2026年7月14日 · 嚴選台北超過50家好吃甜點、蛋糕下午茶懶人包 真心推薦的好吃、好拍、氣氛佳的網美必訪咖啡甜點午茶店 網 …"
    },
    {
      "title": "甜點控必存!11家編輯私藏台北法式甜點名店,走進巴黎般高級的甜點 …",
      "url": "https://rosy-arts.com/life/taipei-french-pastries/",
      "snippet": "2026年7月10日 · 台北的法式甜點如此豐富,該如何尋覓那間命定店鋪? Rosy編輯精選了11家台北超人氣的法式甜點名店,令人驚豔 …"
    },
    {
      "title": "甜點推薦清單!19間名店統整,蛋糕、肉桂捲、泡芙、可頌通通有",
      "url": "https://www.heritage.com.tw/post/best-desserts-in-taipei",
      "snippet": "甜點推薦吃哪家? 台北甜點推薦外帶買什麼? 本文依烘焙、泡芙、可頌、法式與日式分類,整理19間好吃甜點的台北甜點店名單與地 …"
    },
    {
      "title": "台北排隊甜點清單|10家人氣甜點,限量甜甜圈、草莓盒一次收…",
      "url": "https://supertaste.tvbs.com.tw/hot/357894",
      "snippet": "2026年1月3日 · 《食尚玩家》幫你把10家台北排隊甜點名店一次整理好,從甜甜圈、布丁、法式巧克力到台味粉條甜品通通入榜,還 …"
    },
    {
      "title": "2026台北甜點店下午茶推薦 精選10家特色甜點 抹茶/巧克力專賣 …",
      "url": "https://lizzzstyle.tw/%e5%8f%b0%e5%8c%97%e7%94%9c%e9%bb%9e%e5%ba%97%e4%b8%8b%e5%8d%88%e8%8c%b6%e6%8e%a8%e8%96%a610%e5%ae%b6/",
      "snippet": "2024年10月2日 · 且本篇甜點類型不只有一般蛋糕,更有台北巧克力專賣店、抹茶專賣店、人氣布朗尼、舒芙蕾......等,有些是有內用 …"
    },
    {
      "title": "台北平價甜點店地圖:內行推薦5間高CP值口袋名單",
      "url": "https://benjamintrips.tw/read-1473424",
      "snippet": "2026年4月9日 · 想在台北找到高CP值的平價甜點嗎? 本文親自走訪5間內行人才知道的甜點店,從老字號豆花到創意千層蛋糕,每間 …"
    }
  ]
}

*/

Bing Parse網址有一點麻煩

Bing 搜尋結果大多包在 b_algo 裡,用 Regex 抓標題、網址、摘要倒還好,麻煩的是搜尋結果的轉址

有些網址硬是包成 u=a1...,還得把 a1 前綴拿掉,處理 Base64 字元,自己補齊 padding 才能解出真正的網址

只是想拿個搜尋結果而已,還得搞這些有的沒的,不過現在有 AI :P

而且整套解析都是靠 HTML 結構,微軟哪天改個 CSS Class,Regex 就準備重寫,不過現在有 AI 只要不要過度阻擋都不是大問題

接進 AI Agent 的 web_search Tool

我的 Web Agent 原本就有 web_search Tool,參數只有 query 跟 limit,預設 5 筆,最多 10 筆

底層先走 DuckDuckGo,遇到 HTTP 錯誤、驗證碼或解析結果為空,就 Fallback 到 Bing,兩個都掛掉才回報 LLM

LLM 根本不用知道背後是哪個搜尋引擎,以後想接 Exa 或其他 Search API,也不用去改 Tool Schema

因為我的系統是給多用戶同時用的,總不可能每次搜尋都開一個 Chromium,記憶體跟 CPU 直接爆...

實際專案另外做了共用 HttpClient、20 秒 Timeout 跟 CancellationToken,Console 範例就不塞這些東西了

免費的代價

自己抓 Bing、DuckDuckGo,不用 API Key,也沒有搜尋 API 的計次費用

但這種土炮方式說穿了就是爬蟲,搜尋引擎哪天封 IP、加驗證碼、改 HTML,通通自己處理

跟 Exa 這類正式 API 比起來,就是拿維護時間換 API 費用

目前先當 Web Agent 的備援線路,Bing 能撐多久也不知道,哪天連它都開始回 HTTP 403 就再說吧,且用且珍惜...

 

Google 官方文件: Custom Search JSON API

 

---

Before you read this, the article was in a superposition of human and AI. Upon observation, the wave function collapsed into a sleep-deprived developer.