應該來規定自己一週至少要寫一篇文章的, 這禮拜剛回歸工作的生活, 回歸了Java, 先從今天算起, 看多久能寫個一篇

這次來寫寫怎麼測試REST client, 測試最直覺的當然是讓Client直接連到Server, 但這樣變數比較多, 比如說網路斷了呀, Server掛掉了呀, 測試資料也不穩定(資料庫內的資料並不一定是固定的), 不太利於自動化測試, 如果只是要測試Client邏輯, 自然擺脫這些因素比較好, 餵假資料(設計好的資料)是比較好的選擇

但總不可能為了測試, 寫一個測試用的假server吧? 為了這樣的需求, Okhttp有提供一個叫做MockWebServer的(Android當然也可以用), 這個就是為了這用途而出現的

要使用MockWebServer的話, 它並不直接包含在Okhttp的包裝內, 要另外含入:

Maven:

<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>mockwebserver</artifactId>
  <version>(insert latest version)</version>
  <scope>test</scope>
</dependency>

Gradle:

compile 'com.squareup.okhttp3:mockwebserver:(insert latest version)'

使用方法也很簡單, 以Retrofit來當例子:

// 建立一個MockWebServer
MockWebServer server = new MockWebServer();
// 建立假的回應資料
server.enqueue(new MockResponse().setBody("{\"status\":\"ok\"}"));

Retrofit retrofit = new Retrofit.Builder()
        .baseUrl(server.url("/"))
        .addCallAdapterFactory(RxJavaCallAdapterFactory.create()) 
        .build(); 
service = retrofit.create(Service.class);

// 啟動server 
server.start();
service.subscribe(subscriber);
server.shutdown();

MockWebServer是一個貨真價實的http server, 所以有自己的Url, 藉由呼叫 server.url("/") 可以取得它的url, 使用MockResponse來回傳假資料(比如說是JSON), 另外可以藉由takeRequest來驗證client送的request是否正確

RecordedRequest request1 = server.takeRequest();
assertEquals("/v1/chat/messages/", request1.getPath());
assertNotNull(request1.getHeader("Authorization"));

那如果要測試不止一個URL呢?那就可以利用Dispatcher, 如下:

final Dispatcher dispatcher = new Dispatcher() {

    @Override
    public MockResponse dispatch(RecordedRequest request) throws InterruptedException {

        if (request.getPath().equals("/v1/login/auth/")){
            return new MockResponse().setResponseCode(200);
        } else if (request.getPath().equals("v1/check/version/")){
            return new MockResponse().setResponseCode(200).setBody("version=9");
        } else if (request.getPath().equals("/v1/profile/info")) {
            return new MockResponse().setResponseCode(200).setBody("{\\\"info\\\":{\\\"name\":\"Lucas Albuquerque\",\"age\":\"21\",\"gender\":\"male\"}}");
        }
        return new MockResponse().setResponseCode(404);
    }
};
server.setDispatcher(dispatcher);

在Unit test中要測試REST Client的話, MockWebServer應該算是蠻好用的一個工具

最近才發現, ptt的rss功能好像拿掉了, 這樣好像就不能拿feedly之類的來訂閱版面內容, 反正我自己有寫了一個gopttcrawler 所幸自己來寫一個吧!

source code在: pttrss

可以自行deploy到heroku去, 如果不想這麼麻煩, 可以用:

https://ptt.cowbay.wtf/rss/版名

例如:

要找到英文版名才可以

資料30分鐘才會更新一次, 不會時時更新, 避免被灌

最近搬家又讓我挖出了Amazon Kindle, 又覺得拿來看漫畫很方便(這戲演了幾次了呀?), 雖然說好像也有網站可以下載漫畫.mobi檔, 不過似乎是會員制的, 不喜歡

因此又讓我想寫漫畫的爬蟲了, 這次的目標是: 無限動漫 (他們的app實在做得有夠差)

這次幾個需求是:

  1. Command line下就可以跑了(這也沒必要做UI吧?)
  2. 在os x下可以執行(我自己電腦是mac)
  3. 出來的檔案可以放到kindle看(.mobi檔或epub)

mobi或epub的檔案格式似乎有點麻煩, 也不太好做得好, 所以決定用cbz檔再用Calibre轉mobi

Calibre有一個方便的command line tool叫ebook-convert, 可以用來轉檔, 而cbz本身非常的簡單 , 它就是一個zip檔, 裡面的圖片檔名照編號就好, 這code還算好寫

再來就是看一下怎麼解析無限動漫的內容了, 它的URL是長這樣的:

http://v.comicbus.com/online/comic-653.html?ch=1

以上範例是名偵探柯南第一卷, 大膽猜測, 653是漫畫編號, ch是集數, 選到第二頁, URL會變成這樣

http://v.comicbus.com/online/comic-653.html?ch=4-2

這樣其實就很明顯了, 接下來是內容的部分

每一集的頭上有一個"正在觀看:[ 名偵探柯南 1 ]", “[]“內就是標題了吧, 另外還有一個"select”, 裡面有這集所有的頁數資訊, 而圖片的id是"TheImg”

不過麻煩的是, 這些資訊似乎隱藏在javascript中, page載入後才會出現

這如果使用headless browser像是Phantomjs就沒啥問題, 但這邊我不想用它, 因為使用這工具還要再裝它

我下一個選擇是Go + Webloop, Webloop是一個Go的headless browser lib, 它是基於WebkitGtk+做成的, 不過我在mac上裝WebkitGTK+裝好久一直有問題, 所以…放棄….

接下來的選擇呢? 還有其他的headless browser嗎?有的! Erik, 這是一個Swift的head less browser, 用Swift寫爬蟲好像挺酷的, 查了一下, 有人用Alamofire + Kanna, 不過這在這例子不適用, 這例子還是比較適合Erik

成品

先給成果: ComicGo

這已經是一個OS X的可執行檔, 在Command line下執行 ComicGo 653 1就可以抓名偵探柯南第一集, 相關的漫畫編號集數, 就去無限動漫查吧

抓完會在你的Download目錄出現"名偵探柯南 1.cbz"再用ebook-covert去轉成你要的格式就可以了

少少的時間隨便寫寫而已, 有bug就見諒囉

OS X Command line tool

XCode + Swift是可以拿來寫command line tool的, 新增一個專案選"Command line tool":

這樣就可以開始寫了

一開始在專案內部會發現一個"main.swift", 由於用swift寫command line app並沒有像其他語言有main function這類的東西 所以程式就寫在這吧

開發Command line tool的坑

坑…真的不少

首先, 你不能使用任何的framework, 因為command line tool產出會是一個可執行檔, 不是一個app bundle, 所以不能包含任何的framework

第二, swift framework不能static link, 像是Erik, Kanna這些swift module, 都是dynamic lib

慘, 光前面這兩點就麻煩了, 開發這個ComicGo, 我用到了Erik, Kanna, Zip等等 , 這樣到底要怎麼辦? 跑起來就image not found

所以呢?土法煉鋼, 把這些module的codes全部引入到我的專案內(所以沒打算Open ssource, 太醜了), 這樣一來就解決掉問題了, 不過這功不算小, 因為Kanna相依libxml, Zip相依libz這些native lib

第三個坑, Erik是利用OS X裡面原生的WebKit去讀取網頁的, 因此他的設計是把載入網頁放到另一個DispatchQueue(javascript執行又是另一個), 但Command line邏輯很單線, 它並不會等callback回來才結束程式, 因此會發現怎麼Erik都沒動作就結束程式了, 因此必須要有個機制來卡住

這個機制就是RunLoop, 關於RunLoop這邊不多做解釋, 看一下官方文件 在程式內則是這樣:

let rl = RunLoop.current
var finished = false

while !finished {
    rl.run(mode: RunLoopMode.defaultRunLoopMode, before: Date(timeIntervalSinceNow: 2))
}

當callback完畢後, 把finished設成true就可以結束整個程式了

Erik

好像還沒介紹Erik喔?其實有點想偷懶跳過了 :P

使用Erik來爬網頁其實很簡單,

Erik.visit(url: url) { object, error in
    if let e = error {

    } else if let doc = object {
        // HTML Inspection
		for link in doc.querySelectorAll("a, link") {
    		print(link.text)
    		print(link["href"])
		}
    }
}

只要有些CSS selector的觀念就可以了, 連querySelectorAll這名字都是一樣的, Erik並不是直接用Webkit去做CSS query的, 而是把webkit的內容拿來用Kanna解析, javascript的執行也一樣, 因此如果對html node有任何變動, 是不會反映到webkit裡面去的, 用Erik來爬的優點是專門針對那些動態網頁的, 有這個就簡單太多了!

最近在把之前弄的新聞萬事通做優化, 話說, 好久沒來宣傳一下新聞萬事通了(毆飛~~)

加入新聞萬事通請按 :

加入好友 加入好友

好, 回歸正題, 之前新聞萬事通檢查假新聞的邏輯是:

  1. 載入新聞小幫手資料庫到記憶體(server啟動時), 四千多筆資料存放在一個map的資料結構
  2. 使用者輸入的訊息如果含有url去map裡面找到對應的url
  3. 但常常有類似內容的新聞有不同來源, 因此會用Gojieba取出標題的關鍵字去四千多筆資料的標題對應關鍵字出現的次數

聽起來很沒效率(四千筆在記憶體內, 其實也不算慢啦, 但就吃記憶體), 那就要優化囉? 那需要一個搜尋引擎囉?

第一個先想到的是Elastic search, 但我還不想搞那麼大, 為了四千多筆資料多拉一台search server,我只想 一台respberry pi就能搞定

Gojieba知道, 有Bleve Search這東西, Bleve Search也是一個full text index engine, 但跟Elastic search不同的是, 它是內嵌在你的程式內, 而不是獨立的server, 而且它是用Go寫的(開心), 而不是Java

Bleve

Bleve Search的來頭也不小,它是來自於著名的NoSQL DB : Couchbase, 這篇有介紹一下他的功能 : Bleve:来自Couchbase、基于Go语言的全文索引与检索库

使用Bleve Search也很簡單, 就建立index, 然後search:

import "github.com/blevesearch/bleve"

func main() {
    // open a new index
    mapping := bleve.NewIndexMapping()
    index, err := bleve.New("example.bleve", mapping)

    // index some data
    err = index.Index(identifier, your_data)

    // search for some text
    query := bleve.NewMatchQuery("text")
    search := bleve.NewSearchRequest(query)
    searchResults, err := index.Search(search)
}

Bleve Search會把indexes放到資料庫內, 預設是使用Bolt DB, Bolt跟leveldbRocksDb 類似, 都是一種Key-Value database, 只是Bolt是純粹以Go開發的

如果你不想要用Bolt, Bleve也是支援使用leveldb和Rocksdb的, 純粹是作者想做成全Go的方案才預設Bolt db, 我自己有實測幾次, 使用這三種DB, 搜尋的速度差不多, 但建立index時bolt較快, 需求的磁碟空間則是Rocksdb優(比Bolt好很多)

Bleve Search的架構做成很有彈性, 除了可以使用不同的DB(我自己也有實作以Redis當KV database的plugin, 不過實在也沒好多少就作罷了), 文字分析(Text Analysis)比如說斷詞, 也是可以用plugin擴增的

官方支援的語言為: Danish, Dutch, English, Finnish, French, German, Hungarian, Italian, Norwegian, Persian, Portuguese, Romanian, Russian, Sorani, Spanish, Swedish, Thai, Turkish

就是沒中文!

還好Gojieba後來也加入了bleve的analyzer和tokenizer, 這一部分可以獲得解決

使用Gojieba斷詞:

	indexMapping := bleve.NewIndexMapping()
    os.RemoveAll(INDEX_DIR)
    // clean index when example finished
    defer os.RemoveAll(INDEX_DIR)

    err := indexMapping.AddCustomTokenizer("gojieba",
        map[string]interface{}{
            "dictpath":     gojieba.DICT_PATH,
            "hmmpath":      gojieba.HMM_PATH,
            "userdictpath": gojieba.USER_DICT_PATH,
            "type":         "gojieba",
        },
    )
    if err != nil {
        panic(err)
    }
    err = indexMapping.AddCustomAnalyzer("gojieba",
        map[string]interface{}{
            "type":      "gojieba",
            "tokenizer": "gojieba",
        },
    )
    if err != nil {
        panic(err)
    }
    indexMapping.DefaultAnalyzer = "gojieba"

Wukong

除了Bleve外, 還有一個悟空 Wukong, 這孫猴子也好像蠻識字的嘛

這個Wukong是由阿里巴巴的陳輝所開發的, 一樣是內嵌的全文檢索引擎

package main

import (
    "github.com/huichen/wukong/engine"
    "github.com/huichen/wukong/types"
    "log"
)

var (
    // searcher是协程安全的
    searcher = engine.Engine{}
)

func main() {
    // 初始化
    searcher.Init(types.EngineInitOptions{
        SegmenterDictionaries: "github.com/huichen/wukong/data/dictionary.txt"})
    defer searcher.Close()

    // 将文档加入索引,docId 从1开始
    searcher.IndexDocument(1, types.DocumentIndexData{Content: "此次百度收购将成中国互联网最大并购"}, false)
    searcher.IndexDocument(2, types.DocumentIndexData{Content: "百度宣布拟全资收购91无线业务"}, false)
    searcher.IndexDocument(3, types.DocumentIndexData{Content: "百度是中国最大的搜索引擎"}, false)

    // 等待索引刷新完毕
    searcher.FlushIndex()

    // 搜索输出格式见types.SearchResponse结构体
    log.Print(searcher.Search(types.SearchRequest{Text:"百度中国"}))
}

架構上跟Bleve有點接近, 寫法也差不多, 但效率上來說, Bleve根本不能比, index的效率快上許多, 它的docId不像是Bleve用string而是uint64

比較快的原因目前我也還沒深究, 不過它存儲並沒用到BoltDB或LevelDB之類的, 而是自己的格式, 它也像Bleve一樣支援換資料庫引擎, 我嘗試想用BoltDB來取代它原生的, 想試看看是不是這原因, 但一直沒換成功過(後來也懶得追了)

另一點的差別在於是, 剛剛Bleve我用的斷詞器是Gojieba, 而Wukong用的是陳輝自己寫的sego, 這時我就好奇這會不會有影響?

BleveSego

好, 既然要確認斷詞器對index效率有沒影響, 我就得自己實作一個基於Sego的Bleve text analyzer和Tokenizer, 因此仿Gojieba的做了:

blevesego

使用blevesego跟使用Gojieba的有點類似:

	indexMapping := bleve.NewIndexMapping()
	err := indexMapping.AddCustomTokenizer("sego",
		map[string]interface{}{
			"dictpath": "dictionary.txt",
			"type":     "sego",
		},
	)

	if err != nil {
		getLogger().Fatal(err)
		return nil
	}

	err = indexMapping.AddCustomAnalyzer("sego",
		map[string]interface{}{
			"type":      "sego",
			"tokenizer": "sego",
		},
	)

	if err != nil {
		getLogger().Fatal(err)
		return nil
	}

	newsHelperIndexMapping.DefaultAnalyzer = "sego"

實驗的結果, 同樣Bleve, 用不同的斷詞, 似乎用sego有稍微快一點(在raspberry pi下, 約五千多筆資料大約快個一秒鐘), 但似乎不是Wukong效率高過於Bleve的主因

到最後, 我是選擇了Bleve, 原因是, 它目前看起來比較活躍, 而Wukong就比較沒啥更新

這陣子都在寫line bot, 本來都host在heroku上面的, 簡單且方便, 後來突發奇想, 想用Rasberry pi 跑看看(跑得動喔)

Line的webhook有一個需求就是要有SSL連結, 走https, 但我不想申請一個certificate, 在raspberry pi上弄, 所幸Cloudflare 有提供免費的SSL certificate, 利用他們的flexible SSL就可以了

flexible SSL的方式是client到他們CDN server走的是SSL沒錯, 但他們server到你的server則是可以走一般的http connection, 再來的第二個問題是, 我家的網路是浮動IP的(後來才去申請固定IP), 所以必須能動態更新Cloudflare上的DNS紀錄

還好Cloudflare是有API

直接自己自幹一個也是可以啦, 但Cloudflare其實也有一個客製版的ddclient:

Dynamic DNS Client: ddclient

步驟可以照著上面文件的步驟來做, 可以從My settings -> Account -> Global API Key取得API key當作ddclient的密碼

接下來碰到的問題是, 我ddclient是跑在raspberry pi上, ddclient預設是用local IP, 這很明顯不對, 因為會用到內部的IP而不是對外那個, 而我家的ASUS無線分享器並沒支援Cloudflare, 我也不太想改firmware, 但這還是有解的, 把ddclient.conf裡加上這行:

use=web, web=checkip.dyndns.org/, web-skip='IP Address' # found after IP Address

這是告訴ddclient不要用local ip而是用web api去找出IP

但這一切….都還是太麻煩了….raspbeery pi總是會不小心碰掉電源, 總是會當機或跑不動, 更何況, 我都已經跑一個server了, ddclient不要再來搶記憶體了啦

最後我的解法是: DNS-O-Matic

這是一個Dynamic DNS的服務, 我的無線分享器也有支援, 它不是自身有DNS server, 而是可以代你去更新妳的DNS紀錄, 而且, 有支援Cloudflare!!! OK, 結案 (偷懶!)