html字型_鬥魚關注人數爬取 字型反爬的攻與防
轉載自:https://cjting.me/2020/07/01/douyu-crawler-and-font-anti-crawling/
這個字型反爬蟲的攻防挺有趣的,雖然跟安全沒有太大關係,前端的分析除錯也可以在口令爆破等情況下參考,全文如下:
之前因為業務原因需要爬取一批鬥魚主播的相關資料,在這過程中我發現鬥魚使用了一種很有意思的反爬技術,字型反爬。
開啟任何一個鬥魚主播的直播間,例如這個主播,他的關注人數資料顯示在右上角:
鬥魚在關注資料這裡使用了字型反爬。什麼是字型反爬?也就是通過自定義字型來自定義字元與渲染圖形的對映。比如,字元 1 實際渲染的是 9,那麼如果 HTML 中的數字是 111,實際顯示就是 999。
在這種技術下,傳統的通過解析 HTML 文件獲取資料的方式就失效了,因為獲取到的資料並不是真實資料。
Tip: 下文中所談的具體細節高度依賴鬥魚網頁的實現,很有可能當你閱讀這篇文章的時候已經不再是那樣。雖然程式碼會過時地很快,但是,技巧和方法是永遠不會過時的。
進攻
感興趣的讀者可以開啟控制檯,看看顯示關注人數的那個 span 元素裡面的內容,會發現根本不是顯示的數字。
從上圖可以看出,顯示的是 59605,這個是真實值,但是字元卻是 96809,這個是虛假值。右邊的font-family
告訴我們這個元素使用了一個自定義字型。
從 Network 面板中的我們可以找到這個字型,具體連結是 mpepc5unpb.woff。
如果大家將字型下載下來,使用如下的 HTML 程式碼來渲染它,我們就可以看得很清楚:
<html><head> <style type="text/css"> @font-face { font-family: test; src: url(mpepc5unpb.woff); } div { font-family: test; font-size: 80px; text-align: center; margin: 200px 0; }style>head><body> <div> 0123456789 div>body>html>
在這個字型中,字元0
會渲染圖形0
,字元1
會渲染圖形7
,以此類推,因此,HTML 中的字元96809
渲染的圖形就是59605
。
這個原理不難理解,我們甚至不需要知道字型檔案內部的具體格式。因為不管怎樣,字型內部一定有一個對映關係。這個關係規定了什麼樣的字元渲染什麼樣的圖形。
正常情況下,字元 1 對應的圖形是 1 的形狀,但是通過修改字型,我們就可以讓字元 1 對應的圖形是 9 的形狀,或者說,其他任意形狀。
這樣以來,HTML 中的字元就完全失去了意義,這個字元所代表的的含義要依據字型來定。
上面這個字型實際上定義了一個0123456789
到0741389265
的對映,拿到 HTML 中的字元我們需要根據這個對映才能得到真正的值。
重新整理鬥魚你會發現,字型又變了,所以它那邊肯定是有一個字型庫的,但是這個不關鍵,解決一個就解決了所有。
那麼,如何解決字型反爬從而得到真實關注人數?
分析
現在我們知道,如果要獲取關注人數,我們必須要獲取
HTML 中的原始值,也就是假值
字元與真正數字之間的對映關係
這兩個問題都不太好解決,我們先談第一個。
鬥魚的這個關注人數很明顯是 JS 渲染的,我們有兩條路可以走:
使用 Headless Browser。也就是使用一個完整的瀏覽器來渲染整個頁面,然後獲取 DOM 中的值。這個是兜底辦法,永遠可行,但是缺點是效率太差。大家可以看一下開啟鬥魚直播間頁面的速度,正常情況下等到關注人數顯示的時候至少需要 5 秒鐘。
我們找到資料來源。找到鬥魚的 JS 是請求了哪個介面獲取到了資料,然後直接請求該介面。
第二個辦法的效率會高很多,但是同時也困難很多。因為 JS 通過什麼手段獲取了資料實在是靈活性太高了,有太多的辦法:
可以請求多個介面,然後拼接得到資料
可以請求某個介面,返回變形後的資料,然後 JS 再反向處理
組合上面兩種方式
…
面對一堆壓縮後的 JS 程式碼,我們想通過分析程式碼的方式來找到資料來源是不可行的。不過我們還是有別的手段,這個後面再說。
考慮到效能以及讓生活變的更有樂趣,這裡我們選擇第二種辦法。
Tip: 第一種辦法我順帶提一下,使用Selenium或者Puppeteer都可以。載入網頁以後,輪詢直到對應的 DOM 中有值即可,通殺任何網站。
現在我們來看第二個問題,也就是如何通過字型來獲取對映關係。
通過解析字型檔案可以辦到嗎?不可以,字型檔案中儲存的是字元和圖形的對映,那麼對於一個圖形,它是我們眼中的 “0” 還是我們眼中的 “1” 字型檔案也沒法知道,在字型檔案眼中就是一堆座標。
那還能怎麼辦呢?只能渲染好以後找個人來看嗎?
如果時光倒退 20 年回到 2000 年,恐怕只能這樣做了。雖然那個時候也有圖形識別技術,但是不像現在這樣成熟也不像現在這樣隨手可得。
但是現在是 2020 年,OCR 圖形識別技術已經非常成熟了,我們隨便找個 OCR 庫應該就夠用了。
所以這個問題的解決方案也有了,我們使用字型渲染好圖形,然後呼叫 OCR 識別圖形對應的數字便可以獲取到對映關係。
按照這個思路,我們整個流程便是
尋找資料來源
根據資料來源獲取字型檔案和假資料
根據字型檔案渲染圖形
使用 OCR 識別圖形得到對映關係
根據對映關係和假資料得到真資料
資料來源
現在我們來找資料來源,看看鬥魚的 JS 到底是從哪裡獲取的字型資訊和假資料。
這其實是一個很有意思的過程,我建議有興趣的同學先暫停下來,花點時間自己試著找找,看能不能找到。
我的第一個想法很簡單,JS 一定是通過某個介面得到了這些資料,那麼,我們把所有網路請求匯出為 HAR 格式,然後在裡面搜尋試試。
Tip: 點選上圖中標記為紅色的按鈕就可以匯出請求為 HAR 格式,HAR 是一個文字格式,非常有利於搜尋。
我試了用假資料也就是96809
和字型 IDmpepc5unpb
來搜尋,都沒有任何結果。
只能說我們的運氣不太好,這裡情況實在太多了,有可能返回的值經過了一定的處理比如 base64 或者 rot13,也有可能是多介面返回然後再拼接組裝。我們沒辦法進一步驗證,只能放棄這條路。
Tip: 其實在大部分情況下,使用關鍵詞搜尋一下 HAR 是很有效的手段,很容易找到對應的介面。
既然此路不通,我們換個思路,請求到了資料以後,JS 程式碼一定會呼叫相關 API 去修改 DOM,能不能監聽到這個動作?在它修改 DOM 的時候打上斷點,這樣就可以通過呼叫棧知道是哪段 JS 在做此操作,然後順騰摸瓜找到對應的介面。
答案是是可以的,通過使用MutationObserver
我們可以監聽任意 DOM 的修改事件。
new MutationObserver((mutations, observer) => { const el = document.querySelector("span.Title-followNum") if (el != null) { observer.disconnect() new MutationObserver((mutations, observer) => { debugger }).observe(el, {childList: true, subtree: true}) }}).observe(document, {childList: true, subtree: true})
通過Tampermonkey載入上面的程式碼,重新整理,等待斷點觸發。
從上面的呼叫棧可以看出,資料來源自 WebSocket。
去 Network 面板中看一下,果然是這樣。
Tip: 之後爬取像鬥魚這樣的複雜網站,應該先檢查一下 WebSocket 中的訊息。
這個訊息格式很好理解,我們可以猜到[email protected]=63206
表示字元為 63206,[email protected]=t3gadgbaon
表示字型 ID 是 t3gadgbaon,和 DOM 對照一下,確實如此。
至此我們的資料來源問題解決了一半,我們知道了資料是來自 WebSocket 傳送的響應。但是,如何程式化去獲取這個響應?
協議
分析 WebSocket 訊息會發現,客戶端連線以後會發送一條登入訊息,然後服務端會回覆多個訊息,其中,就有我們感興趣的followed_count
。
客戶端傳送的訊息如下:
雖然是二進位制訊息,但是可以看到訊息主體都是可讀的文字,很明顯,鬥魚這裡是自己實現了一個內部協議格式。
開頭 12 個位元組暫時不清楚什麼含義,然後緊跟著一段鍵值對資料,使用@=
連線鍵和值,使用/
分割,最後跟上/\x00
。
多檢視幾個直播間以後,對於開頭的 12 個位元組,我們不難分析出前四個位元組和訊息長度有關,使用 Little Endian,中間四個位元組和前四個位元組相同,而最後四個位元組是固定值0xb1 0x02 0x00 0x00
。
上面的訊息長度是 287 個位元組,而0x0000011b = 283
,所以,長度編碼的值實際上是整個訊息的長度減去 4。
這裡設計其實挺奇怪的,長度資訊比實際長度少了四個位元組,同時開頭又多了四個位元組的冗餘資料,怎麼看怎麼都像是設計失誤,第二個四位元組是多餘的。
Tip: 對於一個協議來說,作為外部人員,我們是永遠無法弄清楚有些問題的成因的。可能這四個位元組有其他用處,可能就是設計失誤,也有可能一開始沒有這四個位元組,後來因為 bug 不小心加上了,然後為了後向相容,就一直帶上了。
現在我們來看具體的鍵值對:
[email protected][email protected][email protected][email protected][email protected]@[email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected][email protected]=83
這些引數中,type
,roomid
,devid
都很好理解,dfl
,ver
,aver
,dmbt
,dmbv
這些看起來像是不重要的資訊攜帶欄位。
rt
很容易發現是一個秒級時間戳,現在唯一剩下的就是vk
這個欄位。我們可以通過修改欄位值的方式來大致判斷欄位的作用和重要性。
如果我們原封不動的使用這個請求體(在檢查器中右鍵選擇Copy message... -> Copy as hex
)請求鬥魚的 WebSocket 服務,會發現一開始是有正常響應的,但是過幾分鐘後就報錯了。
const WebSocket = require("ws")const ws = new WebSocket("wss://wsproxy.douyu.com:6672/")ws.on("open", () => { ws.send(Buffer.from("1b0100001b010000b102000074797065403d6c6f67696e7265712f726f6f6d6964403d373837343537392f64666c403d736e4041413d31303540415373734041413d312f757365726e616d65403d2f70617373776f7264403d2f6c746b6964403d2f62697a403d2f73746b403d2f6465766964403d34643963333961386139333734366236646235333637353830303032313530312f6374403d302f7074403d322f637672403d302f747672403d372f617064403d2f7274403d313539333632333033352f766b403d36653964666236336364616533313066393737303061373530623266613437662f766572403d32303139303631302f61766572403d3231383130313930312f646d6274403d6368726f6d652f646d6276403d38332f00", "hex"))})ws.on("message", payload => { console.log(payload.toString())})
很明顯,鬥魚會校驗rt
的值,如果伺服器時間和rt
時間超過一定間隔,那麼會返回錯誤,這是一個很常見的設計。
如果我們修改了一下vk
,也會得到一個錯誤,這說明vk
是類似簽名的東西,而不是什麼資訊攜帶欄位,服務端會校驗它的有效性。
對於dfl
,ver
,aver
,dmbt
,dmbv
這些欄位,我們會發現隨便修改都不會影響結果,說明我們的之前的猜測是正確的。
所以,現在剩下的問題就是要搞清楚vk
的簽名規則,這個只能從原始碼入手。
Chrome 檢查器中對於每個網路請求都會顯示它的 Initiator,也就是這個請求是什麼程式碼發起的。
滑鼠放上去可以看到完整的呼叫棧。
我們的思路是找到傳送訊息的地方,打上斷點,通過呼叫棧往上找。
所以開啟最上面的playerSDK-room_4a27f53.js
檔案,搜尋關鍵字send
,很容易找到下面這段程式碼。
通過斷點我們可以看出,登入訊息就是通過這裡傳送的,因為e
是登入的訊息體。
展開呼叫棧,會發現有一個函式叫做userLogin
,點進去。
可以發現我們來到了common-pre~9fd51f5d.js
檔案,可以猜到h.default.jsEncrypt.async()
這段程式碼應該就是簽名相關的程式碼。
我們可以斷點進這個函式,然後繼續找。或者,我們直接搜尋vk
。
然後我們就發現vk
的值等於L(y + "r5*^5;}2#${XF[h+;'./.Q'1;,-]f'p[" + p)
。到這裡就簡單了,通過斷點可以發現y
就是rt
,p
是devid
,而L
是一個求 md5 值的函式。
所以vk
的簽名演算法是vk = md5(rt + "r5*^5;}2#${XF[h+;'./.Q'1;,-]f'p[" + devid)
現在整個訊息體的結構我們都清楚了,我們試著構造訊息體請求鬥魚伺服器看看能不能得到響應。
const encode = obj => Object.keys(obj).map(k => `${ k }@=${ obj[k] }`).join("/")const decode = str => { return str.split("/").reduce((acc, pair) => { const [key, value] = pair.split("@=") acc[key] = value return acc }, {})}const crypto = require("crypto")const md5Hash = crypto.createHash("md5")const md5 = payload => { return md5Hash.update(payload).digest("hex")}// 4-byte length, 4-byte length, 0xb1, 0x02, 0x00, 0x00const getPayload = obj => { const arr = [0, 0, 0, 0, 0, 0, 0, 0, 0xb1, 0x02, 0x00, 0x00] const objEncoded = encode(obj) + "/\x00" arr.push(...objEncoded.split("").map(c => c.charCodeAt(0))) const payload = Buffer.from(arr) const dv = new DataView(payload.buffer, payload.byteOffset) const length = payload.length - 4 dv.setUint32(0, length, true) dv.setUint32(4, length, true) return payload}ws.on("open", () => { // 這個隨便填寫一個 const devID = "4d9c39a8a93746b6db53675800021501" const rt = (new Date().getTime() / 1000) >> 0 const obj = { type: "loginreq", roomid: roomID, devid: devID, rt: rt, vk: md5(rt + "r5*^5;}2#${XF[h+;'./.Q'1;,-]f'p[" + devID), } const payload = getPayload(obj) ws.send(payload)})ws.on("message", payload => { const data = decode(payload.slice(12).toString()) if(data.type === "followed_count") { console.log(data) } else { console.log(data.type) }})
成功了!
OCR
獲取到字型 ID 和假資料以後,接下來我們要做的就是使用字型渲染一張圖片,然後呼叫 OCR 工具識別圖片。
我們先使用字型 ID 將字型下載下來,字型的下載 URL 是固定的https://shark.douyucdn.cn/app/douyu/res/font/FONT_ID.woff
。
怎樣渲染字型到圖片呢?這個問題方案有很多,上文中我們利用了瀏覽器,這裡我選擇使用 SDL。
#include #include #include #include intmain(void){ if(TTF_Init() == -1) { printf("error: %s\n", TTF_GetError()); return 1; } TTF_Font *font = TTF_OpenFont("test.woff", 50); if(font == NULL) { printf("error: %s\n", TTF_GetError()); return 1; } SDL_Color black = { 0x00, 0x00, 0x00 }; SDL_Surface *surface = TTF_RenderText_Solid(font, "0123456789", black); if(surface == NULL) { printf("error: %s\n", TTF_GetError()); return 1; } IMG_SavePNG(surface, "test.png"); return 0;}
SDL 渲染速度非常快,結果也很清楚,用來 OCR 應該足夠了。
圖形識別這一塊我並沒有什麼太多經驗,但是沒關係,感謝開源世界。我們 Google OCR,很容易就會找到一個看起來很厲害的庫tesseract。
先安裝它brew install tesseract
。
專案本身是 C++ 的,我們可以直接用 C++ 呼叫。但是因為後面我打算使用 Go 寫一個完整的關注人數爬蟲,所以這裡我們使用 Go 來呼叫。
安裝 Go 的繫結gosseract,然後使用如下程式碼來試試:
package mainimport ( "fmt" "github.com/otiai10/gosseract/v2")func main() { client := gosseract.NewClient() defer client.Close() client.SetImage("test.png") client.SetWhitelist("0123456789") text, _ := client.Text() fmt.Println(text)}
然後我們很順利地就得到了結果,開源萬歲!?
最終實現
最後,我們把上面的各個步驟整合一下,使用 Go 來實現一個完整的鬥魚關注人數爬蟲,最終的程式碼在這裡douyu-crawler-demo。
程式碼的大致流程如下:
啟動一定數量的 worker
通過 channel 傳送 roomID 給到 worker
worker 爬取關注人數
首先通過 WebSocket 獲取字型資訊和假資料
下載字型到快取目錄中
呼叫 SDL 渲染字型為圖片到快取目錄中
使用 OCR 識別圖片得到對映關係
儲存對映關係(生產肯定是寫入資料庫,這裡是 demo,我們使用檔案)
輸出結果(同樣,生產是寫入資料庫,這裡我們寫入到結果檔案)
當然,如果要做一個生產級別的爬蟲,還有很多問題要處理:
日誌:詳細的日誌可以幫助分析問題,改進不足以及恢復資料等。
錯誤處理:爬蟲的天然屬性就是隨時可能無法工作,完善的錯誤處理和報警機制是必須的。
重試機制:很多介面會報錯,需要有一定的重試機制。
資料校驗:每一步獲取到的資料都需要校驗有效性,否則很容易在資料庫中寫入無效資料。
人工干預:有些環節比如 OCR 的準確率是無法做到 100% 的,要考慮到失敗的情況,一旦 OCR 識別失敗,需要引入人工干預流程。
IP 池:很多介面會限制 IP 的訪問頻率,這個時候要掛 IP 池。
最後,我們來測試一下我們的程式效果。roomids.txt中含有 120 個鬥魚主播的 roomID,我們使用爬蟲來爬取這 120 個主播的關注人數。
$ douyu-crawler-demo -f roomids.txt....2020/07/02 13:28:41 all done in 36.313598957s2020/07/02 13:28:41 total: 1202020/07/02 13:28:41 success: 862020/07/02 13:28:41 error: 342020/07/02 13:28:41 ocr failed: 0
120 個主播,一共花費了 36s,這個速度還是非常理想的,使用 Headless Browser 是不可能有這個速度的。
但是我們會發現,其中有一些失敗了,看日誌主要是 WebSocket 沒有返回值或者超時了,這在爬蟲中很正常,直接重試一下就行了。
$ douyu-crawler-demo -f roomids.txt...2020/07/02 13:29:42 all done in 23.660793712s2020/07/02 13:29:42 total: 1202020/07/02 13:29:42 success: 1202020/07/02 13:29:42 error: 02020/07/02 13:29:42 ocr failed: 0
這次全部成功了,結果檔案在result/result.txt
中。
Tip: 可以發現 OCR 沒有一次失敗,tesseract 太讚了?
$ head result/result.txt5324388,b72iyfidmi,5538567,1169154582074,yzs37nb5ik,447330,1168806937618,21lwetbnlg,579241,1283745632185,flecd9ycbg,495291,3276216794440,21lwetbnlg,80850,9091052319,tcmpj93mbl,452436,576593820795,n1kril0e2r,80063,400255168755,5n5pkb33y,861526,6895288546776,84c209m14f,9869,37835747228,svk3del36j,319692,127978
每個欄位分別是房間號、字型 ID、假資料以及真資料。
防守
講完了進攻,現在我們來看看如果我們站在防守方,需要使用這種技巧來反爬,該怎麼做?
字型反爬的核心是隨機生成一個對映,根據對映生成字型,然後返回字型和假資料給到前端。
這個時候我們就需要了解一下字型的檔案格式了。常見的字型格式有ttf
,otf
,woff
。其中woff
是一個包裝格式,裡面的字型不是ttf
就是otf
的,所以真正的儲存格式只有兩種,ttf
和otf
。
這兩種格式很明顯都是二進位制格式,沒法直接開啟看。但是,幸運的是,字型有一個格式叫做ttx
,是一個 XML 的可讀格式。
我們的基本思路是:
裁剪字型:根據一個基礎字型裁剪掉我們不需要的字元,比如鬥魚這種情況,我們只需要數字即可
將字型轉換成
ttx
格式開啟找到字元和圖形的對映
修改這個對映
再匯出字型為
ttf
這裡我們使用fonttools這個強大的 Python 庫來進行後續的操作。
我們以開源字型Hack為例。
我們先來裁剪字型。安裝好fonttools
以後會預設安裝幾個工具,其中之一是pyftsubset,這個工具就可以用來裁剪字型。
$ pyftsubset hack.ttf --text="0123456789"WARNING: TTFA NOT subset; don't know how to subset; dropped
上面的 warning 不用介意,執行完畢之後我們得到了hack.subset.ttf
,這個便是裁剪後的字型,只支援渲染 0 ~ 9。
接下來轉換字型為可讀的 ttx 格式。同樣,fonttools 自帶了一個工具叫做ttx
,直接使用即可。
$ ttx hack.subset.ttfDumping "hack.subset.ttf" to "hack.subset.ttx"...Dumping 'GlyphOrder' table...Dumping 'head' table...Dumping 'hhea' table...Dumping 'maxp' table...Dumping 'OS/2' table...Dumping 'hmtx' table...Dumping 'cmap' table...Dumping 'fpgm' table...Dumping 'prep' table...Dumping 'cvt ' table...Dumping 'loca' table...Dumping 'glyf' table...Dumping 'name' table...Dumping 'post' table...Dumping 'gasp' table...Dumping 'GSUB' table...
我們會發現目錄下多了一個hack.subset.ttx
檔案,開啟觀察一下。
很容易就可以發現,cmap
標籤中定義了字元和圖形的對映。
<cmap> <tableVersion version="0"/> <cmap_format_4 platformID="0" platEncID="3" language="0"> <map code="0x30" name="zero"/> <map code="0x31" name="one"/> <map code="0x32" name="two"/> <map code="0x33" name="three"/> <map code="0x34" name="four"/> <map code="0x35" name="five"/> <map code="0x36" name="six"/> <map code="0x37" name="seven"/> <map code="0x38" name="eight"/> <map code="0x39" name="nine"/> cmap_format_4> ...cmap>
0x30
也就是字元 0 對應name="zero"
的TTGlyph
,TTGlyph 中定義了渲染要用的資料,也就是一些座標。
<TTGlyph name="zero" xMin="123" yMin="-29" xMax="1110" yMax="1520"> <contour> <pt x="617" y="-29" on="1"/> <pt x="369" y="-29" on="0"/> <pt x="246" y="165" on="1"/> <pt x="123" y="358" on="0"/> <pt x="123" y="745" on="1"/> <pt x="123" y="1134" on="0"/> <pt x="246" y="1327" on="1"/> <pt x="369" y="1520" on="0"/> <pt x="616" y="1520" on="1"/> <pt x="864" y="1520" on="0"/> <pt x="987" y="1327" on="1"/> <pt x="1110" y="1134" on="0"/> <pt x="1110" y="745" on="1"/> <pt x="1110" y="-29" on="0"/> contour> ...TTGlyph>
那麼怎麼製作混淆字型的方法就不言而喻了,我們修改一下這個 XML,把TTGlyph(name="zero")
標籤的zero
換成eight
然後把TTGlyph(name="eight")
標籤的eight
換成zero
,儲存檔案為fake.ttx
。
匯出 ttx 到 ttf 依然是使用ttx
工具。
$ ttx -o fake.ttf fake.ttx
Compiling "fake.ttx" to "fake.ttf"...
Parsing 'GlyphOrder' table...
Parsing 'head' table...
Parsing 'hhea' table...
Parsing 'maxp' table...
Parsing 'OS/2' table...
Parsing 'hmtx' table...
Parsing 'cmap' table...
Parsing 'fpgm' table...
Parsing 'prep' table...
Parsing 'cvt ' table...
Parsing 'loca' table...
Parsing 'glyf' table...
Parsing 'name' table...
Parsing 'post' table...
Parsing 'gasp' table...
Parsing 'GSUB' table...
使用上文提到的 HTML 使用fake.ttf
渲染 0 ~ 9,可以看到,我們成功地製作了一個混淆字型。
genfont.py是我使用 Python 編寫的指令碼,可以自動生成任意數量的混淆字型。
# 使用 hack.subset.ttf 為基礎生成 20 個混淆字型$ ./genfont.py hack.subset.ttf 20....$ ls result/generated # 結果儲存在這個目錄中0018fb8365.7149586203.ttf 267ccb0e95.8402759136.ttf08a9457ab9.3958406712.ttf 281ef45f09.2154786390.ttf1bbdd405ca.9147328650.ttf 788e0c7651.8790526413.ttf1f985cd725.6417320895.ttf 5433d36fde.1326570894.ttf2d56def315.8962135047.ttf 6844549191.3597082416.ttf6bd27a4bac.0658392147.ttf a422833064.8416930752.ttf6e337094a4.0754261839.ttf c7f0591c38.5761804329.ttf9a0e22d6ad.9173452860.ttf d3269bd2ce.0384976152.ttf9a407f17c1.8379426105.ttf f97691cc25.1587964230.ttf44a428c37d.3602974581.ttf ffe2c54286.6894312057.ttf
檔名的形式為fontID.mapping.ttf
。比如0018fb8365.7149586203.ttf
這個字型會將0
渲染為7
。