1. 程式人生 > >NodeJs抓取頁面html()方法亂碼

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;

如需顯示小於號,我們必須這樣寫:&lt; 或 &#60;。前者(實體名)易於記憶,而後者(實體數字)在瀏覽器中的支援較好。

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 的條件註釋
編碼後為:
&#x524D;&#x7AEF;&#x5907;&#x5FD8;&#x5F55; &#x2014; IE &#x7684;&#x6761;&#x4EF6;&#x6CE8;&#x91CA;

中文的編碼結果開頭都是 &#x。試著用 charCodeAt() 取得 "前" 字的 unicode 編碼大小,然後將它轉成 16 進位制,正是 524d !看來和 escape() 相似,又是一次十六進位制的轉換。

但是英文卻沒有被轉,這點和 escape() 也神似。唯一不同的是 escape 會將空格轉為 %20,而 HTML 編碼並沒有。

而且 HTML 編碼甚至會將 &nbsp 自動編碼成 &#xA0,這也就意味著如果要手寫個 HTML 編碼函式,需要將所有字元實體的對映都找出來,而且對於 &XXXX 形式的,似乎還要作個校驗(確認是實體集還是普通的字串)。

而 HTML 解碼則相對來說簡單寫,只需將 &#xXXX 進行轉換,詳細程式碼可以參考 Solution 一節的正則。

事實上,HTML 編碼並不一定要轉成十六進位制,十進位制也可以。還是以 "前" 為例,它的十進位制 unicode 碼為 21069,完全可以用 &#21069; 來代替&#x524d;

最後還有兩個客戶端的編碼、解碼函式:

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 的實體編碼,再也不用擔心了!