1. 程式人生 > 實用技巧 >go網站爬蟲

go網站爬蟲

公司有需求爬取全國所有城市的樓盤,解析出經緯度,這種大範圍的爬取一般用scrapy框架比較合適,但是剛好最近學完了go語言,go語言的http庫用起來也很方便,因此嘗試用go語言完成

一,程式支援根據使用者輸入城市進行爬取,使用flag方法可以解析使用者資料,定義好要爬取的城市、爬取翻頁時等待時長(慢速爬取更友好)和是否列印爬取結果

func init() {
	flag.StringVar(&p, "p", "湖北", "省份")
	flag.StringVar(&c, "c", "武漢", "城市")
	flag.IntVar(&t, "t", 5, "翻頁間隔")
	flag.BoolVar(&r, "r", true, "列印記錄")
	flag.Parse()
}

二,判斷使用者輸入的省份城市是否合法(是否在該網站中存在記錄),在進行元素定位時用到了htmlquery庫,採用xpath語法定位

func CheckCityExist() bool {
	rsp, err := engine.GetRsp("https://www.ke.com/city/")
	if err != nil {
		log.Fatalln("請求查詢城市失敗: ", err)
	}
	defer rsp.Body.Close()
	doc, _ := htmlquery.Parse(rsp.Body)
	retLis := htmlquery.Find(doc, `//div[@class="city_province"]`)
	for _, i := range retLis {
		pr := strings.TrimSpace(htmlquery.InnerText(htmlquery.FindOne(i, `./div[@class="city_list_tit c_b"]`)))
		if p == pr {
			ct := htmlquery.FindOne(i, fmt.Sprintf(`./ul/li[@class="CLICKDATA"]/a[text()="%s"]`, c))
			if ct != nil {
				ctURL = htmlquery.SelectAttr(ct, "href")
				if ctURL != "" {
					ctURL = "https:" + ctURL
					return true
				}
			}
		}
	}
	return false
}

 

三,每個城市會存在樓盤(新房)和小區(舊房)兩個爬取源,GetCityURL方法找到樓盤和小區的根路徑,並將其存入結構體pp中

func GetCityURL(u string) {
	rsp, err := engine.GetRsp(u)
	if err != nil {
		log.Fatalln("請求查詢城市失敗: ", err)
	}
	defer rsp.Body.Close()
	doc, _ := htmlquery.Parse(rsp.Body)
	louPan := htmlquery.FindOne(doc, `//li[@class="CLICKDATA"]/a[text()="新房"]`) // 新房
	xiaoQu := htmlquery.FindOne(doc, `//li[@class="CLICKDATA"]/a[text()="小區"]`) // 舊房
	lp := htmlquery.SelectAttr(louPan, "href")
	if lp == "" {
		louPan2 := htmlquery.FindOne(doc, `//li[@class="new-link-list"]/a[text()="新房"]`) // 新房
		lp = htmlquery.SelectAttr(louPan2, "href")
		lp = strings.TrimRight(lp, "/")
	}
	if !strings.HasPrefix(lp, "http") {
		lp = "https:" + lp
	}
	pp.Loupan = lp
	pp.Xiaoqu = htmlquery.SelectAttr(xiaoQu, "href")
	pp.Province = p
	pp.City = c
}

四,爬取樓盤,解析欄位,該網站請求樓盤返回的資料是json格式,每次返回10條記錄,解析很方便,注意該網站在爬到一定頁數後,每次會返回200條記錄,並且都是重複資料,因此程式對此作了判斷並結束迴圈

func ProcessLoupanData(data *utils.JData, pp *utils.CityMeta, r bool) {
	// 提取出資料庫需要的欄位
	for _, item := range data.Data.List {
		item.CityName = pp.City
		item.Province = pp.Province
		db.InsertRow(&item, true)
		if !r {
			continue
		}
		fmt.Println("封面", item.CoverPic)
		fmt.Println("省份", item.Province)
		fmt.Println("城市", item.CityName)
		fmt.Println("區域", item.District)
		fmt.Println("地址", item.Address)
		fmt.Println("商圈", item.CircleName)
		fmt.Println("小區名", item.Loupan)
		fmt.Println("均價", item.AvePrice)
		fmt.Println("價格單位", item.PriceUnit)
		fmt.Println("銷售狀態", item.Status)
		fmt.Println("面積", item.Area)
		fmt.Println("房型", item.RoomType)
		fmt.Println("經度", item.LNG)
		fmt.Println("緯度", item.LAT)
		fmt.Println(strings.Repeat("#", 50))
	}
}

func ParseCityLoupan(r bool, t int, pp *utils.CityMeta) {
	// 爬取樓盤資訊
	if pp.Loupan == "" {
		log.Printf("%s-%s 無新房記錄", pp.Province, pp.City)
		return
	}
	url := pp.Loupan + "/pg1/?_t=1"

	b, err := engine.Download(url)
	if err != nil {
		log.Printf("首頁新房請求失敗", err)
		return
	}
	var data utils.JData
	err = json.Unmarshal(*b, &data)
	if err != nil {
		log.Printf("解析新房第1頁失敗:%v\n", err)
		return
	}
	total, err := strconv.Atoi(data.Data.Total)
	if err != nil {
		log.Fatalln("無法獲取新房總頁數", data.Data.Total, err)
	}
	ProcessLoupanData(&data, pp, r)
	pages := int(math.Ceil(float64(total)/ 10)) + 1
	count := 0
	for page := 2; page < pages; page++ {
		var data utils.JData
		time.Sleep(time.Second * time.Duration(t))
		url := fmt.Sprintf("%s/pg%d/?_t=1", pp.Loupan, page)
		b, err := engine.Download(url)
		if err != nil {
			log.Printf("請求第%d頁失敗\n", page)
			continue
		}
		err = json.Unmarshal(*b, &data)
		if err != nil {
			log.Printf("解析第%d頁失敗\n", page)
			continue
		}
		if len(data.Data.List) > 10 {
			count ++
		}
		if count == 2 {
			fmt.Println("結束重複值")
			break
		}
		ProcessLoupanData(&data, pp, r)
	}
}

 

五,爬取小區,解析欄位,該網站請求小區返回的是html格式內容,需要用htmlquery庫xpath,方便解析欄位

func ParsePageRegionXiaoqu(r bool, url string, areaName string, p string, c string) {
	rsp, err := engine.GetRsp(url)
	if err != nil {
		log.Printf("爬取連結%s失敗\n", url)
		return
	}
	defer rsp.Body.Close()
	doc, _ := htmlquery.Parse(rsp.Body)
	items := htmlquery.Find(doc, `//li[contains(@class, "xiaoquListItem")]`)
	for _, item := range items {
		var data utils.MData
		titleNode := htmlquery.FindOne(item, `.//a[@class="maidian-detail"]`)
		title := htmlquery.SelectAttr(titleNode, "title")
		if title == "" {
			continue
		}
		circleNode := htmlquery.FindOne(item, `//a[@class="bizcircle"]`)
		circle := strings.TrimSpace(htmlquery.InnerText(circleNode))
		if circle == "" {
			continue
		}
		lng, lat := engine.BaiduAPI(c, areaName+" "+title)
		if lng == 0 || lat == 0 {
			continue
		}
		data.Province = p
		data.CityName = c
		data.Loupan = title
		data.District = areaName
		data.CircleName = circle
		data.Address = circle + title
		data.LNG = strconv.FormatFloat(lng, 'f', 6, 64)
		data.LAT = strconv.FormatFloat(lat, 'f', 6, 64)
		if r {
			fmt.Println("封面", data.CoverPic)
			fmt.Println("省份", data.Province)
			fmt.Println("城市", data.CityName)
			fmt.Println("區域", data.District)
			fmt.Println("地址", data.Address)
			fmt.Println("商圈", data.CircleName)
			fmt.Println("小區名", data.Loupan)
			fmt.Println("均價", data.AvePrice)
			fmt.Println("價格單位", data.PriceUnit)
			fmt.Println("銷售狀態", data.Status)
			fmt.Println("面積", data.Area)
			fmt.Println("房型", data.RoomType)
			fmt.Println("經度", data.LNG)
			fmt.Println("緯度", data.LAT)
			fmt.Println(strings.Repeat("#", 50))
		}
		db.InsertRow(&data, false)
	}
}

func ParseRegionXiaoqu(r bool, t int, baseURL string, areaName string, p string, c string) {
	page := 1
	url := baseURL + strconv.Itoa(page)
	rsp, err := engine.GetRsp(url)
	if err != nil {
		log.Printf("爬取連結%s失敗\n", url)
		return
	}
	defer rsp.Body.Close()
	doc, _ := htmlquery.Parse(rsp.Body)
	totalNode := htmlquery.FindOne(doc, `//h2[contains(@class, "total")]/span`)
	total, err := strconv.Atoi(strings.TrimSpace(htmlquery.InnerText(totalNode)))
	if err != nil {
		log.Printf("非有效數字", total)
		return
	}
	pages := int(math.Ceil(float64(total) / 30))
	for page:=1;page<=pages;page++{
		time.Sleep(time.Second * time.Duration(t))
		url := baseURL + strconv.Itoa(page)
		ParsePageRegionXiaoqu(r, url, areaName, p, c)
	}
}

// 爬取舊房資訊
func ParseCityXiaoqu(r bool, t int, pp *utils.CityMeta) {
	if !strings.HasSuffix(pp.Xiaoqu, "xiaoqu/") {
		log.Printf("%s-%s 無小區記錄", pp.Province, pp.City)
		return
	}

	url := pp.Xiaoqu + "pg1/"
	b, err := engine.Download(url)
	if err != nil {
		log.Printf("首頁新房請求失敗:%v", err)
		return
	}
	pt, _ := regexp.Compile(`href="/xiaoqu/([a-z0-9]{2,20}/pg)1".*?>(.*?)</a>`)
	retLis := pt.FindAllStringSubmatch(string(*b), -1)
	mp := make(map[string]string) // 去重後的區域url
	for _, v := range retLis {
		mp[pp.Xiaoqu+v[1]] = v[2]
	}
	for aURL, aName := range mp {
		fmt.Println("列印當前", aURL, aName)
		ParseRegionXiaoqu(r, t, aURL, aName, pp.Province, pp.City)
	}
}

  

六,無論是爬取樓盤還是小區都有可能返回資料中沒有經緯度,這裡需要呼叫百度api獲取地址對應的經緯度(需要申請百度開發者賬號)

func BaiduAPI(city string, addr string) (lng, lat float64) {
	// 呼叫百度api,獲取地理位置
	if useIdx == len(utils.AK) {
		log.Fatalln("無可用ak")
	}
	params := url.Values{}
	params.Set("address", addr)
	params.Set("output", "json")
	params.Set("city", city)
	params.Set("ak", utils.AK[useIdx])
	toURL := apiURL + "?" + params.Encode()
	req, err = http.NewRequest("GET", toURL, nil)
	if err != nil {
		return 0, 0
	}
	req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.135 Safari/537.36")
	rsp, err = client.Do(req)
	if err != nil {
		return 0, 0
	}
	defer rsp.Body.Close()
	b, _ := ioutil.ReadAll(rsp.Body)
	var ret apiRet
	err = json.Unmarshal(b, &ret)
	if err != nil {
		return 0, 0
	}
	if ret.Msg == "天配額超限,限制訪問" {
		log.Println(utils.AK[useIdx], ret.Msg)
		useIdx += 1
		return BaiduAPI(city, addr)
	}
	return ret.Result.Location.LNG, ret.Result.Location.LAT
}

  

七,將資料存入mysql,使用經緯度md5值去重

func generateID(data *utils.MData) string {
	md := md5.New()
	md.Write([]byte(strings.Join([]string{data.LNG, data.LAT}, "")))
	ret := md.Sum([]byte(""))
	return fmt.Sprintf("%x", ret)
}

// InsertRow ...
func InsertRow(data *utils.MData, checkGeo bool) {
	// 若經緯度任意為空,則根據地址,呼叫百度api獲取經緯度
	if data.LNG == "" || data.LAT == "" {
		if !checkGeo {
			return
		}
		if data.CityName == "" || data.Address == "" || data.Loupan == "" {
			return
		}
		lng, lat := engine.BaiduAPI(data.CityName, fmt.Sprintf("%s %s", data.Address, data.Loupan))
		if lng == 0 || lat == 0 {
			return
		}
		data.LNG = strconv.FormatFloat(lng, 'f', 6, 64)
		data.LAT = strconv.FormatFloat(lat, 'f', 6, 64)
	}
	iid = generateID(data)
	if _, ok := mp[iid]; ok {
		// 過濾重複座標
		return
	}
	mp[iid] = ""
	sqlStr := "insert into lbs(id, province, city, region, avenue, community_name, community_addr, longitude, dimension, status) values (?,?,?,?,?,?,?,?,?,?)"
	_, err := sdb.Exec(sqlStr, iid, data.Province, data.CityName, data.District, data.CircleName, data.Loupan, data.Address, data.LNG, data.LAT, 0)
	if err != nil {
		fmt.Printf("insert failed, err:%v\n", err)
		return
	}
}

編譯後執行後效果如下:

完整程式碼參考github專案地址:https://github.com/Tarantiner/golang_beike