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的語法, 它不太認得呀

昨天趁等著去面試前稍微把這之前想要寫一下的這題目打包成一個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是可行的, 這等下次再來弄好了

在之前工作的時候, 做了一個專門用來產生thumbnail(縮圖)的服務, 當時這東西主要的目的是為了因應Zencircle會有不同尺寸的縮圖的需求, 而且每次client app改版又可能多新的尺寸, 因此當時寫了這個叫Minami的服務, 當時幾個簡單的需求是:

  1. 要能夠被CDN所cache (因此URL設定上不採用query string,而是簡單的URL)
  2. 能夠容易被deploy
  3. 能夠的簡單的被擴展 (加一台新的instance就可以)
  4. 不需要太多額外的dependencies

不過那時候寫的版本, 沒寫得很好, 這兩天花了點時間重寫了一個叫做Minami_t(本來Minami這名字就是來自於Minami Takahashi, 所以加個"t" XD), 新的這個重寫的版本採一樣的架構(使用了groupcache), 但多加了Peer discovery的功能(使用etcd), 但少了 臉部辨識跟色情照片偵測功能(原本在前公司的版本有, 新寫的這個我懶得加了)

我把這次重寫的版本放到github上: Minami_t

不過這算是一個sample project, 影像來源來自於Imgur, 如何使用或如何改成支援自己的Image host, 那就自行看source code吧, 這版本縮圖的部分用了我改過的VIPS, 當然原來版本的VIPS也是可用, 這版本只是我當初為了支援Face crop所改出來的

Groupcache

先來說說為什麼採用groupcache? 我不是很確定當時為何會看到groupcache這來, 但後來想想, 採用它的原因可能是看到這份投影片, 它是memchached的作者寫來用在dl.google.com上面的, 架構上剛好也適合thumbnail service, 可能剛好投影片又提到thumbnail(我腦波也太弱了吧), 所以當初採用它來實作這個service

架構上會像是這樣:

Groupcache有幾個特色

  1. Embedded, 不像memcached, redis需要額外的server, 它是嵌入在你原本的程式內的
  2. Shared, Cache是可以所有Peer共享的, 資料未必放在某特定的Peer上, 有可能在本機, 也可能在另一台, 當然如果剛好在本機時就會快一點
  3. LRU, Cache總量有上限限制的, 過久沒使用的資料有可能會被移出記憶體
  4. Immutable, key所對應的值不像memcached, redis可以修改, 而是當cache miss時, 他會再透過你實作的getter去抓真正的資料

要讓Groupcache可以在不同node間共享cache, 就必須開啟HTTPPool, 像下面

	ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
	if err != nil {
		return nil, err
	}

	if port == 0 {
		port = ln.Addr().(*net.TCPAddr).Port
	}

	_url := fmt.Sprintf("http://%s:%d", ip, port)
	pool := groupcache.NewHTTPPool(_url)

	go func() {
		log.Printf("Group cache served at port %s:%d\n", ip, port)
		if err := http.Serve(ln, http.HandlerFunc(pool.ServeHTTP)); err != nil {
			log.Printf("GROUPCACHE PORT %d, ERROR: %s\n", port, err.Error())
			os.Exit(-1)
		}
	}()

Groupcache 的getter範例:

func getter(ctx groupcache.Context, key string, dest groupcache.Sink) error {
	log.Println("Cache missed for " + key)

	params := strings.Split(key, ":")

	if len(params) != 3 {
		return ErrWrongKey
	}

	d := ctx.(*Downloader)
	fileName, err := d.Download("http://i.imgur.com/" + params[2])

	if err != nil {
		return err
	}

	//Should assume correct since it is checked at where it is from
	width, _ := strconv.Atoi(params[0])
	height, _ := strconv.Atoi(params[1])

	data, err := resize(fileName, width, height)

	if err != nil {
		return err
	}

	dest.SetBytes(data)
	return nil
}

etcd

我之前寫的版本有個問題是, 沒有自動的peer discovery的功能, 所以必須手動加peer, 這版本把etcd導入, etcd已經是coreos的核心之一了, 簡單, 又蠻好用的, 不過選它也是它直接有Go的client了

Peer discovery的部分, 參考了Go kitetcd實作, Go kit是一個蠻好的Go的微服務框架, 它裡面也有實作用etcd做service discovery, 這一部分正好是這邊需要的, 因此 參考並寫出了這邊這個版本

重點是要能夠在有新server加入後就新增到peer list去, 有server離開後要拿掉, 因此必須利用到etcd的watch功能

func (s *ServiceRegistry) Watch(watcher Watcher) {
	key := fmt.Sprintf("/%s/nodes", s.name)
	log.Println("watch " + key)
	w := s.etcd_client.Watcher(key, &etcd.WatcherOptions{AfterIndex: 0, Recursive: true})

	var retryInterval time.Duration = 1

	for {
		_, err := w.Next(s.ctx)

		if err != nil {
			log.Printf("Failed to connect to etcd. Will retry after %d sec \n", retryInterval)
			time.Sleep(retryInterval * time.Second)

			retryInterval = (retryInterval * 2) % 4096
		} else {
			if retryInterval > 1 {
				retryInterval = 1
			}

			list, err := s.GetNodes()
			if err == nil {
				watcher(list)
			} else {
				//skip first
			}
		}
	}
}

Watch可以用來監測某一個key有無改變, 因此我們只要一直監測server node的list就好(指定一個key來放), 因此流程是這樣的:

  1. Server開啟後, 自己到etcd註冊自己, 並把etcd上找到的nodes全加到peer list中
  2. 另一台由etcd發現有另一台出現後, 把它加到peer list中
  3. Server下線後, 要移除自己的註冊, 其他機器要從peer list把它移除

問題點在最後一點, Server下線有可能是被kill的, 也有可能按ctrl-c中斷的, 這時候就要監聽os的signal, 在程式被結束前, 可以先去移除註冊, 像這樣:

//Listening to exit signals for graceful leave
go func() {
	c := make(chan os.Signal, 1)
	signal.Notify(c, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)
	<-c
	log.Println("I'm leaving")
	cm.Leave()
	os.Exit(0)
}()

這只是一個sample而已, 還有一些待改進的

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

可能是之前"所謂"的大數據(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的解決方案, 不過這部份我目前還沒試過, 留待以後有機會再試吧

不知不覺的突然就多出了兩天颱風假, 這颱風實在很威, 乒乒乓乓的, 不過, 也沒做什麼, 時間就快過完了, 現在才想到, 還是來寫點什麼, 嚴格說來這些東西並不完全是颱風假時弄的, 只是拖得有點久

起因是, 之前(很久…追朔到去年)想寫個App, 需要用到中華職棒賽程的資料, 拖了很久一直沒真的去做, 斷斷續續的, 最近才先把資料這部分補齊, 首先需求是:

  1. 當月之後的賽程資料, 但中華職棒並沒有API, 只有(很爛)的網頁, 因此資料勢必得從網頁去解析
  2. 由Client app直接去解析html, 會比較麻煩(如果網站更新了, 就要更新App), 不是那麼可行
  3. 不想花錢(或不想花太多錢)弄一個server, 更不用說還要考慮Scaling

而賽程表這樣的資料的特性則是:

  1. 球季是3~10月
  2. 資料內容除週一(休賽)外, 幾乎每天都會變, 但不會一兩個小時或幾分鐘就變一次
  3. 變動的內容可能是: 4. 比賽結束, 比數有更新 5. 延賽或停賽
  4. 一個月才幾十場比賽而已, 基本上不太需要有search或query的功能, 依據月份分類也就足夠了

因此我採用的做法是:

  1. 利用AWS lambda定時解析中華職棒網站的資料
  2. 資料以json格式存在github (使用Github api)
  3. Client透過CDN去要這些json的raw content

賽程解析

這部分我是用Go + Goquery來寫的, source code在這邊: cpblschedule, 這code沒啥整理過, 光解析這堆亂七八糟的html就夠頭痛囉, 就沒啥整理

我做成了一個package, 因此要使用可用下列指令先安裝:

go get -u github.com/julianshen/cpblschedule

裡面也很簡單就一個function而已, 因此要使用可以參考:

import "github.com/julianshen/cpblschedule"

func main() {
	matches, err := cpblschedule.ParseCPBLSchedule(year, month)
    ....
}

AWS Lambda

這邊就不介紹這東西是什麼了, 網路上文章一大堆, 基本上他是AWS一個severless的解決方案(這算廣告詞吧), 會使用這個的原因有二:

  1. 依我的用量應該是免費(事實證明, 其實還是要花點錢, 我忘了算網路傳輸的費用了, 不過這不多)
  2. 可以用Cloud watch排程觸發

不過有個小問題

**他不支援Go!!!!!**

而我上面那個解析的東東是go寫的, 那不就寫心酸的

所幸還有別的辦法,就是把程式編譯成執行檔, 然後用nodejs去包裝它, 不過這有點煩瑣, 所幸還有工具

apex, 這是讓你更簡單的去建立lambda function的工具, 而且他正好也可以幫你簡單做好上面所說的包裝

安裝及使用就看文件吧, 不特別說了, 但要如何用go寫一個lambda function handle呢?以下是範例:

import (
	"encoding/json"
	"github.com/apex/go-apex"
)
func main() {
	apex.HandleFunc(func(event json.RawMessage, ctx *apex.Context) (interface{}, error) {
		dosomething()
		return nil, nil
	})
}

Github as API source

資料既然變動不頻繁, 就用lambda定期產生然後把結果放到Github上就可了

Github API的Go的實做是Google放出來的go-github, 文件還蠻眼花撩亂的, 不過在這應用需要的API不多:

  1. client.Repositories.GetContents - 取得內容
  2. client.Repositories.CreateFile - 創立一個新檔
  3. client.Repositories.UpdateFile - 更新某個檔

之所以需要1的原因是要確認檔案是不是已經在repository裡面了, 如果沒有就用create, 如果有就拿SHA hash去更新內容

GetContents會把檔案內容一併給抓回來, 這可以用來在更新檔案前先比較, 如果不比較, 就算沒更動, API也會新增一個新的commit, 為了避免不要太誇張, 還是先比較一下好了

那之後client怎樣存取這些資料? 找到檔案, 選取raw就可以知道raw的url了, client每次就抓這個URL就好, 但為了避免過量地request湧到github, 因此透過一個CDN來存取可能會好一點

這時候就可以用RawGit, 這邊透過MaxCDN, 讓你可以去存取Github上的raw content, 而你的檔案的網址會是像這樣:

https://cdn.rawgit.com/user/repo/tag/file

大致上就這樣