一些說明
本文中描述的例子程序都是專門針對南京大學小百合 http://lilybbs.net/ 網上論壇當前的 web 界面來做的。因為這是我最經常去逛的一個 web 論壇喽。而且據說這個 web 論壇的程序代碼也是國內高校裡面 bbs 論壇上的 web 界面的一個共同的基礎哦。呵呵。不過我們的程序並不牽扯到服務器的後台啦。由於 web 界面都是經常變動的,而且各個網站的 web 界面也千差萬別,所以本文的目的並不是要提供給讀者一個立即可用的泡網程序,而是通過對這個程序的說明,讓讀者了解 curl 和 scsh 結合起來編寫簡單的 web 腳本的方法。
本文也假設讀者對 scheme 程序語言已經有了一定程度的了解,至少能看懂 scheme 語言編寫的程序片段。還沒有這個自信的讀者可以從本文結尾列出的參考資料中讀到宋國偉發表在 IBM developerWorks 上的關於 scheme 程序語言入門的文章。也許有的讀者朋友對學習一門新的程序語言不是那樣的熱心。我要對這些讀者朋友說的是,本文中的例子最初是用 Plan 9 操作系統上的 rc shell 來開發的。這已經是一個比 UNIX 系統上標准的 Bourne shell 方便了不少的 shell 程序語言了。可是由於程序不斷增加的復雜程度,以及變來變去的 web 界面對於程序靈活程度的高要求,再加上作者期望的更近一步的發展,程序不得不從 rc shell 移植到了基於 scheme 語言的 scsh 上面。從另一個方面來說,scheme 語言其實是一門很容易學習的程序語言,而且你學了以後,肯定會感覺到不同的。
正文部分主要是講述兩個例子。第一個例子比較簡單。是一個監視好友上線情況的小程序。第二個例子復雜一些,也更加有趣一些。是一個用 web 論壇上的帖子來作為輸入和輸出、並且加上了一點簡單的安全限制的 scheme 語言解釋器。由於時間和精力的限制,沒有那麼多時間泡網哇,所以這裡介紹的這個版本只是一個功能十分有限的半成品。不過已經可以完成在 scheme 語言的 r5rs 標准中要求的絕大部分的內容了。
簡單任務之一
南京大學的小百合 bbs 和其它許多高校 bbs 一樣,可以讓用戶設置自己的好友。當用戶的好友上線的時候,系統就會通過自動更新了的 web 頁面通知用戶,用戶就可以和自己的好友在網上交流了。可是我們不能一打開計算機,就總是盯著浏覽器查看自己的好友上線沒有哇。我們的第一個簡單的任務就是用一個腳本程序自動監視好友上線情況,當好友上線以後,就立即發出通知給用戶。這樣用戶就可以打開 web 浏覽器登錄 bbs 和好友聊天啦。
判斷用戶是否在線
在 web 論壇上不同的用戶用不同的 id 來標識。一個 id 就是一個簡短的字符串。當這個 id 登錄 web 論壇以後,小百合這個 web 論壇就會在這個 id 的相關信息頁面上顯示一句話,說明這個用戶“目前在站上”。如果這個 id 注銷了這次登錄,這個頁面上相應的一句話就變成了這個用戶“目前不在站上”。我們的第一個任務,就是根據我們的好友列表,把屬於好友 id 的這個 web 頁面給抓下來。把這個頁面抓下來之後,我們就可以對它進行詳細的分析,並采取進一步的動作了。
這一步任務主要是由 curl 來完成。這是一個工作在 UNIX shell 上的工具程序。它可以接收好多不同的命令行參數,根據這些命令行參數內容的不同,就可以完成不同的任務。最常見的命令行參數就是一個 URL 字符串,標識出我們想要抓取的 web 頁面的完整的網絡地址。這樣 curl 就會把這個頁面抓取下來,送到自己的標准輸出端口打印出來。我們可以用 web 浏覽器手工找到小百合上用來顯示 id 在線信息的 web 頁面地址。這樣就可以用下面這個命令抓取這個頁面。
clearcase/" target="_blank" >cccccc" border="1">bash-2.05b$ curl "http://lilybbs.net/bbsqry?userid=iloveqhq"
接下來的任務就是要在我們的腳本程序中驅動這個命令,並要在腳本程序中獲取到這個命令輸出的內容,以准備交給程序的其它部分進行進一步的分析和處理。
在普通 UNIX 操作系統上的 Bourne shell 環境中,比如在 GNU/Linux 操作系統上的 BASH 腳本程序當中,驅動一個 shell 工具是一件很直接、也很簡單的事情。這是 shell 的長處。基於 scheme 程序語言的 scsh 也自诩為是 UNIX shell 的一種,當然也可以作到方便輕松的驅動 shell 工具程序。這一點其實也正是 scsh 區別於其它許多的 scheme 程序語言的實現版本的一個主要的特征。在用 scsh 編寫的腳本程序當中,用下面的這個 run/string 語法形式就可以完成這一任務。
(run/string (curl "http://lilybbs.net/bbsqry?userid=iloveqhq"))
這個 curl 命令在一般運行的時候,會在標准錯誤輸出端口打印一些統計信息。在腳本程序當中,有的時侯並不需要這樣的統計信息。在 scsh 中我們可以用下面的語法形式來關閉 curl 的標准錯誤輸出端口。
(run/string (curl "http://lilybbs.net/bbsqry?userid=iloveqhq") (- 2))
就像在標准的 UNIX 操作系統中一樣,數字 2 表示標准錯誤輸出端口。上面的減號表示要關閉這個端口。
上面命令中出現的長長的 URL 字符串,我們可以看出來,從腳本程序的角度,可以分為三個部分。第一部分 "http://lilybbs.net" 是小百合站點的 URL 字符串,這在整個程序當中都是不變的。第二部分 "/bbsqry?userid=" 是在腳本程序的這一部分的這個函數裡面,每次調用都固定不變的內容。第三部分 "iloveqhq" 是根據每次函數調用所關心的用戶 id 的不同,每次都要發生變化的內容。我們當然希望用不同的變量來分別表示這三個部分字符串。這個要求我們用下面這個語法形式就可以達到。
(define lilybbs "http://lilybbs.net")(run/string (curl ,(string-append lilybbs "/bbsqry?userid=" userid)) (- 2))
注意到上面的語法形式中出現在 string-append 括號前面的逗號。之所以需要這個逗號,是因為 run/string 並不是一個普通的 scheme 語言的函數,而是一個特殊的語法形式。在 run/string 這個語法形式裡面如果想要調用 scheme 語言中的函數和變量的話,就需要在相應的表達式前面加上一個逗號才行。
上面的語法形式把 curl 命令的輸出內容抓取到一個字符串裡面,這樣以後就可以在 scheme 程序的其它部分進一步的分析和處理這個字符串的內容了。不過我們並不是對這個字符串裡面全部的內容都感興趣的。我們只關心這個字符串裡面說明這個 id 所代表的用戶究竟是“目前在站上”還是“目前不在站上”的這個部分。
我們可以用 scheme 語言自帶的字符串處理函數來分析這方面的內容。我們也可以像編寫 shell 腳本程序所通常習慣做的那樣,把這個任務交給 grep 這個 UNIX 系統上標准的 shell 命令來做。在 scsh 裡面要這樣做的話,可以用下面這樣的一個語法形式。
(run/string (| (curl ,url) (grep -m 1 -n "目前在站上")))
上面的豎槓符號就像在標准的 UNIX shell 環境中一樣,表示兩個 shell 命令之間的一個管道聯系。上面 grep 命令的參數 -m 1 表示只要一出現後面指定的字符串,就中止命令的繼續運行。參數 -n 表示我們希望 grep 在輸出的結果前面增加打印一個行號。這個行號就說明如果後面指定的字符串在管道中出現的話,它究竟是出現在那一行上面。我們為什麼需要行號信息,這在下面的一小節就可以看出來。
防止欺騙
在小百合上用來顯示用戶 id 在線信息的 web 頁面允許用戶自己輸入一個簽名檔。有些用戶喜歡用這些簽名檔來開各種各樣的玩笑。我們前面希望用檢查一個特定的字符串“目前在站上”是否出現在這個頁面當中,來判斷一個用戶 id 是否在線。這樣的話,如果用戶在簽名檔中輸入了這個字符串的話,我們前面的程序就會始終認為這個用戶 id 在線或者不在線。要避免這樣被欺騙,我們判斷一個用戶 id 是否在線的函數就不得不寫成下面這個樣子。
(define (user-online? userid) (let* ((url (string-append lilybbs "/bbsqry?userid=" userid)) (html (run/string (curl ,url) (- 2))) (online (run/string (| (echo ,html)(grep -m 1 -n "目前在站上"))))) (and (< 0 (string-length online)) (let ((offline (run/string (| (echo ,html) (grep -m 1 -n "目前不在站上"))))) (or (= 0 (string-length offline)) (let ((online (grep-line-number online)) (offline (grep-line-number offline))) (< online offline)))))))
發出通知
當腳本程序發現我們的好友 id 上線以後,腳本程序應該能夠給我們發出通知。在 GNOME 桌面環境下,我們可以用 zenity 這個 shell 命令在桌面上顯示一個 GTK+ 的圖形用戶界面的對話框來提醒我們:已經有好友 id 登錄小百合了。我們如果在這個時候也登錄小百合的話,就可以和好友聯系上了。這件事情可以用下面的這個語法形式來做到。
(run (zenity --info --title ,lilybbs --text ,info-text))
上面是用的 run 而不是 run/string 這個語法形式。這是因為我們在這裡並不關心 zenity 這個 shell 命令的返回結果。上面的語法形式中出現的逗號的用處,我們在前面已經說過了。
如果我們對一個普通的 GTK+ 對話框還不能夠感到滿意,比如說,我們希望能在好友 id 上線的時候,聽到我們的計算機音箱裡面播放出來一段美妙的音樂。我們就可以用下面的這個表達式來做到這一點。
(run (mplayer ,(string-append "some-short-music-for-" ,userid ".mp3")))
這樣就可以根據不同的好友用戶 id 播放不同的 mp3 音樂片段。當然,能夠這樣做的前提是你的 GNU/Linux 系統上裝有 mplayer 這個媒體播放軟件。
關於完成這個簡單任務的完整的程序代碼,可以在本文末尾列出的下載文件中得到。這裡就不再贅述了。下面進入我們的簡單任務之二:面向 web 論壇的 scheme 解釋器。
簡單任務之二
南京大學小百合 http://lilybbs.net/ 上的 CompLang 版是一個專門討論程序語言的理論與實踐的版面。對於各種程序語言的學習與實踐對於這個版面上的討論來說,當然是十分的重要的啦。在討論版上發表的帖子裡面附上可以執行的程序代碼片段以及執行的結果,這對於這個版面來說,就是一個非常有用的功能了。我們的第二個簡單任務就是在這個方向上開一個小頭,開發一個以版面上的文章為輸入和輸出的 scheme 程序語言的解釋器。
這個 scheme 語言的解釋器在小百合的 CompLang 版面上讀取特定標題的帖子,把帖子中的 scheme 程序代碼片段提取出來,交給一個在本地後台運行的真正的 scheme 解釋器來運行。然後再把運行得到的結果作為一個新的帖子,發表在小百合上的 CompLang 版面上。
讀取輸入帖子
第一步要完成的任務,就是把 CompLang 版面上的帖子標題都讀出來。首先打開一個 web 浏覽器,訪問到這個顯示 CompLang 版面帖子標題的這個 web 頁面。人工看一下這個頁面的 HTML 代碼的細節到底是怎麼樣的。很快,我們就注意到,用下面這個 scsh 語法形式就可以提取到每個帖子標題的相關 HTML 代碼片段。
(run/strings (| (curl ,(string-append lilybbs "/bbsdoc?board=CompLang"))(grep "bbscon?board=")))
注意到上面的 run/strings 是復數,而不是 run/string 的單數。這兩個語法形式的不同在於,前者把 shell 命令的輸出數據中的每一行都作為一個單獨的 scheme 語言中的字符串數據返回給程序的其余部分,而後者則把所有的輸出數據,不分行就當作一個整個的 scheme 語言中的字符串數據返回給程序的其余部分。我們在這裡因為要把每一行所代表的不同的帖子標題的 HTML 代碼區別開來,所以用的是復數的形式。
正則表達式
這樣我們就得到了每個帖子標題的 HTML 代碼。接下來的任務就是用正則表達式解析這一行 HTML 代碼,把裡面的相關的內容都提取出來。在 scsh 當中,用 rx 開頭的語法形式就表示一個正則表達式。下面我們就來看一看我們要用到的正則表達式的例子。
(rx (/ "09azAZ"))
上面的表達式表示正好有一個或者是 0 到 9 的阿拉伯數字或者是小寫的或者是大寫的一個英文字母。開頭的斜槓符號就表示一個“區段選擇”的意思。需要指出的是,只有在rx 涵蓋的語法形式裡面,這些特殊含義才發生效果。在 scsh 腳本程序的其它部分,這些特殊字符是沒有這裡所說的特殊效果的。
(rx (** 2 12 (/ "09azAZ")))
上面的這個正則表達式表示 0 到 9 的阿拉伯數字和不區分大小寫的英文字母正好出現 2 到 12 遍。由不少於兩個並且不多於十二個的阿拉伯數字和英文字母組成的字符串正好就是小百合對用戶 id 的要求。
(rx (| #\_ (/ "azAZ09")))
在 scheme 語言中 #\_ 表示下劃線這個字母符號。上面的這個正則表達式就表示正好有一個數字、英文字母、或者下劃線符號。在這個正則表達式開頭的豎槓符號,就表示一個“或者”的意思。在這裡我們再次看到,這個豎槓只有在 rx 的語法形式裡面,才表示“或者”這個意思。在 run/string 等語法形式裡面,豎槓表示的是 shell 管道的意思。這兩個意思是萬全不相干的。
(rx (** 2 18 (| #\_ (/ "azAZ09"))))
上面這個正則表達式可以近似說明版面的英文名稱。表示出現了一個由兩個到十八個下劃線、阿拉伯數字或者英文字母等字符組成的字符串。
(rx (~ #\<))
上面的波浪號表示否定。這個正則表達式表示的意思就是正好有一個不是小於號的任意一個字符。
(rx (+ (~ #\<)))
在 rx 語法形式中的加號表示後面的正則表達式會匹配一次或者多次。單個星號表示其後的正則表達式會匹配零次或者多次。兩個星號連在一起,後面再跟兩個正整數,這樣的形式我們已經在前面看到過了,這就表示其後的正則表達式會匹配不少於第一個整數次,同時又不多於第二個整數次。上面的正則表達式的意思就是一個或者多個不是小於號的字符組成的字符串。這個正則表達式在分析 HTML 代碼的時候是很有用、也很方便的。
(rx (: "bbscon?board=" ,board "&file=" (+ (~ #\&)) "&num=" ,num))
上面的這個正則表達式稍微長了一點。它分為六個部分。最一開頭的冒號,表示這個正則表達式是由這六個部分按順序組合起來的,其中的每一個部分都要正好匹配一次。第一部分的字符串 "bbscon?board=" 就匹配它自己。第二部分開頭的一個逗號表示 scsh 會把這一部分作為一個變量或者一小段 scheme 函數來解釋運行,運行得到的結果,必須是一個 rx 開頭的語法形式。其它的部分就沒有什麼新的內容了。這個例子就可以讓我們看出來一點 scsh 裡面的這種 scheme 語法風格的正則表達式,比起傳統的基於字符串的 POSIX 的正則表達式來說,可以有一個更加清晰的邏輯結構。這一點我們從下面的例子裡面可以看的更加清楚。
(define regexp-userid (rx (** 2 12 (/ "09azAZ"))))(define regexp-board (rx (** 2 18 (| #\_ (/ "azAZ09")))))(define regexp-time (rx (+ (~ #\<))))(define regexp-size (rx (+ (~ #\<))))(define regexp-num (rx (+ (/ "09"))))(define regexp-url (rx (: "bbscon?board=" ,board "&file=" (+ (~ #\&)) "&num=" ,num)))(define regexp-sub (rx (+ (~ #\<))))(define re (rx (: "<tr><td>" ,num "<td>" (+ whitespace) "<td><a href=bbsqry?userid=" ,userid ">" ,userid "</a><td><nobr>" ,time "<td><a href=" ,url ">" ,sub "</a>(<font style='font-size:12px; color:#" (| "f00000" "008080") "'>" ,size "</font>)" "<td><font color=" (| "red" "black") ">" ,num "</font>")))
上面這最後一個正則表達式如果用基於字符串的、傳統的 POSIX 的方式寫出來,恐怕誰都會受不了的吧。
匹配
有了正則表達式,我們就可以用它匹配指定的字符串。這主要是通過 regexp-search 這個函數來完成的。
(regexp-search 正則表達式 字符串)
如果不發生匹配,就會返回表示“假”的 #f 這個布爾值。如果發生匹配了,則會返回一個 match 類型的數據。這個類型的數據裡面包括了關於具體匹配的子字符串的具體內容。這些內容可以用 match:substring 等一些函數提取出來。
(match:substring match-data index)
零號索引表示整個的正則表達式匹配到的子字符串。其它的索引則表示正則表達式中出現的 submatch 的部分。我們還是用上面最後的那個 re 正則表達式來說明。這一次我們給它加上 submatch 的信息。
(define re (rx (: "<tr><td>" (submatch ,num) "<td>" (+ whitespace) "<td><a href=bbsqry?userid=" ,userid ">" (submatch ,userid) "</a><td><nobr>" (submatch ,time) "<td><a href=" (submatch ,url) ">" (submatch ,sub) "</a>(<font style='font-size:12px; color:#" (| "f00000" "008080") "'>" ,size "</font>)" "<td><font color=" (| "red" "black") ">" ,num "</font>")))
在 match:substring 等一系列函數中,索引零表示整個正則表達式匹配到的內容,索引從一往後就表示在上面從左到右一個接一個依次出現的 submatch 所涵蓋的正則表達式上發生的匹配。
(match:substring match-data index)
上面這個函數運行起來,返回的就是由索引 index 所指明的那個 submatch 所匹配到的子字符串。關於 match-data 我們前面已經講到過,是由 regexp-search 所找到的數據。
下面我們看到的就是由 HTML 代碼,經由正則表達式的匹配,找到帖子的標題、發帖者、發帖時間、以及帖子詳細網址的完整的 scsh 函數的程序代碼。
(define (html->posts htm) (let* ((userid (rx (** 2 12 (/ "09azAZ")))) (board (rx (** 2 18 (| #\_ (/ "azAZ09"))))) (time (rx (+ (~ #\<)))) (size (rx (+ (~ #\<)))) (num (rx (+ (/ "09")))) (url (rx (: "bbscon?board=" ,board "&file=" (+ (~ #\&)) "&num=" ,num))) (sub (rx (+ (~ #\<)))) (re (rx (: "<tr><td>" (submatch ,num) "<td>" (+ whitespace) "<td><a href=bbsqry?userid=" ,userid ">" (submatch ,userid) "</a><td><nobr>" (submatch ,time) "<td><a href=" (submatch ,url) ">" (submatch ,sub) "</a>(<font style='font-size:12px; color:#" (| "f00000" "008080") "'>" ,size "</font>)" "<td><font color=" (| "red" "black") ">" ,num "</font>")))) (map (lambda (str) (let* ((mat (regexp-search re str)) (sub (lambda (idx) (match:substring mat idx)))) (if (not mat) #f (lambda (sym) (case sym ((num) (sub 1)) ((userid) (sub 2)) ((time) (sub 3)) ((url) (sub 4)) ((subject) (sub 5))))))) (run/strings (| (echo ,htm) (grep "bbscon?board="))))))
面向對象
上面的這個函數如果找到了我們關心的數據,返回的就是下面這樣的一個 lambda 函數。
(lambda (sym) (case sym((num) (sub 1))((userid) (sub 2))((time) (sub 3))((url) (sub 4))((subject) (sub 5))))
這個 lambda 函數可以接受一個調用參數,這個調用參數的效果,就相當於給這個 lambda 函數發了一個短消息。根據這個短消息的不同,這個 lambda 函數返回不同的結果。這就有點像是面向對象編程裡面一個對象的效果。上面的這個技巧也就是在函數式編程語言裡面模擬面向對象編程的一個簡單的方法。當然,要真正的做到在函數式編程裡面模擬面向對象編程,還是要做多得多的工作的。
用帖子作為輸入和輸出
在這一部分,我們只是做一個簡單的設計。考慮到減輕整個系統的運行負擔,這包括小百合的服務器端以及我們本地的運行程序,我們只搜索處理論壇上最新發表的標題以“○ iloveqhq: ”為開頭的帖子。我們的回復帖子也規定以“○ iloveqhq Re: ”為標題。相關的程序代碼片段列在下面。這個設計當然不是很好。但是更好的設計只有在有相當數量的用戶加入進來測試,並提供足夠多的反饋信息以後才有可能達到。所以目前暫時就先這樣吧。^_^
(define (get-ask-post) (let* ((url (string-append lilybbs "/bbsdoc?board=CompLang")) (htm (run/string (curl ,url))) (asksub (rx "○ iloveqhq: ")) (anssub (rx "○ iloveqhq Re: "))) (let lp ((lis (html->posts htm)) (asknum 0) (askpost #f) (ansnum 0) (anspost #f)) (if (null? lis) (if (> asknum ansnum) askpost #f) (let* ((post (car lis)) (sub (post 'subject)) (num (string->number (post 'num)))) (if (and (> num asknum) (regexp-search? asksub sub))(lp (cdr lis) num post ansnum anspost)(if (and (> num ansnum) (regexp-search? anssub sub)) (lp (cdr lis) asknum askpost num post) (lp (cdr lis) asknum askpost ansnum anspost))))))))
帖子中的內容有普通文本,也有 scheme 程序代碼,我們在這裡也只是做一個頭腦簡單的設計,假設帖子中只能出現一段 scheme 程序代碼。這段代碼的開頭第一行必須是“iloveqhq: elk”內容不多也不少。結尾的一行必須是“iloveqhq: kle” 內容也必須是恰恰好。這樣的設計當然也不是很好。在以後的版本中應該會有更好的設計出現的。下面列出的就是提取帖子中 scheme 程序代碼的主要函數。
(define (string->elk-string str) (let* ((elk (rx (: #\newline "iloveqhq: elk" (* whitespace) #\newline))) (kle (rx (: #\newline "iloveqhq: kle" (* whitespace) #\newline))) (re (rx (: ,elk (submatch (+ any)) ,kle)))) (let lp ((str str) (res "")) (let ((mat (regexp-search re str)))(if (not mat) res (lp (substring str (match:end mat 1) (string-length str))(string-append res (match:substring mat 1))))))))
用 elk scheme 做沙盤
從帖子中得到 scheme 程序代碼以後,我們就可以把這段代碼喂給一個 scheme 程序解釋器,讓它運行這段代碼,並且把返回信息傳遞給我們。然後我們就可以用這段返回信息作出一個回復的帖子,張貼到小百合的版面上去。
這裡面需要考慮一個安全問題。因為從理論上說,小百合上的任意一個用戶都可以在帖子中嵌入任意的 scheme 代碼片段。我們用 curl 把網上這個我們並不了解詳細內容的代碼片段抓回到本地機器上,交給運行在本地機器的後台的一個 scheme 解釋器去執行,肯定要考慮到安全的問題。
我們解決這個安全問題的一個簡單辦法,就是做一個 scheme 語言的沙盤環境。我們用 elk scheme 來設置這個環境。
(define (elk-disable) (let ((nuke (lambda (sym)(string-append "(define " (symbol->string sym) " #f)")))(sym '(require call-with-input-file call-with-output-file with-input-from-file with-output-to-file open-input-file open-output-file open-input-output-file tilde-expand file-exists? load load-path load-noisily? load-libraries autoload autoload-notify? dump))) (concat-string-list (map nuke sym) " ")))(define (elk-run-string str) (run/string (| (echo ,(string-append (elk-disable) str)) (elk -l -))))
在這裡做的事情其實就是把 elk scheme 當中涉及到輸入和輸出的大部分函數都給屏蔽掉。這樣一來,網上下載下來的不安全的代碼就不會對本地系統造成任何過分的破壞了。除了輸入和輸出以外,我們也要把 elk scheme 中的模塊加載的部分也給注銷掉。這個理由也是顯然的。
這個安全屏障當然是很簡單的。只能防止一些最惡劣的破壞。在一些更加細致的方面,並沒有做到周密的考慮。因為我們在這裡只是說明一個例子而已,所以就沒有必要在這個雖然困難,但卻是枝節的問題上耗費腦筋了。
登錄和注銷
從 scheme 程序語言的沙盤環境得到程序的輸出以後,我們就可以考慮往小百合的 CompLang 論壇上發帖子,把程序輸出的效果張貼出來。不過發帖子和我們前面遇到過的任務都不相同,需要我們登錄小百合。前面的所有任務都是可以用匿名用戶的身份來完成的,不過發帖子就不行了,小百合的大部分版面都是不允許匿名發帖的。發帖之前,我們首先要登錄小百合系統。小百合的登錄和注銷是用 cookie 來處理的。我們就需要用 curl 來處理這些和 cookie 有關的問題了。
首先是通過一個 web 表格把我們需要用到的登錄用戶 id 和密碼發給小百合的 web 服務器。這一步用下面的 curl 命令就可以做到。
(run/string (curl -d ,(string-append "id=" id) -d ,(string-append "pw=" pw) ,(string-append lilybbs "/bbslogin?type=2")) (- 2))
curl 命令的 -d 選項,後面跟著 key=value 這樣的字符串,就可以用來向 web 地址發送 web 表格信息。表格被發送給 web 服務器以後,服務器會返回一個頁面,這個頁面裡面就包括 cookie 有關的信息。
小甜餅
小百合的 cookie 設置比較奇怪,不是通過 HTTP 協議的信息頭來傳送的,而是通過 JavaScript 來傳送。這樣一來,我們就無法利用 curl 標准的處理 cookie 的辦法了。我們需要自己用 scsh 首先對返回的頁面 HTML 加上 JavaScript 做一些分析處理。這個分析處理還是用前面提到過的正則表達式的方法,把 cookie 信息提取出來。相關的具體的代碼實現列在下面。
(define (get-login-cookie id pw) (let* ((url (string-append lilybbs "/bbslogin?type=2")) (html (run/string (curl -d ,(string-append "id=" id) -d ,(string-append "pw=" pw) ,url) (- 2))) (cookie-lines (run/strings (| (echo ,html) (grep "<script>document.cookie='utmp")))) (re (rx (: "<script>document.cookie='" (submatch (: "utmp" (| "num" "key" "userid") "=" (+ (~ #\')))) "'</script>"))) (find (lambda (line) (let ((mat (regexp-search re line))) (if (not mat) "" (match:substring mat 1)))))) (concat-string-list (map find cookie-lines) "; ")))
curl 命令的 -b 選項加上一個字符串參數,就可以向網站發送 cookie 小甜餅。我們從下面的例子可以看出來。另外,我們了解到所謂 cookie 其實就是一個個的鍵和鍵值組成的字符串。
bash-2.05b$ curl -b "key1=value1; key2=value2" http://lilybbs.net
發送 cookie 給小百合以注銷先前登錄的用戶 id 的函數片段在下面列出來。
(define (logout-cookie cookie) (let ((url (string-append lilybbs "/bbslogout"))) (run (curl -b ,cookie ,url) (- 2))))
發帖子
如何登錄和如何注銷都談過了以後,下面我們就可以在小百合上發帖子了。要注意的一件事情是在把帖子的內容交給 curl 發送到網站上去之前,先要把帖子中的一些特殊的字符按照 HTTP 協議的要求進行編碼轉換。這件事情 curl 是不會代替我們完成的。我們必須自己用 scsh 函數來完成。下面的程序代碼片段就是完成這個工作。
(define (url-encode-char ch) ;; Returns the url-encoded equivalent of a character (cond ((char-ascii=? ch 32) "%20") ; space((char=? ch #\&) "%26") ; ampersand((char=? ch #\?) "%3F") ; question((char=? ch #\{) "%7B") ; open curly((char=? ch #\}) "%7D") ; close curly((char=? ch #\|) "%7C") ; vertical bar((char=? ch #\) "%5C") ; backslash((char=? ch #\/) "%2F") ; slash((char=? ch #\^) "%5E") ; caret((char=? ch #\~) "%7E") ; tilde((char=? ch #\[) "%5B") ; open square((char=? ch #\]) "%5D") ; close square((char=? ch #\`) "%60") ; backtick((char=? ch #\%) "%25") ; percent((char=? ch #\+) "%2B") ; plus(else (string ch))))
有了前面的那麼多准備工作,最後發送文章就是一件輕而易舉的事情了。
(define (post-article board title text) (let* ((cookie (get-login-cookie my-own-id my-own-pw)) (url (string-append lilybbs "/bbssnd?board=" board)) (post (string-append "title=" (url-encode title) "&text=" (url-encode text)))) (run (curl -b ,cookie -d ,post ,url) (- 2)) (logout-cookie cookie)))
結語
上面用兩個例子說明了用 curl 和 scsh 編寫 web 腳本程序的技術。如果網站提供有標准的 web 服務接口的話,當然會讓我們的任務減輕許多。可是目前大部分的網站,尤其是我們最感興趣的論壇網站都沒有提供適於編程的 web 服務接口,所以如果我們想要對 web 論壇上的一些任務進行自動化處理的話,用 curl 和 scsh 編寫 web 腳本程序的辦法就是非常有吸引力的了。
在本文中的第二個例子裡面涉及到的 scheme 語言的沙盤環境,這也是非常有意思的一個話題。如果有機會的話,在這個方面,作者還有一些更加有趣的內容可以說一說。
致謝
南京大學小百合 < http://bbs.nju.edu.cn/> 上的 vt
非常感謝你的 upbbs1 和 upbbs2 兩個腳本程序!見識了你的例子,我才知道有 curl 這麼個強大的工具!而且,我又可以在 LinuxUnix 版上貼圖啦!謝謝!
南京大學小百合 < http://bbs.nju.edu.cn/> 上的 xiaoxinpan
感謝你的支持!謝謝!
Fcitx 小企鵝中文輸入法 < http://www.fcitx.org/>
這篇文章是在 GNU/Linux 操作系統上用 Emacs 編輯器加上 Fcitx 小企鵝中文輸入法編輯輸入的。感謝 Fcitx 的開發者們!終於可以在我最喜愛的操作系統上舒舒服服的編輯中文文章啦!謝謝!
文件下載
iloveqhq-991213.tar.bz2; 這個打包文件裡面是一些 scheme 語言的小程序例子。裡面包括有本文中詳細說明的這個腳本程序的完整程序代碼。
參考資料