最近又在寫程式爬網頁轉電子書,其中一件麻煩事是圖檔。

之前我的做法是依據 img src 下載圖檔,以自定檔名(通常會用流水號)寫入該電子書的附檔資料夾統一儲存,再將 img src 改為圖檔相對路徑。

這回處理的網頁引用圖片不多,每篇頂多兩三張,照片也不大,故我想試試更簡單的做法 - 將圖檔直接轉成 Data URI 內嵌在 HTML,乾淨俐落,省去處理額外附檔的工夫。

最初想到的做法是用 img.onload 事件加 canvas 轉 Data URI,如下範例:

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8">
    <title>Image Conversion Demo</title>
    <style>img {
        display: block; margin: 6px;
    }</style>
</head>

<body>
    <img id='logo' src="icon180x180.png" alt="logo" style="display: block;" />
    <button type="button" onclick="test()">Convert To DataUri</button>
    <script>
        function getImageDataUri(url) {
            let image = new Image();
            let imgType = url.match(/\.jpg/) ? 'image/jpeg' : 'image/png';
            return new Promise((resolve, reject) => {
                image.onload = function () {
                    let canvas = document.createElement('canvas');
                    canvas.width = this.naturalWidth;
                    canvas.height = this.naturalHeight;
                    canvas.getContext('2d').drawImage(this, 0, 0);
                    resolve(canvas.toDataURL(imgType));
                };
                image.onerror = function () {
                    reject('Error: Image load failed');
                };
                image.src = url;
            });
        }
        function test() {
            let logo = document.getElementById('logo');
            getImageDataUri(logo.src).then(res => {
                logo.src = res;
            }).catch(err => {
                alert(err);
            });
        }
    </script>
</body>

</html>

點一下按鈕 src 變成 data:image/png;base64,... ,圖片顯示結果不變:

所以這樣就搞定了?有經驗的老鳥都知道事情沒這麼簡單,基於安全考量,Canvas 存取網頁元素時會檢查來源,只要沾到來自其他網站的資源馬上破功。延伸閱讀:HTML5 Canvas的Origin-Clean安全原則

當 img src 來源改成其他網站的圖檔,toDataURL() 立即噴出錯誤 Uncaught DOMException: Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported.

一旦撞上 Same Origin Policy 限制,即便改走 XHR、Fetch API 也無濟於事。簡單解法是在本機跑個迷你 Poxy 解套,若是爬蟲程式則可改用 .NET WebClient、HttpClient 抓檔案餵進去。這裡來寫幾行 C# 示範用 ASP.NET Core Minimal API 當 Proxy 下載圖檔轉 Data URI。(應有現成解決方案,但這種練習 CORS 存取的機會怎能放過?)

using System.Net;
var builder = WebApplication.CreateBuilder(args);
// 允許跨域存取,不限呼叫網頁來源(AllowAnyOrigin)具危險性,故後面會限定本機存取及圖檔
builder.Services.AddCors(options => options.AddDefaultPolicy(builder => builder.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()));
var app = builder.Build();
var httpClient = new HttpClient();
app.UseCors(); // 允許跨域存取
app.MapGet("/{*path}", async (string? path, HttpContext ctx) =>
{
    // 限定本機存取
    if (!"127.0.0.1,::1".Split(',').Contains(ctx.Connection.RemoteIpAddress.ToString()) ||
        string.IsNullOrEmpty(path) || path.StartsWith("favicon.ico")) return string.Empty;
    path = Uri.UnescapeDataString(path);
    try
    {
        var response = await httpClient.GetAsync(path);
        if (response.StatusCode != HttpStatusCode.OK)
            throw new ApplicationException($"Status Code {response.StatusCode}");
        var contentType = response.Content.Headers.ContentType.MediaType ?? string.Empty;
        if (!contentType.StartsWith("image")) // 只處理圖片
            throw new ApplicationException($"{response.Content.Headers.ContentType.MediaType} not supported");
        return contentType + ";base64," + Convert.ToBase64String(await response.Content.ReadAsByteArrayAsync());
    }
    catch (Exception ex)
    {
        return $"ERROR - {ex.Message}";
    }
});
app.Run();

Porgram.cs 30 行搞定,由於下載 Proxy 容易被當成攻擊跳板,我加上兩道安全防護,限定 localhost 存取及只處理 image/* 類型的內容,以彌補 CORS 未限定來源 URL 的風險。

使用時傳入 encodeURIComponent() 編碼的 URL 可取得 Data URI 字串,接著我們改寫 HTML 透過 fetch API 呼叫 ASP.NET Core 網站:

<script>
    function getImageDataUri(url) {
        return new Promise((resolve, reject) => {
            fetch('http://localhost:5158/' + encodeURIComponent(url)).then(res => {
                if (res.status !== 200) {
                    reject('Error: Image load failed');
                }
                return res.text();
            }).then(text => {
                if (text.includes('base64'))
                    resolve(text);
                else
                    reject(text);
            }).catch(err => {
                reject(err);
            });
        });
    }
    function test() {
        let logo = document.getElementById('logo');
        getImageDataUri(logo.src).then(res => {
            logo.src = 'data:' + res;
        }).catch(err => {
            alert(err);
        });
    }
</script>

測試成功。


Comments

# by Cash

提外話,我後來大多使用 SingleFile 把網頁存成 html 檔,然後丟到 obsidian 裡面,就可以跨裝置閱讀。 給黑大參考一下,雖然跟本文無關 XDD

# by Jeffrey

to Cash, 感謝分享。單純轉單一 HTML,SingleFile 這個工具很好用。

# by Samuel Leung

fetch(url) 之後不是可以 response.blob() 拿到 blob 之後用 FileReader.readAsDataURL() 就拿到 data url 了

# by access

SingleFile看起來是好物一件,感謝 Cash分享~

Post a comment