黑暗女王最近有個困擾,某線上小說網站貌似啟用了新的蓋版廣告。當免費仔幫忙看廣告贊助天經地義,但程式沒寫好,用平板看會被蓋版廣告蓋到無邊無際,找不到關閉鈕關不掉。這下可好,廣告都看了不給內容是怎樣?女王震怒大事不妙,只得快使出資訊專長以安太座。

由於平板沒法安裝瀏覽器外掛、也沒法用 TamperMonkey 寫 JavaScript 自力救濟,我想到一個解法是在 CentOS 迷你家用伺服器上裝個 Reverse Proxy 當中間人,從中幫網站修 Bug。雖有用大砲打小鳥之嫌,反正家裡伺服器平日很閒,找點事讓它做也好。

nginx 可以寫 Lua 腳本修改回應內容,應是最簡便的解法,但我比較想練習用 .NET 解決,解決問題兼充實本職學能。

YARP (Yet Another Reverse Proxy) 是微軟的一個開源專案,起源是微軟內部有一堆 Reverse Proxy 需求,索性一群人合作開發出一套共用的 .NET 程式庫。業界優秀的 Reverse Proxy 很多(例如:nginx,它是我跑 ASP.NET Core Docker 的首選,還寫了安裝懶人包),所以專案取名 Yet Another (又一套),YARP 的效能還不錯,用 C# 開發是它最大的優點,可跟 .NET 專案無縫接軌。因此,本案例用 YARP 做,再適合也不過。

查到一篇不錯的介紹:Reverse Proxy 的新選擇 — Yarp by OrangeRed,要啟用 YARP 很簡單,參照 Yarp.ReverseProxy 套件、在 appsettings.json 加入設定、在 ASP.NET Minimal API 加兩行就大功告成了。

這裡選了內容農場「壹讀」練兵,來試試串接 YARP 移除網頁廣告。

開發過程累積一些實戰經驗及處理的眉角,整理如下:

  1. 在 YARP 要修改請求或回應,被歸類在 Transform 功能,內建功能以 Header 及 URL 改寫為主,要修改回應內容則需用 AddResponseTransform() 寫一段,Lambda 由 ProxyResponse.Content.ReadAsStreamAsync() 取得 Stream 改寫後,再用 HttpContext.Response.Body.WriteAsync() 輸出。
  2. ProxyResponse.Content.ReadAsStreamAsync() 讀取的來源網站串流可能被壓縮過,需解壓才能讀到 HTML。我的解法是檢查來源設定 Content-Encoding 時,串接 new GZipStream(stream, CompressionMode.Decompress) 解壓縮再用 new StreamReader(stream) 讀取文字內容。輸出時可選擇重新壓縮或改傳非壓縮版本,我採用後者,移除 Content-Encoding Header 再用標準 Response.Body.WriteAsync() 輸出 HTML,簡單搞定。
  3. 移掉廣告部分,我是用 String.Rlace() 把 HTML 中原本原本 <script src="ad-script-url"> 載入廣告腳本部分改成 <script _="invalid-ad-script-url"> 使其失效。
  4. 照片部分意外多花了點工夫。來源網站照片都另外放在 <img-host-name>.read01.com,圖床有多台且會限定自家網頁使用。YARP 轉接後網址改變,沿用原本 <img> 內嵌圖檔會得到 HTTP 403 無法存取(Referrer 比對不符)。我的解法是用 JavaScript 將圖檔 URL 改為 /imgbed-<img-host-name>/path-to-image,再用 ASP.NET Core 基本技巧寫個 app.MapGet("/imgbed-{hostName}/{**path}",...) 處理圖檔下載,用 HttpClient 從圖床主機取得照片回傳。

成品如下,連上 http://localhost:5121 會看到壹讀網站,照片正確顯示,無廣告,成功!

實作心得是 YARP 的使用方式比預期簡單,本次案例用到的設定跟程式都不多。設定檔如下:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ReverseProxy": {
    "Routes": {
      "route1" : {
        "ClusterId": "cluster1",
        "Match": {
          "Path": "{**catch-all}"
        }
      }
    },
    "Clusters": {
      "cluster1": {
        "Destinations": {
          "destination1": {
            "Address": "https://read01.com/"
          }
        }
      }
    }
  }  
}

全部程式如下,大約 60 行搞定。

using System.IO.Compression;
using System.Text;
using Microsoft.AspNetCore.Components.Forms;
using Yarp.ReverseProxy.Transforms;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddReverseProxy()
    .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
    .AddTransforms(context => {
        context.AddResponseTransform(async respCtx => {
            var resp = respCtx.HttpContext.Response;
            var req = respCtx.HttpContext.Request;
            var srcResp = respCtx.ProxyResponse;
            if ((srcResp?.Content.Headers.ContentType?.MediaType ?? string.Empty).Contains("html")) {
                var stream = await srcResp?.Content.ReadAsStreamAsync()!;
                // Decompress the response stream if needed
                if (srcResp.Content.Headers.ContentEncoding.Any()) {
                    resp.Headers.Remove("Content-Encoding");
                    stream = new GZipStream(stream, CompressionMode.Decompress);
                }
                using var reader = new StreamReader(stream);
                // TODO: size limits, timeouts
                var body = await reader.ReadToEndAsync();
                if (!string.IsNullOrEmpty(body)) {
                    respCtx.SuppressResponseBody = true;
                    // 停用廣告相關 JavaScript
                    body = body.Replace("src=\"//pagead2", "_=\"");
                    body = body.Replace("src=\"https://pagead2", "_=\"");
                    body = body.Replace("src=\"https://player.gliacloud.com", "_=\"");
                    // 將圖片連結轉換為本地代理、移除廣告區塊
                    body = body.Replace("</body>", @"<script>
document.querySelectorAll('img[data-src]').forEach(img => {
    let src = img.getAttribute('data-src'); // 原程式採 Lazy-Loading,檢視到才下載,URL 在 data-src
    img.setAttribute('data-src', src.replace(/https:\/\/([^.]+).read01.com/, '/imgbed-$1'));
});
document.querySelectorAll('.axslot').forEach(o => o.remove());
</script></body>");
                    var bytes = Encoding.UTF8.GetBytes(body);
                    resp.ContentLength = bytes.Length;
                    await respCtx.HttpContext.Response.Body.WriteAsync(bytes);
                }
            }
        });
    });
var app = builder.Build();
// 處理圖床照片下載
app.MapGet("/imgbed-{hostName}/{**path}", async (HttpContext ctx, string hostName, string path) => {
    var url = $"https://{hostName}.read01.com/{path}";
    var client = new HttpClient();
    var resp = await client.GetAsync(url);
    if (resp.IsSuccessStatusCode) {
        var contentType = resp.Content.Headers.ContentType!.ToString();
        ctx.Response.ContentType = contentType;
        await resp.Content.CopyToAsync(ctx.Response.Body);
    } else {
        ctx.Response.StatusCode = (int)resp.StatusCode;
    }
});
app.MapReverseProxy();
app.Run();

YARP 技能點數 +1。

A new overlay ad on an online novel site is causing issues on tablets. The author sets up a Reverse Proxy using YARP on their home server to remove ads. They share their implementation process and code.


Comments

# by Jackson2873

黑大手機用三星,假設夫人手機也是 Android 系列。 在安卓上有一款 kiwi browser Modest Chromium-based browser for power users. https://github.com/kiwibrowser 這款app最重要特色是:可以相容於現有 Chrome 外掛, 所以 kiwi + uBlock 一切就解決了!

# by Felix

只是避免載入廣告腳本的話,可以在路由器設定 URL 過濾,或是在裝置或路由器設定改用 AdGuard DNS 也可以。

# by Dennis

我是用NEXTDNS在阻止列表多掛幾個來過濾,大部分的廣告都能被消滅掉

# by 公海到了沒

請問 Reverse Proxy,查了網路的相關說明。所謂的 Reverse Proxy 似乎安裝在 與網站主機同一側的網路環境。可是你的文章 http://localhost:5121 似乎這個 Reverse Proxy 是架自己的網路環境.... 可否請黑大指導一下 正向代理、反向代理 使用情境

# by Jeffrey

to 公海到了沒,我主要是 Proxy 擔任角色而非所處網段判斷:瀏覽器透過 Forward Proxy 連上各個網站 vs 網站經過 Reverse Proxy 接受各個瀏覽器存取。這是個 Reverse Proxy 的特殊應用案例,只有一個網站一個瀏覽器又是在本機測試,所以才有點混淆吧。

Post a comment