When question comes#
在 如何用 Nodejs 分析一個簡單頁面 一文中,我們爬取了博客園首頁的 20 篇文章標題,輸出部分拼接了一個字符串:
var $ = cheerio.load(sres.text);
var ans = '';
$('.titlelnk').each(function (index, item) {
var $item = $(item);
ans += $item.html() + '<br/><br/>';
});
// 將內容呈現到頁面
res.send(ans);
頁面呈現良好:

但是查看網頁源代碼,卻看到這樣的情景:

什么鬼?我們讓問題再清晰些,試着把爬蟲代碼稍做修改:
var $ = cheerio.load(sres.text);
var ans = [];
$('.titlelnk').each(function (index, item) {
var $item = $(item);
ans.push($item.html());
});
// 將內容呈現到頁面
res.send(ans);

這輸出的是什么玩意兒?
亂碼?不,是 HTML 實體編碼!
HTML 實體編碼#
在 HTML 中,某些字符是預留的,比如不能使用小於號(<)和大於號(>),這是因為瀏覽器會誤認為它們是標簽。如果希望正確地顯示預留字符,我們必須在 HTML 源代碼中使用字符實體(character entities)。當然還另一個重要原因,有些字符在 ASCII 字符集中沒有定義,因此需要使用字符實體來表示,比如中文。
字符實體類似這樣:
&entity_name;
或者
&#entity_number;
如需顯示小於號,我們必須這樣寫:< 或 <。前者(實體名)易於記憶,而后者(實體數字)在瀏覽器中的支持較好。
HTML 中常見的需要替換成字符實體的字符有 4 個,分別是 <、>、& 以及 "。為此,我們可以簡單寫個 escapeHTML 函數(使得網頁上可以正確顯示這 4 個字符,而不會被誤認為是標簽):
function escapeHTML(text) {
var replacements= {"<": "<", ">": ">","&": "&", """: """};
return text.replace(/[<>&"]/g, function(character) {
return replacements[character];
});
}
更多關於 HTML 實體編碼的內容可以參考 HTML 字符實體
Solution
不僅是 "<" ">" 這樣的能編碼,所有字符均能編碼,這也是出現 "亂碼" 的原因。在文章開頭的例子中,其實它把該 target 標簽內的所有東西(包括中文)都給編碼了。
而最開始的代碼(字符串輸出)之所以沒有 "亂碼",完全是因為瀏覽器自動幫你解碼了。(如果存在於 HTML 代碼中,會被自動解碼)
知道了原因,我們可以從兩個方向解決問題。
首先,我們可以不對其內容進行編碼。用 text() 方法取代 html() 方法:
$('.titlelnk').each(function (index, item) {
var $item = $(item);
ans.push($item.text());
});
很簡單並且完美地解決了這個問題。
或者我們關閉 cheerio 中的 .html() 方法 轉換實體編碼的功能(2016-01-25 add):
var $ = cheerio.load(sres.text, {decodeEntities: false});
$('.titlelnk').each(function (index, item) {
var $item = $(item);
console.log($item.html());
});
如果說不能從編碼的角度解決,我們可以試着解碼。
方法一:
創建空標簽,將編碼內容用 html() 方法塞入,用 text() 取出,轉換過程讓第三方完成(當然前提是獲取了 $ 對象):
function htmlDecode(str) {
var t = $("<div></div>");
t.html(str);
return t.text();
}
var $ = cheerio.load(sres.text);
var ans = [];
$('.titlelnk').each(function (index, item) {
var $item = $(item);
ans.push(htmlDecode($item.html()));
});
// 將內容呈現到頁面
res.send(ans);
方法二:
根據編碼轉換規則,用正則 decode:
function htmlDecode(str) {
// 一般可以先轉換為標准 unicode 格式(有需要就添加:當返回的數據呈現太多\\\u 之類的時)
str = unescape(str.replace(/\\u/g, "%u"));
// 再對實體符進行轉義
// 有 x 則表示是16進制,$1 就是匹配是否有 x,$2 就是匹配出的第二個括號捕獲到的內容,將 $2 以對應進制表示轉換
str = str.replace(/&#(x)?(\w+);/g, function($, $1, $2) {
return String.fromCharCode(parseInt($2, $1? 16: 10));
});
return str;
}
var $ = cheerio.load(sres.text);
var ans = [];
$('.titlelnk').each(function (index, item) {
var $item = $(item);
ans.push(htmlDecode($item.html()));
});
// 將內容呈現到頁面
res.send(ans);
Encode & Decode
事情到此似乎可以告一段落,我們找到了問題的原因,也找到了解決辦法。但是,HTML 實體編碼,它到底是如何編碼的?
我們任意取一條標題:
前端備忘錄 — IE 的條件注釋
編碼后為:
前端备忘录 — IE 的条件注释
中文的編碼結果開頭都是 &#x。試着用 charCodeAt() 取得 "前" 字的 unicode 編碼大小,然后將它轉成 16 進制,正是 524d !看來和 escape() 相似,又是一次十六進制的轉換。
但是英文卻沒有被轉,這點和 escape() 也神似。唯一不同的是 escape 會將空格轉為 %20,而 HTML 編碼並沒有。
而且 HTML 編碼甚至會將   自動編碼成  ,這也就意味着如果要手寫個 HTML 編碼函數,需要將所有字符實體的映射都找出來,而且對於 &XXXX 形式的,似乎還要作個校驗(確認是實體集還是普通的字符串)。
而 HTML 解碼則相對來說簡單寫,只需將 &#xXXX 進行轉換,詳細代碼可以參考 Solution 一節的正則。
事實上,HTML 編碼並不一定要轉成十六進制,十進制也可以。還是以 "前" 為例,它的十進制 unicode 碼為 21069,完全可以用 前 來代替 前。
最后還有兩個客戶端的編碼、解碼函數:
function HtmlEncode(str) {
var t = document.createElement("div");
t.textContent ? t.textContent = str : t.innerText = str;
return t.innerHTML;
}
function HtmlDecode(str) {
var t = document.createElement("div");
t.innerHTML = str;
return t.textContent || t.innerText;
}
真的是吃一塹長一智,以后碰到 "&#x" 開頭的一些編碼,十有八九是 HTML 的實體編碼,再也不用擔心了!
Read More:
