goでspeakerdeckからpdfを掠め取りたかった

この記事は[Wanoグループアドベントカレンダー](https://qiita.com/advent-calendar/2017/wano-group)の6日目です。
普段のやりこみが足りないので、またgoで小ネタです。

# 犯行動機: 4G回線でspeakerdeckが重い
ちゃんと計測したわけじゃないんですが、4G回線で、iPhoneから閲覧してるとめっちゃ重くないですか？speakerdeck

javascriptが重いのか、スライドのデータサイズが大きいせいなのか分かりませんが、とにかく通勤途中の電車内でまともに閲覧できないので困っておりました。
(slideshareは意外といける)

そういうわけなので、スライドを端末のローカルに保存してしまえという話です。


## 手順1: speakerdeckからスライドのURLをスクレイピングする
speakerdeckはご丁寧にスライドのダウンロードリンクを用意してくれているので、それをスクレイピングします。
(ブラウザから手動で落とせばええやんとか無しで)

``` html
<h1>Share</h1>
<ul class="delimited share">
  <li><a href="#" class="grey" id="share_social">Twitter, Facebook</a></li>
  <li><a href="#" class="grey" id="share_embed">Embed</a></li>
  <li><a href="#" class="grey" id="share_link">Direct Link</a></li>
  <li><a href="https://speakerd.s3.amazonaws.com/presentations/022594eca7cc4f3987d32bde33dfab26/ios_test_night__6.pdf" class="grey" id="share_pdf">Download PDF</a></li>
</ul>
```

上記のhtmlがspeakerdeckの右側にあるShareリンク部分
ご丁寧に「share_pdf」とid属性を振ってくれている。**神か**


### goqueryで一撃パース

htmlのパースにはgoqueryを使います。
(もはやcurlでhtml落としてgrepで抽出できるレベルなんだけど)

goqueryはJQueryライクにhtmlをチョメチョメするやつです。

``` go:parse.go
func searchPdfLink(url string) (string, error) {
	doc, err := goquery.NewDocument(url)
	if err != nil {
		return "", err
	}

	link, ok := doc.Find("#share_pdf").Attr("href")
	if !ok {
		return "", errors.New("pdf link not found")
	}

	return link, nil
}
```


### 全体

``` go:main.go
func main() {
        //コマンドライン引数にurlが指定された場合
	if len(os.Args) == 2 {
		url := os.Args[1]
		link, err := searchPdfLink(url)
		if err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}
		fmt.Println(link)
		return
	}

        //標準入力からurlを受け取る場合
	urlCh := make(chan string)
	stopCh := make(chan struct{})
	var wg sync.WaitGroup

	for i := 0; i < 3; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for {
				select {
				case url := <-urlCh:
					link, err := searchPdfLink(url)
					if err != nil {
						fmt.Fprintln(os.Stderr, err)
						continue
					}
					fmt.Println(link)
				case <-stopCh:
					return
				}
			}
		}()
	}

	scanner := bufio.NewScanner(os.Stdin)

	for scanner.Scan() {
		url := scanner.Text()
		urlCh<- url
	}

	close(stopCh)
	wg.Wait()

}
```

コメントにある通り、引数にURLが指定されたらそれを見にいき、そうでない場合は標準入力からURLを受取ます。

標準入力からURLを受け取る場合に、URLの数だけgoroutineを起動するのはおイタが過ぎるので、並列数は3でfor-selectパターンでgoroutineを起動しておき、mainのgoroutineがchannel経由でURLを引き渡して処理するようにしています。


## 手順2: 手順1で取得したURLからpdfをダウンロードする

wgetでいいじゃんと思うでしょ？goroutineで並列ダウンロードしたかったからgoで書きました。
(xargs使えばwgetでも並列ダウンロードいけるみたいだけど。。。)

基本的にはhttp.getで取得して、ファイルに保存するだけなので、以下のような感じ

``` go:getter.go
package main

import (
	"os"
	"net/url"
	"strings"
	"fmt"
	"net/http"
	"io"
	"bufio"
	"sync"
)

func download(targetURL string, mu *sync.Mutex) error {
	u, err := url.Parse(targetURL)
	if err != nil {
		return err
	}
	paths := strings.Split(u.Path, "/")
        //ファイル名はURLのパスの末尾から取得
	fileName := paths[len(paths)-1]
	var f io.WriteCloser
        //万一のファイル被りを回避
	mu.Lock()
	func () {
		defer mu.Unlock()
		if _, err = os.Stat(fileName); err == nil {
			counter := 1
			for {
				tmpFileName := fmt.Sprintf("%s.%d", fileName, counter)
				if _, err = os.Stat(tmpFileName); err != nil {
					fileName = tmpFileName
					break
				}
				counter++
			}
		}

		f, err = os.Create(fileName)
	}()
	if err != nil {
		return err
	}
	defer f.Close()

	resp, err := http.Get(targetURL)
	if err != nil {
		return err
	}

	defer resp.Body.Close()

	io.Copy(f, resp.Body)
	fmt.Println(fileName)
	return nil
}

func main() {
	var mu sync.Mutex
	if len(os.Args) > 1 {
		targetURL := os.Args[1]
		if err := download(targetURL, &mu); err != nil {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		}
		return
	}

	scanner := bufio.NewScanner(os.Stdin)
	var wg sync.WaitGroup
	for scanner.Scan() {
		targetURL := scanner.Text()
		wg.Add(1)
		go func() {
			defer wg.Done()
			if err := download(targetURL, &mu); err != nil {
				fmt.Fprintln(os.Stderr, err)
			}
		}()
	}

	wg.Wait()
}
```

pdfが保存されているのがawsのs3なので、goroutineの起動制限はかけておらず、URLの数だけgoroutineを起動して取得するようになってます。
(つーてもspeakerdeckのpdfのURL以外にも使えるダウンローダーだから危ないんだけど)



## 中間成果

こんな内容のファイルがあったとして

``` url.txt
https://speakerdeck.com/fujikawakei/assertion-woji-ji-de-nishi-tute-yi-li-tutahua
https://speakerdeck.com/kfujita/du-miyasuikodowoshu-kutameni
https://speakerdeck.com/mobilebiz/google-home-broadcast-system
https://speakerdeck.com/danilop/re-invent-2017-serverless-recap
```

こんな感じで使える
(pdf_linkがpdfのリンク抽出、getterがダウンローダー)

``` shell
$ pdf_link < url.txt | getter
ios_test_night__6.pdf
コード.pdf
IoTLT用資料_2017_12_4_.pdf
reInventServerlessRecap-20171205.pdf
```


## 今後の課題

### どうやってモバイル端末に同期するか

とりあえずMacのiBooksからファイルを追加してあげると、iPhoneのiBooksからも閲覧できるようになるようだけど、最後にGUI操作が入ってしまうのは片手落ち感が大きい。

**というかiOS端末以外からも閲覧したいし**

kindleパーソナルライブラリに追加するのが早そうです。
(所定のメールアドレスにファイルを添付したメールを送信)

### そもそもspeakerdeckのURLをリストアップするのがダルいな？

中間成果で使ったurl.txtは手動で編集しましたが、まあめんどくさいですよ。
speakerdeckとかslideshareで興味のあるやつは大体pocketに登録してあるので、pocketのAPIを叩いてURL一覧を抽出するコードを書くのが良さそうです。


### ていうかchrome拡張でボタン押したらファイル取得できるようにしたい

究極的にはこれ
speakerdeckのページでボタンを押すと、pdfを落としてkindleパーソナルライブラリにメール送信するところまでやるやつ


## 感想

speakerdeckのDOMが親切なおかげでpdfを取得するのはチョロいのですが、ラストワンマイルがやれてません。
アドベントカレンダーが埋まらなかったら最後までやるかも。

## 参考

- [goquery](https://github.com/PuerkitoBio/goquery)
- [goでスクレイピングするのにgoquery + bluemonday が最強な件](https://qiita.com/ryurock/items/b0466ad144f5e0555a95)

## 余談

なんでspeakerdeckはs3の前にcloudfrontおいてないんだろう。。。？