使用 Go 撰寫 Plurk 噗浪偷偷說網路爬蟲

作者: Yong-Siang Shih / Fri 11 January 2019 / 分類: Notes

crawler, go, Plurk

前言

記得剛開始學習 Go 好像是在 2013 年的時候,不過後來就一直沒太多機會使用。最近剛好想寫個爬蟲,於是就決定用 Go 來練習看看。

這次的目標是 Plurk 噗浪上的「偷偷說河道」

本次的程式放在 shaform/experiments/plurk_crawler

Coweb

環境設定

這次只有用到 schollz/progressbar 來顯示進度,其他都是使用 Go 內建的函式庫,故只要安裝一個套件:

go get -u github.com/schollz/progressbar

撰寫爬蟲

分析網頁

首先透過觀察網路流量的方式分析噗浪的偷偷說到底是讀取什麼資料來顯示的,最後就會發現,主要透過兩組 endpoints。

第一個可以得到偷偷說河道的所有噗:

GET /Stats/getAnonymousPlurks?lang=<language>

第二個則可以用來讀取一個噗的回應:

POST /Responses/get2?plurk_id=<plurk_id>&from_response=<start_num>

得知兩組 endpoints 之後就能開始撰寫了。

主程式

主程式架構如下所示,首先利用 flag 來宣告參數,主要可以控制語言、存檔資料夾,以及要間隔多久送一次請求,防止被伺服器封鎖。

再來,則是實際呼叫第一個 endpoint 取得偷偷說河道。

最後則是解析河道取得所有的噗,再根據每個噗擷取回應並存檔。

func main() {
	const startUrl = "https://www.plurk.com/Stats/getAnonymousPlurks?lang=%s"

	// parse args
	var lang = flag.String("lang", "zh", "language of Plurks")
	var outputDir = flag.String("output-dir", "output", "directory for output")
	var file = flag.String("file", "", "read file instead of query URL")
	var delay = flag.Int("delay", 1000, "delay between each request in milliseconds")
	flag.Parse()

	// get plurks
	var result map[string]interface{}
	if *file != "" {
		result = GetPlurksFromFile(*file)
	} else {
		uri := fmt.Sprintf(startUrl, *lang)
		result = GetPlurks(uri)
	}

	plurks := ProcessPlurks(result)

	// start storing content...
	os.MkdirAll(*outputDir, 0700)
	FetchAndSavePlurks(*outputDir, plurks, *delay)
}

讀取噗浪河道

使用 HTTP GET 發送請求後,河道的 endpoint 會回傳一組 json 資料,所以我們用 json.Unmarshal 將其解譯成物件。因為 Go 是 static typing,所以不像 Python 那麼方便會根據 json 自動變成各種正確的物件。所以是用 interface{} 來暫時代表任意 type 的物件。

func ParseJson(inputs []byte) map[string]interface{} {
	var result map[string]interface{}
	json.Unmarshal(inputs, &result)

	return result
}

// GetPlurksFromFile queris uri
// to obtain plurk timeline.
func GetPlurks(uri string) map[string]interface{} {
	client := http.Client{Timeout: time.Second * 15}
	resp, err := client.Get(uri)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()

	content, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}

	return ParseJson(content)
}

解析河道內容

這裡我們只拿出幾個我們關心的欄位,包含 id、內容、張貼時間、以及有幾個回應等等。

type Plurk struct {
	PlurkId       int
	Content       string
	Posted        string
	ResponseCount int
	Responses     []PlurkResponse
}

type PlurkResponse struct {
	Id      int
	Handle  string
	Content string
	Posted  string
}

// ProcessPlurks parses the timeline,
// and produces a list of plurks.
func ProcessPlurks(result map[string]interface{}) []Plurk {
	var plurks []Plurk
	for _, value := range result {
		// Each value is an interface{} type, that is type asserted as a string
		switch obj := value.(type) {
		case map[string]interface{}:
			plurk := Plurk{
				PlurkId:       int(obj["plurk_id"].(float64)),
				Content:       obj["content"].(string),
				Posted:        obj["posted"].(string),
				ResponseCount: int(obj["response_count"].(float64))}
			plurks = append(plurks, plurk)
		default:
		}
	}

	return plurks
}

抓取回應

由於之前已經知道每則噗浪有幾則回應,這裡只要針對有回應的噗浪抓取就行。如果遇到錯誤,可能是該噗浪已經被刪除,所以就把該噗一併捨去。最後再存檔。

// FetchAndSavePlurks queries plurk.com to
// obtain responsese from each plurk and
// save them to disk
func FetchAndSavePlurks(outputDir string, plurks []Plurk, delay int) {
	client := http.Client{Timeout: time.Second * 15}
	bar := progressbar.New(len(plurks))
	for _, plurk := range plurks {
		var responses []PlurkResponse
		var err error
		if plurk.ResponseCount > 0 {
			responses, err = FetchResponses(&client, plurk.PlurkId)
		}
		if err != nil {
			log.Printf("[WARN] Responses from %d cannot be fetched\n", plurk.PlurkId)
		} else {
			plurk.Responses = responses
			plurkJson, _ := json.Marshal(plurk)
			ioutil.WriteFile(fmt.Sprintf("%s/%d.json", outputDir, plurk.PlurkId), plurkJson, 0644)
		}
		bar.Add(1)
		if delay > 0 {
			time.Sleep(time.Millisecond * time.Duration(delay))
		}
	}
}

至於實際的抓取程式則設計成如下。感覺用 Go 寫起來比 Python 嚴謹不少,在用 Python 時,反正錯誤就習慣讓他丟 Exception 壞掉就算了。可是寫 Go 就強迫要想想錯誤時到底要幹麻。

// FetchResponses fetches responses of a given plurk
func FetchResponses(client *http.Client, plurkId int) ([]PlurkResponse, error) {
	const fetchUrl = "https://www.plurk.com/Responses/get2"

	data := url.Values{}
	data.Set("plurk_id", strconv.Itoa(plurkId))
	data.Set("from_response", "0")
	req, _ := http.NewRequest("POST", fetchUrl, strings.NewReader(data.Encode()))
	req.Header.Set("X-Requested-With", "XMLHttpRequest")
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")

	// fetch responses
	resp, err := client.Do(req)
	if err != nil || resp.StatusCode != 200 {
		return []PlurkResponse{}, errors.New("connot load")
	}
	defer resp.Body.Close()

	content, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return []PlurkResponse{}, errors.New("connot load")
	}

	result := ParseJson(content)
	var responses []PlurkResponse
	for _, value := range result["responses"].([]interface{}) {
		switch obj := value.(type) {
		case map[string]interface{}:
			response := PlurkResponse{
				Id:      int(obj["id"].(float64)),
				Handle:  obj["handle"].(string),
				Content: obj["content"].(string),
				Posted:  obj["posted"].(string),
			}
			responses = append(responses, response)
		default:
		}
	}
	return responses, nil
}

如此一來就完成了。

實際抓取

實際抓的時候感覺像這樣:

go run crawl.go
15% |██████                                  |  [13s:1m11s]

抓完則會像這樣:

.                      
├── crawl.go           
└── output             
    ├── 1398664726.json
    ├── ...

接著或許就能進行噗浪輿情分析之類等等的運用。

結語

感覺如果要快速的寫一些 scripts 還是 Python 比較好用,但 Go 寫起來已經算是很快了。本次的程式放在 shaform/experiments/plurk_crawler

Yong-Siang Shih

作者

Yong-Siang Shih

軟體工程師,機器學習科學家,開放原始碼愛好者。曾在 Appier 從事機器學習系統開發,也曾在 Google, IBM, Microsoft 擔任軟體實習生。喜好探索學習新科技。* 在 GitHub 上追蹤我

載入 Disqus 評論