Go言語入門メモ② スクレイピングを試す

Goに入門して1ヶ月、研修の最終課題は提出できたが、覚えるならなんか作りながらだよな〜と思ったので、しばらくサボってた陰陽師中国版公式サイトの壁紙ダウンロードをGoでやってみることにした。

Goバージョンは 1.19

go-colly

超高速でエレガントなスクレイピングフレームワーク go-colly、使ってみたらめっちゃ簡単にスクレイピングできて使い心地がよかった。

go get github.com/gocolly/colly

1. 画像のURLを全て出力してみる

コントローラー作る。UserAgentをChromeのに設定した。

c := colly.NewCollector(
		colly.UserAgent("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36"), // PC Chrome
	)

壁紙を内包する div.item 要素に対してイベントハンドラを設定する。
子要素に div.mask があり、その中にaタグで各解像度の画像が設定されている。
セレクタがそのまま使えるの便利。

c.OnHTML("div.item", func(e *colly.HTMLElement) {
  e.ForEach(".mask > a", func(index int, el *colly.HTMLElement) {
     href := el.Attr("href")
     fmt.Println(href)
  })
})

Visitで訪問するURLを設定すればスクレイピングが実行される。

c.Visit("https://yys.163.com/media/picture.html")

2. 画像URLを保存用に変換する

画像を保存するんだけど、元の画像のURLを利用して、年月/解像度/年月-番号.jpg という命名規則で変換する。これは URLをスラッシュで分割→並び替えて結合 でやっつける。

文字列分割は strings.Split 。大体の言語で名称が同じだからわかりやすいですな。

arr := strings.Split(href, "/")

画像のURLを分割すると11〜12個の配列になる。以下は11個の例。

https://yys.res.netease.com/pc/zt/20170731172708/data/picture/20230118/1/1366x768.jpg

length: 11

0:https:
1:
2:yys.res.netease.com
3:pc
4:zt
5:20170731172708
6:data
7:picture
8:20230118
9:1
10:1366x768.jpg

クエリに対してもSplitで取り除いて、必要な部分を結合する。
文字列結合は + でできるけど、fmt.Sprintf の方がスッキリしていいと思った。
しかし可読性はJavaScriptのテンプレートリテラルに負けるかな。

sizeAndExt := strings.Split(arr[10], ".")
imageSize := sizeAndExt[0]
imageExt := strings.Split(sizeAndExt[1], "?")[0] // remove query
if ext != "" {
  imageExt = ext
}
fileName := fmt.Sprintf("%s-%s.%s", arr[8], arr[9], imageExt)
dirName := fmt.Sprintf("%s/%s/%s/", getDownloadDir(), arr[8], imageSize)
imagePath := fmt.Sprintf("%s%s", dirName, fileName)

3. コントローラーを複製して画像にアクセス

作成済みのコントローラーはCloneで複製できるので、画像用のコントローラーを作っておく。

c2 := c.Clone()

このコントローラーはレスポンスにイベントハンドラを設定しておく。

c2.OnResponse(func(r *colly.Response) {
   if strings.Contains(r.Headers.Get("Content-Type"), "image") {
      // 画像だったらなんかする
   }
})

ループ処理しているところで複製したコントローラーにVisitさせたら画像へのアクセスができるようになる。

c.OnHTML("div.item", func(e *colly.HTMLElement) {
  e.ForEach(".mask > a", func(index int, el *colly.HTMLElement) {
     href := el.Attr("href")
     c2.Visit(e.Request.AbsoluteURL(href))
  })
})

(これそのまま実行すると迷惑なので注意)

4. 画像を保存する

レスポンスイベントハンドラに渡される colly.Response に Save メソッドがあり、これに保存先のパスを渡すだけで画像がダウンロードできる。神すぎんか?

c2.OnResponse(func(r *colly.Response) {
   if strings.Contains(r.Headers.Get("Content-Type"), "image") {
      // 画像だったら保存する
   r.Save(imagePath)
   }
})

リクエストURLを取得できるが、ドメインの部分が省略される。

href := r.Request.URL.RequestURI()
/pc/zt/20170731172708/data/picture/20230118/1/1366x768.jpg

0:
1:pc
2:zt
3:20170731172708
4:data
5:picture
6:20230118
7:1
8:1366x768.jpg

aタグのhrefとリクエストURLで結果が異なるので、スラッシュで分割して画像URLを作るところを4パターン用意した。

5.ファイルの複製

デスクトップ壁紙には専用ディレクトリを指定しているので、指定した解像度の画像を1つのディレクトリにまとめることにした。同じPC内なら直接コピーしちゃっていいと思う。

Stackoverflow – How to read/write from/to file?

os.Open でコピー元の中身を読み込み、os.Createでコピー先のファイルを作って、io.Copy で 中身を移す。

func CopyFile(in io.Reader, dst string) (err error) {

	// Does file already exist? Skip
	if _, err := os.Stat(dst); err == nil {
		return nil
	}

	err = nil

	out, err := os.Create(dst)
	if err != nil {
		fmt.Println("Error creating file", err)
		return
	}

	defer func() {
		cerr := out.Close()
		if err == nil {
			err = cerr
		}
	}()

	if _, err = io.Copy(out, in); err != nil {
		fmt.Println("io.Copy error")
		return
	}
	return
}

ダウンロードディレクトリを再起処理して画像を複製する。

resolutionInput := "1920x1080"

filepath.WalkDir(getDownloadDir(), func(path string, d fs.DirEntry, err error) error {
		if err != nil {
		 fmt.Println("failed filepath.WalkDir")
          return
		}

		if d.IsDir() {
			return nil
		}

		if strings.Contains(path, ".DS_Store") {
			return nil
		}

		if strings.Contains(path, resolutionInput) {

			newPath := convertToDistPath(path, resolutionInput)

			if reader, err := os.Open(path); err == nil {
				defer reader.Close()

				if err != nil {
					fmt.Println("os.Open error")
                      return
				}

				// Directory exists and is writable
				err = CopyFile(reader, newPath)

				if err != nil {
					fmt.Println("CopyFile error")
                      return
				}
				fmt.Println(path + " -> " + newPath)

			} else {
				fmt.Println("Impossible to open the file")
                 return
			}

		}

		return err
	})

6. 遅延と並行処理

これは公式にサンプルがあるのでその通りやってみただけ。
Asyncしない場合、前のダウンロードが終わったら次、というような同期処理になる。
画像ダウンロードの場合だと、タイムアウトまでに終わらないことがあったので設定を伸ばした。

c := colly.NewCollector(
		colly.Async(true),
		colly.MaxDepth(2),
		colly.UserAgent("..."),
	)
c.SetRequestTimeout(120 * time.Second) // タイムアウトを伸ばす
c.Limit(&colly.LimitRule{DomainGlob: "*", Parallelism: 2})

c2 := c.Clone()

// 処理が終わるのを待たせる
c.Wait()
c2.Wait()

コマンドによる処理の分岐

ダウンロードするやつと1つにまとめるやつ、2つ処理ができたのでコマンドプロンプトで実行できるようにしてみた。

	scanner := bufio.NewScanner(os.Stdin)
	fmt.Print("何をしますか?(1.Download, 2.Package):")
	scanner.Scan()
	inputTxt := scanner.Text()
	inputInt, _ := strconv.Atoi(inputTxt)

	switch inputInt {
	case 0:
		Downloader()
	case 1:
		Packager()
	default:
		fmt.Println("コマンドが正しくありません")
	}

bufio を使うとこうなるが、 promptui モジュールを使うと次のようにできた。

	prompt := promptui.Select{
		Label: "何をしますか?",
		Items: []string{"Download", "Package"},
	}

	promptIndex, _, err := prompt.Run()

	if err != nil {
		fmt.Printf("コマンドが正しくありません: %v\n", err)
		return
	}

お手軽で大変よろしいですな☺️

解像度選択するところも選択式にすれば、入力内容を確認する手間が減る。

var resolutions = []string{
	"640x960",
	"640x1136",
	"720x1280",
	"750x1334",
	"1080x1920",
	"1125x2436",
	"1366x768",
	"1440x900",
	"1920x1080",
	"2048x1536",
	"2208x1242",
	"2436x1125",
	"2732x2048",
}	

prompt := promptui.Select{
  // 選択肢のタイトル
  Label: "解像度を選択してください",
  // 選択肢の配列
  Items: resolutions,
}

_, resolutionInput, err := prompt.Run()

使った処理

共通関数にしたものなど。

ファイル・ディレクトリの存在確認

StackOverFlowの解答より。

ファイルやディレクトリのステータスは os.Statで取れる。
エラーがなければ存在している。
エラーが出た場合、それが os.ErrNotExist であれば存在してないということになる。
それ以外の場合のエラーも error.Is を使えばチェックできる。

// ファイル・ディレクトリの存在確認
func exists(path string) bool {

	if _, err := os.Stat(path); err == nil {
		// path exists
		return true

	} else if errors.Is(err, os.ErrNotExist) {
		// path does *not* exist
		return false

	} else {
		// Schrodinger: file may or may not exist. See err for details.

		// Therefore, do *NOT* use !os.IsNotExist(err) to test for file existence

		panic(err)
	}

}

ディレクトリ作成

os.Mkdiros.MkdirAll で作れる。子も全部作るなら MkdirAll で。
存在確認は前の関数でできるので、なかったら作るようにした。

// ディレクトリ確認(なかったら作る)
func checkDir(path string) {
	// なかったら作る
	if !exists(path) {
		os.MkdirAll(path, 0777)
	}
}

ルートディレクトリパスの取得

ダウンロードディレクトリをルートディレクトリ直下に作りたかった。
os.Getwd でパスが取れる。

// ダウンロードディレクトリを返す
func getDownloadDir() string {
	path, err := os.Getwd()

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

	downloadDir := fmt.Sprintf("%s/%s", path, downloadDirName)

	return downloadDir
}

該当する名前のファイルを全て削除する

ファイル名生成ミスった状態の画像が作られてしまったときに使った。
filepath.WalkDir で再起処理ができる。ファイルの削除は os.Remove で。
聞くところによるとReadDirでforループするよりWorkDirのが軽いらしい。

func DeleteFiles(dirPath string) {
	filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
		if !d.IsDir() {

			if strings.Contains(d.Name(), "マッチさせる文字列") {
				fmt.Printf("Path: %s\n", path)
				fmt.Printf("Name: %s\n", d.Name())

				os.Remove(path)

				fmt.Printf("Delete: %s\n", path)
			}
		}
		return err
	})
}

配列内に要素が含まれているか確認する

配列に指定した要素が含まれているかどうかを確認したい時、JSだったら array.includes 使うんだけど、Goにはそういう関数が用意されてなかったので自作するしかないんだって。びっくり。

func contains(target []string, str string) bool {
	for _, v := range target {
		if v == str {
			return true
		}
	}

	return false
}

Repo

https://github.com/Tenderfeel/onmyojigame-pelican

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください