NodeJs抓取頁面html()方法亂碼
在如何用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 的實體編碼,再也不用擔心了!