最近因為寫bot, 處理不少的HTML資料, 其中最常用的就是去取的Open Graph的內容, 取這部分的資料是做啥用呢? 現今, 多數的網頁已會用Open GraphTwitter Card 來描述網頁的一些屬性, 比如說標題, 相關圖片, 關聯影片等等, 而不管是Open Graph還是Twitter Card 都以HTML的meta tags存在的, 像這樣:

<meta property="og:site_name" content="TechCrunch" />
<meta property="og:site" content="social.techcrunch.com" />
<meta property="og:title" content="Hugo Barra is leaving his position as head of international at Xiaomi after 3.5&nbsp;years" />
<meta property="og:description" content="Chinese smartphone maker Xiaomi is losing its head of international and primary English-language spokesperson Hugo Barra after he announced his exit from the.." />
<meta property="og:image" content="https://tctechcrunch2011.files.wordpress.com/2016/05/hugo-barra-2.jpg?w=764&amp;h=400&amp;crop=1" />
<meta property="og:url" content="http://social.techcrunch.com/2017/01/22/hugo-barra-is-leaving-his-position-as-head-of-international-at-xiaomi-after-3-5-years/" />
<meta property="og:type" content="article" />
<meta name='twitter:card' content='summary_large_image' />
<meta name='twitter:image:src' content='https://tctechcrunch2011.files.wordpress.com/2016/05/hugo-barra-2.jpg?w=764&#038;h=400&#038;crop=1' />
<meta name='twitter:site' content='@techcrunch' />
<meta name='twitter:url' content='https://techcrunch.com/2017/01/22/hugo-barra-is-leaving-his-position-as-head-of-international-at-xiaomi-after-3-5-years/' />
<meta name='twitter:description' content='Chinese smartphone maker Xiaomi is losing its head of international and primary English-language spokesperson Hugo Barra after he announced his exit from the company.' />

(範例剛好跟上時事 :P)

在做我的bot新聞萬事通就是用這個來取得圖片跟標題的內容, 當然, 你在Facebook分享了一個連結, Facebook會自動帶上縮圖跟內容, 資料來源也是來自OG

OG

雖然Go也可以找到一兩個Open graph的parser, 看起來好像還算堪用, 不過因為自己用得多了, 所以索性自己寫一個叫OG:

GitHub/julianshen - OG

仿golang本身的json package, 也利用了Reflection, 因此還有點靈活度, 可以支援更多的資訊

安裝OG

go get -u github.com/julianshen/og

基本資料結構

type PageInfo struct {
	Title    string `meta:"og:title"`
	Type     string `meta:"og:type"`
	Url      string `meta:"og:url"`
	Site     string `meta:"og:site"`
	SiteName string `meta:"og:site_name"`
	Images   []*OgImage
	Videos   []*OgVideo
	Audios   []*OgAudio
	Twitter  *TwitterCard
	Content  string
}

PageInfo是預設的資料結構, 前面有提到, 有彈性可以支援更多的資訊, 所以這個除了直接使用外也可當作一個參考用的定義, 主要就是採用了Go的struct field tags, 定義了一個叫做"meta“的自訂tag, 對照前面的html範例就可知道, 後面的值 是html meta tag裡面的property, 因此你可以自定義自己的資料結構, 然後仿這個規則加上tag即可

也可支援巢狀式, Arrays, Pointer

GetPageInfo

GetPageInfo是以已經定義好的PageInfo這個struct為主, (希望)已經包含比較基本的og或twitter card tags了, 直接呼叫對應的API, 就可以取得這個url裡面的PageInfo的資料了

urlStr := "https://techcrunch.com/2017/01/22/yahoo-hacking-sec/"
pageInfo, e := og.GetPageInfoFromUrl(urlStr)

另外還有一個PageInfo.Content, 這是網頁去掉廣告跟多餘的東西的純文字內容, 這就是GetPageData所沒有的了

GetPageData

跟GetPageInfo不同, 這很適合用在自訂資料結構, 舉個例說, 如果我只想抓og:image的資料就像這樣:

ogImage := og.OgImage{}
urlStr := "https://techcrunch.com/2017/01/22/yahoo-hacking-sec/"
og.GetPageDataFromUrl(urlStr, &ogImage)

簡單談談Go reflection

這個package因為要仿json package使用struct field tags, 所以用了Go的Reflection機制

老實說, Go的reflection沒有像Java的設計那麼好, 寫到後面還蠻容易昏頭的, 而主要有三種要先搞清楚

以這個例子來說:

ogImage := og.OgImage{}
pImage := &ogImage

ogImage的Type是struct OgImage, 而Value是og.OgImage{}, pImage的Type即是*OgImage, 而ogImage的Kind則是struct, pImage則是ptr(pointer)

比較難搞懂的就是什麼時候要用Type, 什麼時候要用Value, Kind又是什麼時候? (作業?!)

type := reflect.TypeOf(ogImage)
value := reflect.Value(pImage)

用’TypeOf’可以取得變數的Type, 而’ValueOf‘則是值(value)的部分, 而value雖然代表變數的值, 但它是一個叫做Value的struct,並不是原本的資料型態 所以不要把它直接當參數呼叫函數用, 如果真有需要, 可以用Value.Interface{}轉成interface{}

StructField.Tag.Lookup則是可以查Field裡面的tag內容

所以這個og parser的原理就是走過所有fields, 只要有meta tags的話就拿去搜尋對應的html tags, 有的話再把html tag裡面content屬性填入值

最後小抱怨一下, Open GraphTwitter Card 實在很不一致, 一個用property, 一個用name, 因此在OG的做法是先用property, 如果再找不到用name去找

之前由於想說來玩一下實驗一下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/{{.Index}}")
g.WithText("Hi {{.Name}}")
g.WithMessageAction("Press me", "I'm {{.Name}}")

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, {{name}}. Can you give me {{thing}}?", 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

昨天趁等著去面試前稍微把這之前想要寫一下的這題目打包成一個Gin的middleware :

Rate limiting 通常在很多開放API的服務內會常看到, 像是Twitter, 像是Facebook或是新浪微博, 其目的就是希望API不要被特定節點頻繁存取以致於造成伺服器端的過載

rate limiter

一般的Rate limiting的設計大致上來說就是限制某一個特定的節點(或使用者或API Key等等)在一段特定的時間內的存取次數, 比如說, 限制一分鐘最多60次存取這樣的規則, 最直覺的方式我們是可以起一個timer和一個counter, counter大於60就限制存取, timer則每60秒重置counter一次, 看似這樣就好了, 但其實這有漏洞, 假設我在第59秒時瞬間存取了60次, 第61秒又瞬間存取了60次, 在這設計上是合法的, 因為counter在第60秒時就被重置了, 但實質上卻違反了一分鐘最多60次這限制, 因為他在兩秒內就存取了120次, 遠大於我們設計的限制, 當然我們也可以用Sliding time window來解決, 但那個實作上就稍稍複雜點

目前兩個主流比較常見的做法是Token BucketLeaky Bucket, 這兩個原理上大同小異

先來說說Token Bucket, 他的做法是, 假設你有個桶子, 裡面是拿來裝令牌(Token)的, 桶子不是Doraemon的四次元口袋, 所以他空間是有限的, 令牌(Token)的作用在於, 要執行命令的人, 如果沒從桶子內摸到令牌, 就不准執行, 然後我們一段時間內丟一些令牌進去, 如果桶子裡面已經裝滿就不丟, 以上個例子來說, 我們可以準備一個最多可以裝60個令牌的桶子, 每秒鐘丟一個進去, 如果消耗速度大於每秒一個, 自然桶子很快就乾了, 就沒牌子拿了

Leaky BucketToken Bucket很像, 不過就是反過來, 我們把每次的存取都當作一滴水滴入桶子中, 桶子滿了就會溢出(拒絕存取), 桶子底下打個洞, 讓水以固定速率流出去, 這樣一樣能達到類似的效果

Leaky Bucket

Go的rate limiter實作

Go官方的package內其實是有rate limiter的實作的:

照他的說法他是實作了Token Bucket, 創建一個Limiter, 要給的參數是Limit和b, 這個Limit指的是每秒鐘丟多少Token進桶字(? 我不知道有沒理解錯), 而b是桶子的大小

實際上去用了之後發現好像也不是那麼好用, 可能我理解有問題, 出現的並不是我想像的結果, 因此我換用了Juju’s ratelimit, 這個是在gokit這邊看到它有用, 所以應該不會差到哪去, 一樣也是Token Bucket,給的參數就是多久餵一次牌子, 跟桶子的大小, 這就簡單用了一點

包裝成Gin middleware

要套在web server上使用的話, 包裝成middleware是比較方便的, 因此我就花了點時間把Juju’s ratelimit包裝成這個:

使用範例如下:

    //Allow only 10 requests per minute per API-Key
	lm := limiter.NewRateLimiter(time.Minute, 10, func(ctx *gin.Context) (string, error) {
		key := ctx.Request.Header.Get("X-API-KEY")
		if key != "" {
			return key, nil
		}
		return "", errors.New("API key is missing")
	})
	//Apply only to /ping
	r.GET("/ping", lm.Middleware(), func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})

	//Allow only 5 requests per second per user
	lm2 := limiter.NewRateLimiter(time.Second, 5, func(ctx *gin.Context) (string, error) {
		key := ctx.Request.Header.Get("X-USER-TOKEN")
		if key != "" {
			return key, nil
		}
		return "", errors.New("User is not authorized")
	})

	//Apply to a group
	x := r.Group("/v2")
	x.Use(lm2.Middleware())
	{
		x.GET("/ping", func(c *gin.Context) {
			c.JSON(200, gin.H{
				"message": "pong",
			})
		})
		x.GET("/another_ping", func(c *gin.Context) {
			c.JSON(200, gin.H{
				"message": "pong pong",
			})
		})
	}

這邊本來想說為了簡單理解一點把參數設計成"每分鐘不能超過10次"這樣的描述, 然後後面再轉換成實際的fillInterval, 不過好像覺得怪怪的, 有點不太符合Token Bucket的特質, 寫成middleware後的彈性就較大一點, 可以全部都用一個limiter或是分不同的資源不同限制都可

這邊建構時要傳入一個用來產生key的函數, 這是考慮到每個人想限制的依據不同, 例如根據API key, 或是session, 或是不同來源之類的, 由這函數去 產生一個對應的key來找到limiter, 如果傳回error, 就是這個request不符合規則, 直接把他拒絕掉

跨server的rate limit

這方法只能針對單一server, 但現在通常都是多台server水平擴展, 因此也是會需要橫跨server的解決方案, 這部分的話, 用Redis來實作Token Bucket是可行的, 這等下次再來弄好了

之前寫了一些爬蟲, 想說來補一篇這樣的文章好了

可能是之前"所謂"的大數據(Big Data)太過流行, 以至於網路爬蟲好像是一種顯學, 隨便Google一下都可以找到一堆用python加上BeautifulSoup 相關的文章, 這可能也是因為, 現在網路上的資料, Open data的, 提供API的, 在比例上還是非常的少數, 但網頁的數量真的多到很難統計(想到早期還真是屈指可數) 要取得網頁內的內容, 解析HTML, 做字串的處理就是一個必要的基礎, 這也難怪python + BeautifulSoup 一直廣泛的被採用

不過, 當你真的去寫一個爬蟲, 突然就會發現了, 代誌不是這麼單純呀, 不是去抓個html回來解析一下就有自己想要的資料了呀, 而是會發現, 怎麼大家正正當當的網頁不寫, 一堆奇技淫巧, 繞來繞去的, 網頁廣告內容也一堆, 很多網頁都很難抓到自己想要的資料呀!!!

真的來說, 做爬蟲是一種Hacking的活動, 天下網頁萬萬種, 為了總總不同的目的, 早已不是幾個簡單的html標籤就做出來的了, 還搭配了很多程式的技巧在內, 因此要從裡面萃取資料出來, 常常還真的是要無所不用其極

那到底做爬蟲會先需要懂什麼?

  1. HTML
  2. CSS
  3. DOM
  4. DOM Selectors
  5. Javascript
  6. Regular Expression (正規表示式)
  7. Chrome dev tool
  8. Curl

以上這幾個東西可能是基本必須懂得, 而不是程式語言, 那反而其次, 很多程式語言都有很好的能力跟工具來做, 另外需要具備的是耐心, 眼力, 直覺, 和運氣

底下就拿我之前弄過的幾個東西當範例, 由於我寫比較多Go和nodejs, 所以就不用(主流的)Python來做範例了

範例一 : 文章內容分析 (如新聞, 部落格文章等等)

這應該是比較常見的應用, 單純抓取文章內容去做分析, 常碰到的麻煩是現在網站放了大大小小的(補釘)廣告, 那些跟內容不相干, 也不會是我們想拿來分析的目標

底下這個範例是從Yahoo! News的RSS抓取新聞文章連結, 再從這些連結的文章內找出關鍵字:

這段code非常的簡單, 主要也只用到以下這幾種東西:

  1. rss parser
  2. go-readability
  3. 結巴

簡單的來說就是先從rss內找出所有新聞的連結再一個個去爬, 然後用go-readability精簡出網頁內文, 再用結巴取出關鍵字

readability

這一個library以往的用途就是清除不必要的html tags跟內容(像是廣告), 只留下易讀的內文(純文字), 以這個例子來說, 這是一個最適合的工具了

最早的readability應該是arc90這人開源出來的, 最早應該是javascript的版本, 但就算你不是用javascript, 它老早也被翻唱成其他語言的版本了, 像是:

  1. Python - python-readability
  2. Node.js - node.js readability
  3. Java - JReadabilitysnacktory
  4. Go - go-readability

結巴 jieba

結巴 jieba 是一個用在做中文分詞的工具, 英文每個單詞都是用空白分開的, 但中文就不是那麼回事了, 結巴 jieba 可以幫忙作掉這部份的工作, 這可以拿來做文章分析或是找關鍵字用

一樣有好幾種語言的版本 - Java, C++, Node.js, Erlang, R, iOS, PHP, C#, Go (參照結巴的說明內文), 算蠻齊全的了

範例二 : 抓取Bilibili的視訊檔位址

這個範例稍微做了點弊, 但還是從頭把分析過程來講一下好了

Bilibili視頻網頁長得就像這樣: 範例 - http://www.bilibili.com/video/av6467776/

先簡單的從網址猜一下….“av6467776"應該是某個ID之類的東西, 再進一步, ID可能就是這個"6467776”

接下來我們就需要借助一下Chrome的"開發人員工具", 這是一個強大也重要的工具, 不要只傻傻的用View source而已, View source能看到的也只有原始HTML的內容

開了網頁後, 用Ctrl+Shift+I (windows)或是Cmd+Opt+I (mac)打開他, 打開後先選到elements, 像這樣:

Chrome devtools elements

這邊標示了四個部分, 先點選了1, 再用滑鼠游標點你想知道的元件(以這邊來說是那個視訊框 2 的地方), 然後他就會幫你跳到相關的HTML位置(如3), 而4所標示出的是css屬性

把object這部份點開, 果然, 在flashvars那邊我們可以找到"cid=10519268&aid=6467776&pre_ad=0"這樣的字串, 表示"6467776"的確是某種叫aid的東西, 但, 這不代表找到結案了!! 我們再用curl檢查一下:

curl http://www.bilibili.com/video/av6467776/ --compressed

抓原始的html檔來比對一下(可以把這指令的輸出存成檔案在來看會方便點), 怎麼沒"object"這標籤呀?到哪去了?可見剛剛那段html是某段javascript去產生的

再回頭看DevTools上object那段, 可以發現它是一個div包起來的, 這div的class是scontent, id是bofqi, 再回頭去看原始的HTML, 整段也只有一個這樣的block, 內容是這樣:

    <div class="scontent" id="bofqi">
    <div id='player_placeholder' class='player'></div>
<script type='text/javascript'>EmbedPlayer('player', "http://static.hdslb.com/play.swf", "cid=10519268&aid=6467776&pre_ad=0");</script>
    </div>

OK, 這邊就很容易可以確定從scontent這區塊就可以找到兩個id - aid和cid , 但這能做什麼用? 還不知道

接著切換到Network那邊去, 然後再重新整理一下頁面:

Chrome devtools network

左下角的部分是瀏覽器在畫出這個頁面所載入的內容跟檔案, 一開使用時序排序的, 當然你可以用其他方式排序, 這邊就是可以挖寶的地方了

先看到上面那一堆長長短短的線, 這是瀏覽器載入檔案的時間線, 點選這邊可以只看特定時間區間的部分, 最後面可以發現有一條長長的藍線, 那可能就是視訊檔了(因為通常較大), 因此我們可知這視訊檔的URL是

http://61.221.181.215/ws.acgvideo.com/3/46/10519268-1.flv?wsTime=1475663926&wsSecret2=893ba83b8f13d8700d2ae0cddab96c55&oi=3699654085&rate=0&wshc_tag=0&wsts_tag=57f467b8&wsid_tag=dc843dc5&wsiphost=ipdbm

但這串怎麼來的, 依我們手上只有兩個id的資訊是拼湊不起來的, 它一定從某個地方由這兩個id轉換出來的(合理的猜測), 因此, 我們可以再把aid, cid拿去搜尋檔案(點選檔案列表那區塊, 按ctrl-f或command-f開搜尋框)

一個個看, 找出可能的檔案, 由於aid可以找出22個結果而cid只有10個, 從cid開始找起會比較簡單點, 這邊篩選出幾個可能性:

  1. http://interface.bilibili.com/player?id=cid:10519268&aid=6467776 - 回傳是一個xml, 有一些相關資訊, 但沒影片位址
  2. http://interface.bilibili.com/playurl?accel=1&cid=10519268&player=1&ts=1475635126&sign=e1e2ae9d2d34e4be94f46f77a4a107ce - 從這回傳裡面有個durl > url, 跟上面url比對, 似乎就是他了, 後面再來講這段
  3. http://comment.bilibili.com/playtag,10519268 - 這應該是"看过该视频的还喜欢"裡的內容
  4. http://comment.bilibili.com/10519268.xml - 喔喔喔, 看起來這就是彈幕檔喔!
  5. http://comment.bilibili.com/recommendnew,6467776 - 這似乎是推薦視頻的內容
  6. http://api.bilibili.com/x/tag/archive/tags?jsonp=jsonp&aid=6467776&nomid=1 - 看起來這是tag

看來, 2 應該就是我們所要的了, 不過這邊有兩個麻煩, 一個是…我討厭XML!!!!!不過這好像還好, 似乎有個HTML5版本, 點點看好了, 果不其然, 發現另一個:

https://interface.bilibili.com/playurl?cid=10519268&appkey=6f90a59ac58a4123&otype=json&type=flv&quality=3&sign=571f239a0a3d4c304e8ea0e0f255992a

表示我們是可以用otype=json來抓取json格式的, 但後面這個更麻煩了, 那個sign是什麼東西? 從他有個appkey來看, 合理的猜測, 他是某種API的signature, 通常這種東西的規則是把所有的參數先依名字排序成新的query string, 加上某個secret, 再算出他的MD5即是他的sign

但如果真是這樣, 這下有點麻煩, 到哪裡找這個secret, 不過凡走過必有痕跡, 這串既然是由瀏覽器端產生的, 那應該會在哪裡找到點線索, 或許可以先用appkey的內容去每個javascript檔案搜尋吧

不過, 不出所料, 找不到, 那, 還有一個可能性, 它寫在flash內, 從上面抓到的資訊來看, 他的flash檔案應該是: http://static.hdslb.com/play.swf

可以把它抓回來反編譯(decompile), 有個工具叫JPEX Flash decompiler的, 可以做到這件事

JPEX

在script裡面有它的程式原始碼, 應該可以在裡面找到, 不過有點辛苦, 因為你也沒辦法從appkey找到那個secret, 這邊直接跳轉答案, 應該就如同截圖所示是"com.bilibili.interfaces.getSign"這邊, 只是被混淆到很難看, 看得很頭痛, 會短命的

理論上, 把這段源碼翻譯一遍後應該就解決了, 但一來我看不太懂action script, 二來我實在懶得看, 想偷懶, 有沒作弊的方法? 凡走過必留下痕跡嘛, 一定還會有前人走過這條路, 所以直接把"6f90a59ac58a4123"這串appkey拿去搜尋, 果然, 找到secret了, 寫個程式驗證一下, 果然是沒錯的

範例三: 楓林網

楓林網是一個非法的電視劇來源, 有相當齊全的電視劇內容, 我這個是之前寫的一個工具了, 可以把一整部劇可以抓回本地端, 原始碼跟使用方法在這:

這邊就不再說明怎麼使用它了, 主要著重它是怎做出來的, 這一個跟上面幾個不一樣的地方在於我是用nodejs寫的, 之所以用nodejs是有原因的, 後面再解釋, 主要的程式碼在88.js

楓林網的電視劇集的網址長成這樣: http://8drama.com/178372/ , 當然178372又是ID了, 但它裡面所有的東西ID長得都是一個樣, 一整部劇跟單一集的ID都是同一個格式, 所以是無法從ID判斷出他是哪類

一樣打開Chrome DevTools, 點選到每一集的列表區塊, 我們可以發現在"“裡面的可以找到每一集的URL, 而他的格式都是http://8drama.com/(ID), 所以我們可以輕易的用regular expression分辨出來

接下來就要掃每一集的內容把影片檔找出來了

當然可以用前面的方法試試看, 不過那方法在這邊沒啥用, 因為這邊影片的網址編碼是用好多亂七八糟的javascript堆積起來, 沒記錯的話, 我是從video.js找到線索的(這有點時間之前做的了, 有點沒印象了)

所以該怎麼面對那一大段javascript的code呢? 這就是我這工具為何選nodejs的原因了, 抄過來就好了!!! 去除掉跟瀏覽器相關的部分, 它就是不折不扣nodejs可以跑的程式碼, 所以這也就是88.js一些怪怪程式碼的由來

除了這版本外, 我本來也有想要做一個Java的版本, 做了一半, 用Java也是可以用類似的技巧, 不過就得要導入Javascript的runtime了, 而我採用的是Mozilla Rhino

更進階一點的

做爬蟲大部分的時間都是要跟html, javascript, css這類的東西搏鬥, 但其實很多東西本來就是原本瀏覽器就會處理的, 因此如果可以直接用瀏覽器或甚至是WebKit來處理, 可以處理的事應該就會多一點, 這時候就可以利用所謂的Headless browser來簡化, 這種東西本來是用在網頁自動化測試的, 不過, 用在這應用也一樣強大, 這類的解決方案有:

  1. Phantomjs - 這是以WebKit為基礎的, 我比較常用
  2. Slimerjs - 這是以Mozilla Geco為基礎的
  3. Selenium
  4. Webloop - Go版本的

關於Phantomjs的應用, 我很久之前已經有寫過一篇了:

颱風天的宅code教學: 抓漫畫

Android上呢?

Java上, 大多是用jsoup來解析html的, 像我之前這篇:

[Android] 土製Play store API

當然應該也是可以用Webkit/WebView做成headless的解決方案, 不過這部份我目前還沒試過, 留待以後有機會再試吧

會寫這東西, 起因是上週跟同事聊到, 在social network上, 尤其是我們自己的ㄍservice, 似乎很多人不習慣或是會沒設大頭照, 如果有一個可以自動產生不同的大頭照的服務, 應該會不錯, 所以就用週末寫了這個小程式, 先給兒子試玩了一下, 他對改個東西就能產生變化還頗有興趣的, 以後再來教他寫, 哈 

Source code在此: github.com/julianshen/goticon

有了動機後, 突然想到, Github也是會給沒設大頭照的人設定一張方格圖像當預設大頭照, 所以第一個方向就是拷貝出一個這樣的功能:

這樣的東西並不難寫, 它只是一個5x5的方格, 我把名字用SHA512算出一個固定長度的binary string (5x5有25個方格, 我打算每格是1byte, 所以至少要有25 bytes), 每一byte如果大於某一數就填滿對應那格, 本來我的設定是128, 不過後來想說這樣如果有些極端的案例可能造成方格太多或太少, 因此這個數我取前25 bytes的中位數, 至於顏色, 我採用了一個叫go-colorful的lib去產生隨機的暖色, 以下就是我做出的結果:

不過這離有趣還有一些些距離, 所以又找到一個應用: https://github.com/matveyco/8biticon 

這是一個做8bits人偶的應用程式(open source, MIT License), 不過它是一個web app, 然後讓使用者自己拼湊, 這當然跟我懶人的目的不同, 我是希望只要有名字就自動產生, 所以就利用了它的圖, 自己來疊囉, 這比上面那個稍稍有趣點但更簡單, 只是依照對應的資料選出不一樣的圖疊起來而已:

我把這整個應用放到heroku去了(我原始碼也已經用godep整理好了, 有興趣的人也可以自行deploy到heroku去修改測試)”

有興趣的可以用以下的URL產生自己的identicon:

  1. Github style: https://goticon.herokuapp.com/i/g/julianshen (把julianshen取代成你自己名字就可以了)
  2. 8 bits style: https://goticon.herokuapp.com/i/8/m/julianshen (一樣換掉名字, 如果你是女生, 把”m”(male)換成”f”(female)就好, 像: https://goticon.herokuapp.com/i/8/f/julianshen