使用 Go 實作「URL 短網址爬蟲」應用程式

前言

本文使用網路上的短網址服務來實作一個並行的爬蟲。

此服務的短網址有兩個特性:

  1. 有效期限

由非會員所生成的短網址只有 14 天的有效期限。

這個特性導致了一種可能性,比方說 /a 短網址所儲存的圖片為一隻貓,過了 14 天後,由於 a 這個代碼被釋放了,下一個使用者上傳了一隻狗的圖片,剛好又被分配到了 a 這個代碼,於是 /a 這個短網址所儲存的圖片就變成了狗。

使用者應該注意這個特性,在必要時註冊會員,以避免短網址很快被取代。

  1. 編碼

生成的短網址由其網域名稱和一個 Base52 的編碼所組成。所謂 Base52 是由 ASCII 字元 a-z 和 A-Z 所組成的表示方法。

由於其有效期限的特性,導致這個服務在使用量不大的情況下,所生成的短網址代碼最多就只有 3 碼。

這個特性也導致了一種可能性,由於代碼是 3 位 Base52 的字元,所以其所有組合為 52 的 3 次方,即 140,608 種。一般人可以隨意輸入代碼就存取到其他短網址。

當然,這個服務有提供密碼功能,使用者應該善用密碼。

實作

首先建立一個 Letters() 函式,在 Base52 的情況下,這個函式會生成一個元素由 a 到 Z 所組成的陣列,這個陣列總共會有 52 個元素。

1
2
3
4
5
6
7
8
9
10
// Letters generates different ASCII characters.
func Letters(base int) []string {
letters := make([]string, base)

for i := 0; i < base/2; i++ {
letters[i], letters[i+base/2] = string('a'+i), string('A'+i)
}

return letters
}

再建立一個 Code() 函式,實現進位系統。在 Base52 的情況下,可以用數字來取得字元。比如輸入 1 可以得到 a、輸入 2 可以得到 b,如果輸入 53 則可以得到 aa,以此類推。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

// Code returns the letter according to the given number.
func Code(num int, base int) string {
code := ""

letters := Letters(base)

for num > 0 {
num--
code = letters[num%base] + code
num /= base
}

return code
}

建立一個 generateCodes() 函式,建立一個元素由所有組合的代碼所組成的陣列,並且將元素的順序打亂。在 Base52 的情況下,這個函式會輸出包括從 a 到 ZZZ 所有元素的陣列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func generateCodes(nums int) []string {
codes := make([]string, nums)

for i := 0; i < nums; i++ {
codes[i] = helper.Code(i, base)
}

// 將陣列中的元素打亂
rand.Seed(time.Now().UnixNano())
rand.Shuffle(len(codes), func(i, j int) {
codes[i], codes[j] = codes[j], codes[i]
})

return codes
}

建立主程式,以發送請求並下載圖片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// Handle func
func Handle() {
codes := generateCodes(amount)
codeChan := make(chan string)
imageChan := make(chan Image)

go func() {
// 不斷疊代
for {
// 不斷將 a 到 ZZZ 的代碼放進 codeChan 通道中
for _, code := range codes {
codeChan <- code
}
}
}()

// 限制並行數
for i := 0; i < concurrency; i++ {
go func() {
// 將代碼從 codeChan 通道中取出
for code := range codeChan {
// 取得圖片網址
image := fetchImage(code)

go func() {
defer helper.Measure(time.Now(), "fetch")

// 將圖片網址放進 imageChan 通道中
imageChan <- image
}()

// 稍微休息
time.Sleep(time.Duration(86400*concurrency/amount) * time.Second)
}
}()
}

// 將圖片網址從 imageChan 通道中取出
for image := range imageChan {
if len(image.FileInfos) > 0 {
// 下載圖片
image.download()
}
}
}

宣告一些會頻繁使用到的常數:

1
2
3
4
5
6
const (
baseURL string = "https://risu.io/" // 請求網址
base int = 52 // 要使用的 ASCII 字元數量
amount int = base * base * base // 要產生的代碼數量
concurrency int = 10 // 並行數
)

宣告取得的圖片結構體:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Image struct
type Image struct {
Code string
FileInfos []FileInfo `json:"file_infos"`
}

// FileInfo struct
type FileInfo struct {
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ByteSize string `json:"byte_size"`
FilePath string `json:"file_path"`
CreatedAt string `json:"created_at"`
}

建立一個 setCode() 方法,用來設置圖片結構體的代碼:

1
2
3
func (image *Image) setCode(code string) {
image.Code = code
}

建立一個 download() 方法,用來下載圖片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func (image *Image) download() error {
defer helper.Measure(time.Now(), "download")

// 解析時間
date, err := time.Parse("2006-01-02 15:04:05", image.FileInfos[0].CreatedAt)

if err != nil {
log.Panicln(err)
}

// 重新命名
name := fmt.Sprintf("storage/%s_%s.jpg", date.Format("20060102150405"), image.Code)
url := image.FileInfos[0].FilePath

return storeImage(name, url)
}

建立一個 storeImage() 函式,用來發送請求,並將圖片儲存到本地。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func storeImage(path string, url string) error {
resp, err := http.Get(url)

if err != nil {
return err
}

defer resp.Body.Close()

file, err := os.Create(path)

if err != nil {
return err
}

defer file.Close()

_, err = io.Copy(file, resp.Body)

return err
}

建立一個 fetchImage() 函式,用來發送請求取得圖片資訊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func fetchImage(code string) Image {
var image Image

client := &http.Client{
Timeout: time.Duration(10 * time.Second),
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: true,
},
},
}

req, err := http.NewRequest("GET", baseURL+code, nil)

if err != nil {
return image
}

resp, err := client.Do(req)

if err != nil {
return image
}

defer resp.Body.Close()

doc, err := html.Parse(resp.Body)

if err != nil {
return image
}

node := getNode(doc)

if err = json.Unmarshal([]byte(node), &image); err != nil {
return image
}

image.setCode(code)

return image
}

建立一個 getNode() 方法,用來解析 HTML,並取得圖片資訊。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
func getNode(n *html.Node) string {
node := ""

var f func(*html.Node)

f = func(n *html.Node) {
if n.Type == html.ElementNode && n.Data == "page-image" {
for _, a := range n.Attr {
node = a.Val
}
}

for c := n.FirstChild; c != nil; c = c.NextSibling {
f(c)
}
}

f(n)

return node
}

結論

這個短網址服務有一些地方可以改善:

  1. 代碼數量

使用極短的代碼來實現短網址,容易遭人任意存取,產生意想不到的風險。因此在做短網址服務時,可以考慮使用 Base64,或者至少要有 5 至 6 位的代碼。

  1. 警告標語

為了保護使用者,應該明確提醒使用者應該加上密碼,以避免一些含有個人資料的圖片遭他人存取。

程式碼