好, 這算我以為我寫過但實際上沒有系列….啥? 剛剛把我以前寫的一個Go package - gopttcrawler更新後, 想說之前好像有寫過相關文章, 但實際上又沒找到(老了?)

沒關係, 把Readme拿來貼一貼就可以混一篇文了(混蛋!!!偷懶!!!)

gopttcrawler

這是我在新聞萬事通裡面用來抓取ptt文章的, 用法也很簡單, 基本上看最後兩種用法就好, 自己個人覺得那比較好用

原本的版本並沒處理18+那個擋在前面的畫面, 查了一下很多人(在python)的做法是去發post取得cookie, 再帶cookie去取, 後來發現, cookie是固定的, 只要這樣就好:

	cookie := http.Cookie{
		Name:  "over18",
		Value: "1",
	}
	req.AddCookie(&cookie)

所以現在像是八卦版這種18+的也可以抓取了

安裝方法: go get -u github.com/julianshen/gopttcrawler

使用方法請參考sample.go或是ptt_test.go #### 資料結構

type Article struct {
	ID       string //Article ID
	Board    string //Board name
	Title    string
	Content  string
	Author   string //Author ID
	DateTime string
	Nrec     int //推文數(推-噓)
}

type ArticleList struct {
	Articles     []*Article //Articles
	Board        string //Board
	PreviousPage int //Previous page id
	NextPage     int //Next page id
}

載入文章列表

  1. 載入最新一頁表特版文章
    articleList, _ := gopttcrawler.GetArticles("Beauty", 0)
    // the 1st parameter is the board name
    // the 2nd parameter is the page id. 0 indicates the latest page
  1. 載入前一頁文章列表
    prevArticleList, _ := articleList.GetFromPreviousPage()

載入文章內容

  1. 載入單篇文章詳細內容
    article := articleList.Articles[0]
    article.Load()
    fmt.Println(article.Content) //印出內文(html)
  1. 取得文章中所有圖片連結
    images := article.GetImageUrls()
  1. 取得文章中的連結
    links := article.GetLinks()

Iterator

新增Iterator功能:

	n := 100

	articles, e := gopttcrawler.GetArticles("movie", 0)
	
	if e != nil {
		....
	}

	iterator := articles.Iterator()

	i := 0
	for {
		if article, e := iterator.Next(); e == nil {
			if i >= n {
				break
			}
			i++

			log.Printf("%v %v", i, article)
		}
	}

上面這範例是抓取最新的100篇文章, 不用管第幾頁, 或是上一頁下一頁, 反正就一直抓

Go routine版本的GetArticles

	ch, done := gopttcrawler.GetArticlesGo("Beauty", 0)
	n := 100
	i := 0
	for article := range ch {
		if i >= n {
			done <- true
			break
		}
		i++
		log.Printf("%v %v", i, article)
	}

這範例一樣也是抓一百篇, 只是抓文章的部分被放到go routine去了, 會立即回傳兩個channel, 第一個是receive only channel, 跟Iterator類似, 一次拿一篇文章, 可以用range, 第二個是一個bool channel, 拿夠了送個訊息通知他終止go routine, 如果把receive部分放到select去, 就是non blocking了, 不會被讀上一頁下一頁的IO給卡住

之前由於想說來玩一下實驗一下Line Messaging API, 就寫了一個叫做新聞萬事通的聊天機器人, 一來是實驗一下API, 二來就是想做一些好玩的東西

加入新聞萬事通請按 :

加入好友 加入好友

一開始的想法很簡單, 掃描聊天內容內有連結的, 去正宗的新聞小幫手查詢, 不過後來越玩越上癮, 就又加了一些功能, 幾個目前有的功能:

  • 掃描假新聞連結 (光只有對連結不太夠, 後來又加上簡單的標題比對)
  • 可以在群組內加入
  • 頭條新聞及其它的分類新聞
  • ptt表特版, 八卦版
  • 最近熱映電影及ptt評論
  • 假新聞話題辨識, 因為現在Line上流傳的假新聞都是沒有連結的, 因此這功能是將聊天內容關鍵字部分去Google搜尋, 如果搜尋到是假新聞便會提醒(這是最後加上的功能)

這個聊天機器人是以Go寫成的, 部署到Heroku上, “沒有”使用任何的資料庫(呃, 那資料呢? 全部在記憶體內)

這篇主要不是要介紹新聞萬事通, 而是要來介紹怎用Go寫一個line的聊天機器人(之後有空再來介紹如何搭配其他語意分析服務), 順便老王賣瓜一下, 來介紹一個我自己包的API - lbotx (還沒補document, 還沒寫example, 不過有做過部分測試)

Go Line Bot SDK開始

Line有提供一個給Go的SDK, 用這個SDK開始來寫其實也還蠻簡單的, 這邊從SDK開始講起, 至於前面申請跟設定的部分就麻煩看一下官方文件

首先, 它在原始碼內, 其實有個echo_bot的範例, 用它來當範本開始最適合也不過了:

func main() {
	bot, err := linebot.New(
		os.Getenv("CHANNEL_SECRET"),
		os.Getenv("CHANNEL_TOKEN"),
	)
	if err != nil {
		log.Fatal(err)
	}

	// Setup HTTP Server for receiving requests from LINE platform
	http.HandleFunc("/callback", func(w http.ResponseWriter, req *http.Request) {
		events, err := bot.ParseRequest(req)
		if err != nil {
			if err == linebot.ErrInvalidSignature {
				w.WriteHeader(400)
			} else {
				w.WriteHeader(500)
			}
			return
		}
		for _, event := range events {
			if event.Type == linebot.EventTypeMessage {
				switch message := event.Message.(type) {
				case *linebot.TextMessage:
					if _, err = bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(message.Text)).Do(); err != nil {
						log.Print(err)
					}
				}
			}
		}
	})
	// This is just sample code.
	// For actual use, you must support HTTPS by using `ListenAndServeTLS`, a reverse proxy or something else.
	if err := http.ListenAndServe(":"+os.Getenv("PORT"), nil); err != nil {
		log.Fatal(err)
	}
}

跟寫一般的HTTP Server沒什麼兩樣, 只是你要透過linebot的Client去parse Http請求, 得到一或多個事件(events), 再根據你需要的事件去處理, 以這個例子來說, 它要處理的只有文字訊息, 因此:

  • event.Type == linebot.EventTypeMessage 判斷這個event是否為訊息(Message)
  • switch message := event.Message.(type) { case *linebot.TextMessage: 判斷是否為文字訊息

為什麼要兩個步驟? 因為events有很多種, 而訊息的event, 又包含了不同的訊息, 像是:

  • 文字 (linebot.EventTypeMessage)
  • 影像 (linebot.ImageMessage)
  • 影片 (linebot.VideoMessage)
  • 聲音 (linebot.AudioMessage)
  • 地點 (linebot.LocationMessage)
  • 貼圖 (linebot.StickerMessage)

然後Events除了訊息(Message)外還有

  • 跟隨/停止跟隨
  • 加入/離開 群組(聊天室)
  • Postback
  • Beacon (進入/離開)

另外, 可以看到在處理TextMessage的最後呼叫了bot.ReplyMessage, 這段等於聊天機器人去回應訊息, Line聊天機器人並不是以http response去回應訊息的, ·bot.ReplyMessage·是一個http call, 直接呼叫Line的API server

bot.ReplyMessage後面可以接多個messages, 但注意不要分開呼叫bot.ReplyMessage送多個訊息, 雖然他是直接呼叫Line API server, 但reply token 只有一次有效

如同送進來的訊息一樣, 回應的訊息不只有文字訊息一種, 比較特別的是還多了幾種特殊訊息型態可以運用

  • Image map
  • 有回應按鈕的模板
  • 確認用的模板
  • Carousel 多欄式模板

以Carousel為例:

imageURL := app.appBaseURL + "/static/buttons/1040.jpg"
template := linebot.NewCarouselTemplate(
	linebot.NewCarouselColumn(
		imageURL, "hoge", "fuga",
		linebot.NewURITemplateAction("Go to line.me", "https://line.me"),
		linebot.NewPostbackTemplateAction("Say hello1", "hello こんにちは", ""),
	),
	linebot.NewCarouselColumn(
		imageURL, "hoge", "fuga",
		linebot.NewPostbackTemplateAction("言 hello2", "hello こんにちは", "hello こんにちは"),
		linebot.NewMessageTemplateAction("Say message", "Rice=米"),
	),
)
if _, err := app.bot.ReplyMessage(
	replyToken,
	linebot.NewTemplateMessage("Carousel alt text", template),
).Do(); err != nil {
	return err
}

這個例子回應了一個包含兩欄的Carousel message, 每欄有兩個動作按鈕, 這邊有一個限制, Carousel裡面每一欄的動作按鈕的數量必須要一致, 總共最多也只能五欄

寫新聞萬事通碰到的問題

其實也不算問題啦, 是覺得寫起來code很醜, 一開始寫簡單的聊天機器人, 以echo_bot開始去擴充就夠了, 但當功能越來越多時, 阿娘喂~~ 一堆if和switch case, 我是看到整個眼花, 不知道別人是怎樣, 但實在很難看

看看kitchensink的範例就知道了, 不好讀

另外像是Carousel也是一個麻煩的地方, 要一個個建出Column和它對應的actions, 然後產生一個linebot.CarouselTemplate, 最後用這個template產生template message才可以送出, 上面的例子只有兩欄, 較為簡單, 感覺不到痛, 如果是不定數量的, 照這三個步驟, 才發一個message, 覺得麻煩又不易閱讀

Line messaging api其實有很多數量上的限制, 像是字數之類的, API本身不會做檢查, 必須送到server後, 才會回傳錯誤回來, 這點也是需要改進的

我想怎麼做?

我想怎麼做就是我後來包裝出來這這個lbotx所做的事情

lbotx 有什麼特色?

  • 撇開if和switch…case (詳細如何呢? 後面再看code吧)
  • Chaining handlers 由寫新聞萬事通的經驗來說, 一個event不太可能只有一段邏輯來處理, 通常會是層層把關, 比如說我們判斷多個指令, 可能會是先檢查是不是A, 然後再檢查是不是B, 一直下去, 但這樣就一堆if了, 這是為了解決這一問屜
  • 讓reply message變好懂一點
  • 提供一些方便的工具

開始使用lbotx

lbotx是一個line bot sdk的再包裝, 所以她底層還是依賴著linebot

bot, e := NewBot("test", "test")
server := http.HandleFunc("/callback", bot)

bot本身就是一個http.Handler, 所以不需要包裝在另一個HandlerFunc裡面, 那如果你用Gin呢?

r := gin.Default()
r.GET("/callback", bot.Gin())

也是支援的

撇開if和switch…case

剛剛有說到, 光處理一個文字訊息, 我們需要先判斷event是不是一個訊息, 然後再判斷是不是文字訊息, 判斷event的型別是字串的比對, 但判斷是不是文字訊息又是用到變數型態的辨別, 這已經是reflection了, 這設計不是很好看, 因此在lbotx用:

bot.OnText(func(context *lbotx.BotContext, msg string) (bool, error) {
	fmt.Println(msg)
	context.Messages.AddTextMessage("test1")
	context.Set("test", "test")
	tested = tested + 1
	return true, nil
})

因為lbotx已經把判斷的部分包裝了, 所以用這樣就可以了

對於其他種Event, 也是有的

bot.OnVideo(func(context *lbotx.BotContext, data []byte) (bool, error) {
	...
	return false, nil
})

bot.OnLocation(func(context *lbotx.BotContext, location *linebot.LocationMessage) (bool, error) {
	...
	return false, nil
})

bot.OnFollow(func(context *lbotx.BotContext) (bool, error) {
	fmt.Println("follow : " + context.GetUserId())
	user, _ := context.GetUser()
	...
	return false, nil
})

不同event預設接進來的參數都不同, 不過都有context, context裡面帶有原始的Event資料, 並且可以讓你帶資料到下一個Handler去

Chaining handlers

bot.OnText(func(context *BotContext, msg string) (bool, error) {
	fmt.Println("first handler")
	context.Messages.AddTextMessage(msg)
	context.Set("test", context.Get("test").(string)+"a")

	next := false
	if context.Event.Source.GroupID != "" {
		next = true
	}
	return next, nil
})

bot.OnText(func(context *BotContext, msg string) (bool, error) {
	//Should never run when type = user
	fmt.Println("second handler")
	context.Messages.AddTextMessage("test1")
	context.Set("test", "test")
	//throw error
	return true, errors.New("Error on purpose")
})

上面就是一個Chaning handlers的範例, 有兩個OnText, 因此當有Text message進來時, 這兩個handler就會一前一後被執行

只有兩種狀況可以中斷chaning handlers不執行下面剩下的handlers:

  • 回傳值為false (這值代表的是要不要執行下一個還是到此為止)
  • 錯誤發生時 (也就是回傳error, 這時候OnError就會被呼叫到)

上面的範例第一個handler在非群組訊息時就才繼續下一個, 另外最後一個會回傳錯誤

前面有提到context可以帶值在chaining handlers間傳遞, 就是利用context.Setcontext.Get

另外, 由於Reply token只能用一次, 而且在多個handlers時, 讓handler自己reply message並不合適, 因此改用context.Messages.AddMessage這類的, 所有handlers執行完後, 會被一次送出

讓reply message變好懂一點

主要是針對Carousel才會想去改動這部分:

b := NewButtonMessageBuilderWith("https://upload.wikimedia.org/wikipedia/commons/c/c4/Leaky_bucket_analogy.JPG", "Leaky Bucket", "For test")
b.WithMessageAction("test", "test1")
b.WithURIAction("test2", "http://www.google.com")
b.WithPostbackAction("test3", "test3data", "test3")
message, _ := b.Build("AltText")

d := NewCarouselMessageBuilder()
for i := 0; i < 5; i++ {
	col := d.AddColumn()
	col.WithImage("http://upload.wikimedia.org/wikipedia/commons/c/c4/Leaky_bucket_analogy.JPG")
	col.WithText("test")
	col.WithTitle("test")
	col.WithMessageAction("Message", "test")
	col.WithURIAction("Google", "http://www.google.com")
}

message, _ = d.Build("altText")

採用了Builder的方式取代一直append array, 雖然code沒省多少, 但看起來比較明暸一點

後來又覺得Carousel裡面的東西, 其實都很一致, 所以又多了下面這種寫法:

b := NewCarouselMessageBuilder()
g := b.GetColumnGenerator()
g.WithImage("http://myhost.com/image/")
g.WithText("Hi ")
g.WithMessageAction("Press me", "I'm ")

data := []struct {
	Index int
	Name  string
}{
	{1, "John"},
	{2, "Mary"},
	{3, "Julian"},
}

b.GenerateColumnsWith(func(data []struct {
	Index int
	Name  string
}) []interface{} {
	ret := make([]interface{}, len(data))
	for i, d := range data {
		ret[i] = d
	}
	return ret
}(data)...)

message, _ := b.Build("altText")

這叫Column generator, 是借助了Go的text/template這個pacakge, 設好template後, 餵資料就可以了

提供一些方便的工具

BotContext

前面提到的context, 還算一個蠻重要的東西, 在前面的範例裡面有一個user, _ := context.GetUser()這是用來取代linebot.GetProfile()的, 原本的GetProfile寫法較為繁瑣, 把它放到context的話, handler之間可以共用, 如果有handler已經從server取過後, 另一個用到就不需要重取

OnTextWith

這是一個OnText變形, 只處理符合條件的Text Message

bot.OnTextWith("Hello, . Can you give me ?", func(context *BotContext, text string) (bool, error) {
	assert.Equal(t, context.Params["name"], "Julian")
	assert.Equal(t, context.Params["thing"], "apple")

	return true, nil
})

第一個參數是一個包含變數的文字模板, 符合這模板的才會去執行這handler, 此外裡面的字串也會被當變數取出, 可以從context.Params取得, 雖然沒api.ai那樣強大, 但這樣的應該勉強堪用吧

還有其他嗎?

document還沒補齊, 也還沒sample codes, 這需要之後來補齊了, 本來是想包裝成line跟Facebook通用的, 不過還沒真的去看Facebook, 就先把Line的包一包吧

新聞萬事通也還沒改成這個架構, 這架構打算在我下一個bot上來應用吧, 新聞萬事通, 目前用戶太少了, 沒動力改 :P

2017的第一篇blog就先又來回的go跟爬蟲了

goquery在Go是像jquery一樣的存在, 可以讓你用jquery一樣的selector來解析html檔案內容, 像這樣:

doc.Find(".sidebar-reviews article .content-block")

只要有一些jquery的知識大概就不難上手, 但一般的網頁內容並不只有html這麼簡單而已, 有非常多的網頁是根本把資料給放在javascript, 在browser端再重組而成, 碰到這種, 光解析網頁原始檔是不夠的, 因為很多tag的內容都是之後被javascript所產生的

因此解析這類的網頁, 便可能需要去解析javascript的內容, 甚至是執行javascript, 在Go有一個好用的套件叫Otto可以來解決這麻煩

這邊以Line Today的網頁當作範例來說明, 如果你打開Line today的網頁原始檔來看, 絕大部分都是javascript, 而且資料似乎都在categoryJson內(如下)

<script>
    var categoryJson = {
		"categories":[
			{"id":100003,"name":"今日頭條","source":0,"title":null,"thumbnail":null,"url":{"type":"FILE","hash":
		...
		}
		...
</script>

一個土法煉鋼的方式是找到categoryJson前後的{},把它substring出來, 這內容當Json來分析, 但這樣其實還蠻容易出錯的

另一個方式是先用goquery取得這個script內容, 然後用Otto去執行它後把值取出來, 像:

url := "https://today.line.me/TW"
doc, err := goquery.NewDocument(url)

if err != nil {
	log.Println(err)
	return
}

s := doc.Find("script")
script := s.Nodes[1].FirstChild.Data

vm := otto.New()
vm.Run(script)

if value, err := vm.Get("categoryJson"); err == nil {
	goData, _ := value.Export()
	mapData := goData.([]map[string]interface{})
	...
}

otto很方便, 可以直接執行這個javascript, 還可以取裡面的值, 取出來的值其實是一個叫Value的struct, 如果要方便用的話, 就要用Export()來把資料轉成interface{}

interface{}對object來說還是很不好取用, 如果確定你要的值是個javascript object, 那轉成[]map[string]interface{}會稍稍方便點

不過針對很複雜也很深的物件來說, []map[string]interface{}也不是那麼的好用, 當你要存取的值要往下好多層時, 光轉型就要做到瘋掉, 多層次的map很麻煩的呀!

因此我的做法是, 多加一段javascript來減低取出的結構的複雜度, 像這樣:

vm := otto.New()
vm.Run(script)
_, err = vm.Run(`
	var news = categoryJson.categories[0].templates[0].sections[0].articles;

	var articles = [];

	for(var n in news) {
		var article = {
			'title': news[n].title,
			'link': news[n].url.url,
			'publisher': news[n].publisher
		};
		articles.push(article);
	}
`)

if err != nil {
	fmt.Println(err)
}

if value, err := vm.Get("articles"); err == nil {
	goData, _ := value.Export()

	mapData := goData.([]map[string]interface{})
	todayNews = make([]TodayNews, 0)
	for _, data := range mapData {
		news := TodayNews{}
		news.Title = data["title"].(string)
		news.Link = data["link"].(string)
		news.ImageUrl = data["imageUrl"].(string)
		todayNews = append(todayNews, news)
	}
}

這樣一來就稍微簡單多了, 不過目前碰到的缺點是, 似乎有些ES6的語法, 它不太認得呀

Retrofit目前已然成為最熱門的呼叫REST API的開源程式庫了,不過, 大部分的Retrofit多是用來處理Json類的REST API, 但Retrofit的能力卻不僅限於此

透過Converter, Retrofit可以處理的不只是Json, 還包含了XML, Protobuf, 甚至你自己的自訂格式, 剛好最近看Json不太順眼, 想來試試Protobuf, 就來試試這部分

Retrofit官方網頁上列的Protobuf converter有兩種, 一種是使用Google親生的Protobuf, 這個只要引入com.squareup.retrofit2:converter-protobuf即可使用, 另一種是有點比較吸引我的是Square自己開發出來的Wire, Sqaure真的是蠻喜歡打造自己的東西的, 打造出來的又比別人威, 這點讓我對Wire的興趣比較大, 因此這篇主要是以Wire當範例來介紹

使用Wire converter其實相當簡單的:

在build.gradle內加入相關的dependencies, 包含了wire runtime跟retorfit的converter:

compile 'com.squareup.retrofit2:converter-wire:2.1.0'
compile 'com.squareup.wire:wire-runtime:2.2.0'

在build service時, 用addConverterFactoryWireConverterFactory加入即可

Retrofit retrofit = new Retrofit.Builder()
		.baseUrl(baseUrl)
		.addConverterFactory(WireConverterFactory.create())
		.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
		.build();
DataService service = retrofit.create(DataService.class);

DataService的定義如下(這範例搭配了rxjava2):

public interface DataService {
    @GET("data")
    Single<Posts> getPosts();
}

這邊有個問題, Posts這個class並不是直接用Java刻出來的, 而是由.proto檔產生的, 內容如下:

syntax = "proto3";
package wtf.cowbay.dp;

message FBPost {
	string id = 1;
	string title = 2;
	string message = 3;
	string imgsrc = 4;
	string target = 5;
	string createdtime = 6;
}

message Posts {
	repeated FBPost data = 1;
}

必須要用工具產生Java class才能使用, Google的Protobuf有自己的工具, 而Wire也有自己的, 如果手動自己執行工具產生檔案後再加入, 未免太鳥,

還好Wire有wire-gradle-plugin, 不過這個有個大問題, 雖然放在Square的repo下, 但似乎 不是官方版本, 而是有人貢獻的, 因此並沒有跟上最新的2.x的版本, JakeWharton大神說將會有官方版本, 但似乎從六月到現在都沒出現, 所以只好自力救濟自己改 - 我的版本

使用這個plugin很簡單, 先在第一層的build.gradle裡的buildscript加入:

buildscript {
    repositories {
        jcenter()
        maven {
            url "https://jitpack.io"
        }
    }
    dependencies {
        classpath 'com.android.tools.build:gradle:2.2.3'

        classpath 'com.github.CowBayStudio:wire-gradle-plugin:ver12'
    }
}

主要是加入jitpack.io, 並把我的版本的plugin放到dependencies去

接下來在app的build.gradle裡加入:

apply plugin: 'com.squareup.wire'

.proto檔案放置的位子在app/src/main/proto, 把檔案放在這邊, build的時候就會自動產生這些對應的Java classes了

自從Android導入gradle之後, 使用開放的第三方的程式庫就越來越方便了, 雖然方便, 但也不免會碰到這類的問題:

  1. 想要的功能在master branch上更新了, 但卻遲遲不release以至於想用新的功能無法用
  2. 程式碼已經沒在維護了, maven repository上一直都還是有問題的舊版本, 明知道怎麼修卻無法代他release到maven repository上去, PR又遲遲沒人理
  3. 想加上自己的私有功能, 又不想包整包source codes到app裡面去
  4. 想要開放自己做的程式庫卻覺得release到maven很麻煩

還好有Jitpack這東西, 剛剛就是碰到一個東西有問題, 想把它修掉直接用, 研究了一下

用法很簡單, 首先要先把maven { url 'https://jitpack.io' }加入到repositories裡面去

allprojects {
    repositories {
        maven { url 'https://jitpack.io' }
    }
}

然後在你的dependencies裡面加入:

compile 'com.github.User:Repo:Tag'

User就是你Github的user name, Repo是Repository的名稱, Tag是Git的tag名稱, 舉個例:https://github.com/CowBayStudio/wire-gradle-plugin , 像這樣的Url, User name就是CowBayStudio, repo就是wire-gradle-plugin , 如果你的Git repo並沒有任何的Tag, 可以用Commit hash或是branch-SNAPSHOT(例如master-SNAPSHOT), 當然 自己加上tag會是比較好的做法, 比較好控管

當你去build你的app時, 在抓這個dependency時, Jitpack就會自動幫你把code從github抓下來build好, 當然是developer就免不了有bug, 導致build fail, 去 Jitpack網站把你Github的URL貼上去就可以找到build log了, 像是這個

我在弄我的東西時, 就發生了build fail的狀況, 而問題的原因是其中一個依賴的jar是JAVA 8 build出來的, 但jitpack用Java 7去build我的程式庫(文件說default應該是Java 8呀, 騙我!), 這時候可以加一個jitpack.yml, 內容如下:

jdk:
  - oraclejdk8

透過jitpack.yml可以有很多客制可設定, 詳情就看一下文件吧!

所以碰到自己想動的程式庫, 可以直接fork出來改, 改出來的就可以直接這樣用了

那private repo呢?付錢給Jitpack就有啦! XD