小試 YARP - 架設 Reverse Proxy 魔改網站內容
5 |
黑暗女王最近有個困擾,某線上小說網站貌似啟用了新的蓋版廣告。當免費仔幫忙看廣告贊助天經地義,但程式沒寫好,用平板看會被蓋版廣告蓋到無邊無際,找不到關閉鈕關不掉。這下可好,廣告都看了不給內容是怎樣?女王震怒大事不妙,只得快使出資訊專長以安太座。
由於平板沒法安裝瀏覽器外掛、也沒法用 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 移除網頁廣告。
開發過程累積一些實戰經驗及處理的眉角,整理如下:
- 在 YARP 要修改請求或回應,被歸類在 Transform 功能,內建功能以 Header 及 URL 改寫為主,要修改回應內容則需用 AddResponseTransform() 寫一段,Lambda 由
ProxyResponse.Content.ReadAsStreamAsync()
取得 Stream 改寫後,再用HttpContext.Response.Body.WriteAsync()
輸出。 ProxyResponse.Content.ReadAsStreamAsync()
讀取的來源網站串流可能被壓縮過,需解壓才能讀到 HTML。我的解法是檢查來源設定 Content-Encoding 時,串接new GZipStream(stream, CompressionMode.Decompress)
解壓縮再用new StreamReader(stream)
讀取文字內容。輸出時可選擇重新壓縮或改傳非壓縮版本,我採用後者,移除 Content-Encoding Header 再用標準Response.Body.WriteAsync()
輸出 HTML,簡單搞定。- 移掉廣告部分,我是用 String.Rlace() 把 HTML 中原本原本
<script src="ad-script-url">
載入廣告腳本部分改成<script _="invalid-ad-script-url">
使其失效。 - 照片部分意外多花了點工夫。來源網站照片都另外放在
<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 的特殊應用案例,只有一個網站一個瀏覽器又是在本機測試,所以才有點混淆吧。