Cross Domain AJAX 抓網頁撈過界
以及如何整合兩個部落格的標籤


為什麼需要 Cross Domain AJAX?

* * * 想討論/改進/更正這份講義嗎? 請到 這帖部落格 留言。 * * *

前一陣子我的 「觀點部落格」 從 OFSET 所架設的 DotClear 搬到了 google 的 blogger。 為了把舊部落格的人氣帶到新的部落格, 又為了讓新的讀者也能看見我的舊文章, 除了用靜態連結互指之外, 還希望將兩邊的標籤整合起來。 例如查看舊部落格 所有貼有 「新聞」 標籤的文章 時, 能不能同時也順便列出新的部落格裡 貼有相同標籤的所有文章 呢? 反之亦然。

最簡單、 最直接的方法是: 直接修改伺服器端的網頁樣版, 例如修改 php 原始碼。 但如果你的權限有限, 那就只好求助於 javascript, 設法在客戶端 (瀏覽器) 裡面完成這項工作。

但是 javascript 有一個很沒意思的限制: "Same Origin Policy" -- 不許跨足到別的網域撈資料。 原本是為了安全考量, 但其實實際的效果只是增加了程式設計師的困擾。 解決的方法是搜尋 "cross domain ajax"。 不過, 還是要請讀者注意最後一節關於安全問題的警告! 本文主要參考 The jQuery Cross-Domain Ajax Guide, 介紹其中兩個方法。

以下分別介紹 (1) Yahoo! Query Language 的 PaaS 服務 (2) JSONP 兩種方法跨網域抓網頁。 每一種方法都分別到:

  1. 資訊人權貴ㄓ疑
  2. 資訊.人.權.貴 隨便記
  3. Slashdot "Your Rights Online"

三個網站取得各站目前最新的文章清單, 然後把三處的文章標題貼在同一個頁面 (本頁)。 請參考本頁原始碼、 javascript 程式碼 和本頁所呈現的 YQL 實驗結果JSONP 實驗結果

如何使用 YQL 實現 Cross Domain AJAX?

首先要在你的網頁當中載入 James Padolsey 所寫的小小 jQuery plugin xdomainajax (最好是 copy 一份到自己的網站):

        <script src='https://raw.github.com/jamespadolsey/jQuery-Plugins/master/cross-domain-ajax/jquery.xdomainajax.js' type='text/javascript'></script>

然後你的 javascript 就可以用 jQuery 的 get 函式抓資料, 而抓回來的個 html 頁面文字都放在 callback 函數的參數的 responseText 欄位裡面:

        $.get(網址, function(data){
            處理 data.responseText
        });

例如, 檢視 「資訊人權貴ㄓ疑」 的頁面原始碼, 可以看見每一則文章的標題長得像這樣:

        <h3 class='post-title entry-title'> <a href='網址'>標題</a></h3>

所以用下面的程式碼把標題變成一個 list, 嵌到本頁的 "mashup-by-yql" 條列清單當中:

        $(data.responseText).find(".post-title").each(function() {
            $("#mashup-by-yql").append("<li>" + $(this).html() + "</li>\n");
        });

其他兩個網站的處理方式類推。 以下是採用 YQL 跨網域抓網頁的實驗結果:

如何使用 JSONP 實現 Cross Domain AJAX?

請參考 "Cowboy" Ben Alman 所寫的 Simple PHP Proxy: JavaScript finally "gets" cross-domain! 簡單講, 就是用一頁 php 當做代理網頁, 你給它一個網址, 它用 JSONP 物件的格式傳回那個網頁的內容; 而 javascript 允許跨網域取得 JSONP 物件, 所以這一頁代理網頁並不需要跟你的網頁放在同一網域, 而 Same Origin Policy 也就破功了 :-) 事實上, 上一節的 YQL 底層, 也是用相同的機制。

JSONP 物件長什麼樣子? 請點進 這裡, 然後把網址後半段的 "url=..." 改成 "url=你自己隨便挑的網址" 觀察結果。 (如果出現 "Fatal error: Call to undefined function: curl_init()..." 之類的錯誤訊息, 那就是代理網頁 ba-simple-proxy.php 所處的網站, 它的 php 不支援 curl 函式庫。 只好把這個檔案搬到別的地方放置囉。) JSONP 傳回一個函數, 而這個函數的唯一參數是一個 associative array (1, 2, 3); 我們對其中的 "contents" 欄位有興趣, 因為整個網頁的內容都放在這個欄位裡面。

首先, 請把代理網頁 ba-simple-proxy.php 放到你自選的網站去, 並且將裡面的 $enable_jsonp = false; 這一句當中的 false 改成 true。

然後你的 javascript 就可以用 jQuery 的 get 函式抓資料, 而抓回來的整個 html 頁面文字都放在 callback 函數的參數的 responseText 欄位裡面: 所以採用 JSONP 技巧抓網頁撈過界的程式碼長得像這樣:

        xdrequest = 代理網頁的網址 + "?callback=?&url=";
        $.getJSON(xdrequest+想要抓進來的頁面網址, function(data){
            處理 data.contents
        });

例如, 檢視 「資訊.人.權.貴 隨便記」 的頁面原始碼, 可以看見每一則文章的標題長得像這樣:

        <h2 id="..." class="post-title"><a href="網址">標題</a></h2>
        <h3 class='post-title entry-title'> <a href='網址'>標題</a></h3>

所以用下面的程式碼把標題變成一個 list, 嵌到本頁的 "mashup-by-jsonp" 條列清單當中:

        $(data.contents).find(".post-title").each(function() {
            $("#mashup-by-jsonp").append("<li>" + $(this).html() + "</li>\n");
        });

其他兩個網站的處理方式類推。 特別要注意的是: 你餵給 ba-simple-proxy.php 吃的網址, 跟你的原始網頁 (載入 javascript 並執行它的網頁), 兩者不可以位於同一個網域。 因為我的網頁有好幾個映射站, 所以加了這段程式碼, 目的就是要確認永遠呼叫不同映射站的 ba-simple-proxy.php :

        var xdrequest = 
            document.location.href.match(/people\.ofset\.org/) ?
            "http://people.ofset.org/~ckhung/" :
            "http://penguin.im.cyut.edu.tw/~greg/" ;

以下是採用 JSONP 跨網域抓網頁的實驗結果:

實際應用

在 blogger 編輯 html, 把其他部落格的標籤文章列表整合進來 我在 blogger (新部落格) 的做法是採用 YQL 從 OFSET (舊部落格) 讀標籤。 先寫好 dct2bs.js, 然後在 blogger 裡面編輯 html (如右圖), 在 </head> 之前加兩句話, 把需要的兩個 js 檔都納進來。 dct2bs.js 裡面, 有幾個地方值得一提:

  1. 中文標籤所形成的網址需要解碼。 但不應該用 unescape, 而應該用 decodeURIComponent。 詳見 這一篇
  2. 要檢查一下, 確認這個 js 檔只在 「標籤」 的頁面有動作。
  3. 從 OFSET 抓回來的部落格文章摘要格式跟 blogger 的格式很不一樣。 我沒有很認真處理, 只稍微修一下。
  4. 標示一下文章總數, 以免遇到 「新站有此標籤, 舊站無此標籤」 的情況時, 讀者可能會不確定是否網頁出錯。 此時顯示 "...0 篇文章" 就不會誤解了。

在 OFSET 的 DotClear 編輯 Presentation Widgets, 把其他部落格的標籤文章列表整合進來 至於在 OFSET 要讀 blogger 的標籤, 用 YQL 失敗。 不知道跟 「標籤裡面有中文」 有沒有關係? 因為若只是讀首頁就沒問題呀... 總之最後採用 JSONP 的方式, 成功。 OFSET 所採用的部落格軟體 DotClear 並不允許部落客修改 html 的 head 部分; 不過沒關係, 在 "Presentation Widgets" 頁面裡, 隨便加一個 text 欄位, 一樣可以塞進一句:

        <script src='http://people.ofset.org/~ckhung/i/bst2dc.js' type='text/javascript'>
        </script>

把事先寫好的 bst2dc.js 抓進來。 除了以上談到的幾點之外, bst2dc.js 裡面, 另有幾個地方值得一提:

  1. 按照部落格 「新文章排前面」 的習慣, 從 blogger 抓來的文章放前面而不是放後面。
  2. 既然放前面, 為避免過度干擾讀者, 只留下標題。
  3. 抓進來的文章標題採用 DotClear 本身原有的 class 排版。 很清楚地看到 css 的好處。

關於安全問題的警告!

Same Origin Policy 的存在, 主要是為了避免惡意人士利用 跨網站指令碼 (Cross-site scripting, 簡稱為 XSS) 來入侵你的瀏覽器。 但實際上 Same Origin Policy 並不能完全有效地阻止 XSS, 卻又造成了程式設計師的不方便, 所以我覺得它很沒有意思。 我認為比較好的安全政策是: 預設禁止跨網站; 但程式設計師應該被允許自行決定解除限制, 並且程式設計的相關文件應該講清楚自行解除限制的風險及應有的防衛措施。

這份文件既然已經教你如何繞過限制, 也應該要提醒你 「抓網頁撈過界」 可能帶來的風險。 不幸的是, 貴哥雖然 比一般資訊教授有較高的資訊安全意識, 但資安相關技術畢竟不是我的專長。 最值得信賴的知識, 還是要從上面給的 XSS 維基百科頁面出發, 閱讀 CERT 及 Apache 的文章。 我這裡只能給幾個簡單的摘要及建議:

  1. 「抓網頁撈過界」 的時候, 可能撈到的危險內容包含: <SCRIPT>, <OBJECT>, <APPLET>, and <EMBED> 等等 html tags。
  2. 「正面列表, 只放行信任的內容」 比 「負面列表, 只禁止有疑慮的內容」 要來得安全。 例如 blogger 的留言編輯器, 只放行少數的 tags。
  3. 抓回網頁後, 真正採用的資料, 範圍越小越好。 例如我的 dct2bs.js 只採用每個 post 的內容; 而 bst2dc.js 更只採用標題; 不去看抓回來的網頁的其他部分, 那麼被來自其他網站 scripts 入侵的機會就小一點了。
  4. 抓回網頁後, 要用 find 進入採用的資料時, 路徑盡量寫詳細一點。 例如 bst2dc.js 裡面的 posts = $(data.contents).find("div.date-posts div.post h3.post-title"); 本來可以只用 ".post-title", 而 dct2bs.js 裡面的 posts = $(data.responseText).find("div#main div#content div.post"); 本來可以只用 ".post" 一樣可以只抓到真正要採用的資料。 但兩處都用了很詳盡的路徑, 目的就是要降低不小心抓到不該抓的資料的機率。 如果你並不是在 google 或 facebook 這類大公司設計網頁, 或許可以放心地假設沒有邪惡的 XSS 作者會這麼認真地針對你的程式佈下陷井。

想留言討論嗎? 請到 部落格