Scrapyをちょっと触ってみて

まだまだわかっていないが現時点でわかった知見をメモ

  • scrapy shell [url]であらかじめアドホックCSSセレクタを調べて、それをそのままSpiderにすれば早い
  • リンクをたどっていく形式のSpiderは CrawlSpiderを使うとよい。一般的なユースケースがカバーできる。
  • 要素が可変なテーブルなどで、スクレイピング対象のDOMが特定しにくい場合、「○○という文字列の次にあるtd要素」のように「次の」「前の」指定をしたいことがある。そのようなケースはCSSセレクターでは処理できない。代わりにxpathを使う。

例:”住所”という文字列の次にあるtd要素を取得

address = response.xpath("//*[text()='住所']/following-sibling::td").extract_first()

scrapyでYahooニュースをクローリング

これも書籍の通り。復習のためにメモを残しておく。 これが一番シンプルかつ実用的なサンプルといえる。

Scrapyの作法に沿ってプロジェクトを作る。

 ᐅ scrapy startproject myproject
 ᐅ cd myproject
 ᐅ scrapy genspider news news.yahoo.co.jp
 ᐅ tree myproject 
myproject
├── __init__.py
├── __pycache__
│   ├── __init__.cpython-36.pyc
│   ├── items.cpython-36.pyc
│   └── settings.cpython-36.pyc
├── items.py
├── middlewares.py
├── pipelines.py
├── settings.py
└── spiders
    ├── __init__.py
    ├── __pycache__
    │   ├── __init__.cpython-36.pyc
    │   └── news.cpython-36.pyc
    └── news.py

3 directories, 12 files

編集するのは、settings.py(設定ファイル), items.py(データのスキーマを定義する), spiders/news.py(スパイダーのロジックを記述)の3つ。

settings.py

(中略)
DOWNLOAD_DELAY = 1 #リクエストごとに1秒空ける
(中略)

items.py

import scrapy


class Headline(scrapy.Item):
    """
    ニュースのヘッドラインを表すItem
    """
    title = scrapy.Field()
    body = scrapy.Field()

spider/news.py

import scrapy

from myproject.items import Headline


class NewsSpider(scrapy.Spider):
    name = 'news'
    allowed_domains = ['news.yahoo.co.jp']
    start_urls = ['http://news.yahoo.co.jp/']

    def parse(self, response):
        """
        トップページのトピックス一覧から個々のトピックスへのリンクを抜き出してたどる。
        """
        for url in response.css('ul.topics a::attr("href")').re(r'/pickup/\d+$'):
            yield scrapy.Request(response.urljoin(url), self.parse_topics)

    def parse_topics(self, response):
        """
        トピックスのページからタイトルと本文を抜き出す。
        """
        item = Headline()
        item['title'] = response.css('.newsTitle ::text').extract_first()  # タイトル
        item['body'] = response.css('.hbody').xpath('string()').extract_first()  # 本文
        yield item
  ᐅ scrapy crawl news -o items.jl                                                    
2018-11-05 23:43:28 [scrapy.utils.log] INFO: Scrapy 1.5.1 started (bot: myproject)

(中略)

2018-11-05 23:43:43 [scrapy.core.engine] INFO: Closing spider (finished)
2018-11-05 23:43:43 [scrapy.extensions.feedexport] INFO: Stored jl feed (8 items) in: items.jl
2018-11-05 23:43:43 [scrapy.statscollectors] INFO: Dumping Scrapy stats:
{'downloader/request_bytes': 3455,
 'downloader/request_count': 13,
 'downloader/request_method_count/GET': 13,
 'downloader/response_bytes': 167867,
 'downloader/response_count': 13,
 'downloader/response_status_count/200': 11,
 'downloader/response_status_count/301': 2,
 'finish_reason': 'finished',
 'finish_time': datetime.datetime(2018, 11, 5, 14, 43, 43, 741275),
 'item_scraped_count': 8,
 'log_count/DEBUG': 22,
 'log_count/INFO': 8,
 'memusage/max': 50143232,
 'memusage/startup': 50143232,
 'request_depth_max': 1,
 'response_received_count': 11,
 'scheduler/dequeued': 10,
 'scheduler/dequeued/memory': 10,
 'scheduler/enqueued': 10,
 'scheduler/enqueued/memory': 10,
 'start_time': datetime.datetime(2018, 11, 5, 14, 43, 28, 234828)}
2018-11-05 23:43:43 [scrapy.core.engine] INFO: Spider closed (finished)

リクエストごとに1秒置いて実行していくので少し時間がかかる。 最後にクローラーの統計がダンプされるので、そこで結果のサマリーがわかる。item_scraped_countが一番わかり易い。

結果は items.jlというファイルに書き込まれているが、そのままだと日本語がエスケープされているので、jqを使えば日本語として読める。

 ᐅ cat news.jl | jq .          
{
  "title": "NHK鈴木奈穂子アナ 21日ぶり「ニュース7」復帰「ご心配をお掛けしました」ネット安堵の声",
  "body": " 10月16日から体調不良で休養していたNHK「ニュース7」(月~日曜後7・00)平日キャスターの鈴木奈穂子アナウンサー(36)が5日、21日ぶりに番組に復帰した。冒頭、サブキャスターの高井正智アナウックス)"
}
(以下略)

Scrapyでシンプルなクローラーを書く

クローリングハック あらゆるWebサイトをクロールするための実践テクニック

クローリングハック あらゆるWebサイトをクロールするための実践テクニック

クローリング&スクレイピングフレームワークであるScrapyを試してみた。 Scrapingはクローリングとスクレイピングを両方やってくれる大きめのフレームワークで、スクレイピングに特化したbeautifulsoupとはその点が異なる。

ここでは上記の書籍に沿ってやってみた。

ᐅ python -V    
Python 3.6.5

ᐅ scrapy version
Scrapy 1.5.1

スクリプト 最もシンプルなスパイダーの実装。 書籍のままだと動かなかった(サイト構成が変わっている)ので少し変えている。

import scrapy


class BlogSpider(scrapy.Spider):
    name = 'blogspider'
    start_urls = ['https://blog.scrapinghub.com']

    def parse(self, response):
        """
        トップページからカテゴリページへのリンクを抜き出してたどる。
        """
        for url in response.css('ul li a::attr("href")').re('.*/tag/.*'):
            yield scrapy.Request(response.urljoin(url), self.parse_titles)

    def parse_titles(self, response):
        """
        カテゴリページからそのカテゴリの投稿のタイトルをすべて抜き出す。
        """
        for post_title in response.css('div.post-header > h2 > a::text').extract():
            yield {'title': post_title}

実行

ᐅ scrapy runspider myspider.py -o items.jl
2018-11-05 22:49:33 [scrapy.utils.log] INFO: Scrapy 1.5.1 started (bot: scrapybot)
2018-11-05 22:49:33 [scrapy.utils.log] INFO: Versions: lxml 4.2.5.0, libxml2 2.9.8, cssselect 1.0.3, parsel 1.5.1, w3lib 1.19.0, Twisted 18.9.0, Python 3.6.5 (default, Apr 25 2018, 14:23:58) - [GCC 4.2.1 Compatible Apple LLVM 9.1.0 (clang-902.0.39.1)], pyOpenSSL 18.0.0 (OpenSSL 1.1.0i  14 Aug 2018), cryptography 2.3.1, Platform Darwin-18.0.0-x86_64-i386-64bit

(中略)

2018-11-05 22:49:37 [scrapy.core.engine] INFO: Spider closed (finished)

結果であるitems.jlの中身を見る jlはjson lineの略。こういうデータ形式があるらしい。行単位で1レコードがjsonになっているデータ形式

ᐅ cat items.jl 
{"title": "<a href=\"https://blog.scrapinghub.com/2016/10/27/an-introduction-to-xpath-with-examples\">An Introduction to XPath: How to Get Started</a>"}
{"title": "<a href=\"https://blog.scrapinghub.com/2016/08/25/how-to-crawl-the-web-politely-with-scrapy\">How to Crawl the Web Politely with 

(以下略)

Redis5とstream

Redis version5(stable)が出た。

[RELEASE] Redis 5 is out! - Google グループ https://groups.google.com/forum/m/#!topic/redis-db/l0OXDAlwosU

リリースノートの中ではstreamという新しいデータ構造が面白そう。

https://redis.io/topics/streams-intro

streamはログを抽象化したようなappend onlyのデータ構造。

ログの扱いって、今でも一般的なアーキテクチャだとファイルにいったん書いて、それをfluentdで拾ってどこかに投げ直す、というのが一般的になっている(GKEも内部的にはそうなっているし)。 それをこのredis streamで置き換えていけないだろうか。

OKRと評価について

speakerdeck.com

OKRと評価について読んだものの中では一番納得感あった。

  • OKRを人事評価に使わない
  • 健康・健全性指標について
  • 給与は結局のところ、ざっくり決めるしかない云々のくだり

OKRの達成度で評価するのではなく、OKRをベースにどれだけ(大胆に)打ち手を打てたかで評価するのはどうだろうか?

Goならわかるシステムプログラミング:第3章(練習問題)

Q3.1 ファイルのコピー

package main

import (
    "io"
    "os"
)

func main() {
    file, err := os.Open("old.txt")
    if err != nil {
        panic(err)
    }
    defer file.Close()

    newFile, err := os.Create("new.txt")
    if err != nil {
        panic(err)
    }
    defer newFile.Close()
    io.Copy(newFile, file)
}

テスト

package main

import (
    "bytes"
    "io/ioutil"
    "os"
    "testing"
)

func exists(filename string)bool {
    _, err := os.Stat(filename)
    return err == nil
}

func TestMainSuccess(t *testing.T){
    if exists("new.txt") {
        os.Remove("new.txt")
    }

    main()
    oldFile, err := os.Open("old.txt")
    if err != nil {
        t.Fatalf("failed to open old file: %#v", err)
    }
    newFile, err := os.Open("new.txt")
    if err != nil {
        t.Fatalf("failed to open new file: %#v", err)
    }
    oldBuffer, err := ioutil.ReadAll(oldFile)
    if err != nil {
        t.Fatalf("failed to read content: %#v", err)
    }
    newBuffer, err := ioutil.ReadAll(newFile)
    if err != nil {
        t.Fatalf("failed to read content: %#v", err)
    }
    if bytes.Compare(oldBuffer, newBuffer) != 0 {
        t.Fatalf("each contents of files are different.")
    }

    os.Remove("new.txt")
}

テストはもうちょっとスッキリかける気がする。

Q3.2 テスト用の適切なサイズのファイルを生成

package main

import (
    "crypto/rand"
    "io"
    "os"
)

func main() {
    file, err := os.Create("samplefile")
    if err != nil {
        panic(err)
    }
    defer file.Close()
    r := io.LimitedReader{R: rand.Reader, N: 1024}
    io.Copy(file, &r)
}

io.Copyを使わないように」と問題には書いてあったが、LimitReaderを使って読み出しているので問題ない?

テスト

package main

import (
    "os"
    "testing"
)

func TestMainSuccess(t *testing.T) {
    main()
    info, err := os.Stat("samplefile")
    if err != nil {
        t.Fatalf("could not open file.")
    }
    if info.Size() != 1024 {
        t.Fatal("size was not 1024 byte.")
    }
    defer os.Remove("samplefile")
}

ファイルサイズはos.Stat()で取れる。

Q3.4 zipファイルをウェブサーバーからダウンロード

package main

import (
    "io"
    "net/http"
    "os"
)

func main() {
    http.HandleFunc("/", handler)
    http.ListenAndServe(":8081", nil)
}

func handler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/zip")
    w.Header().Set("Content-Disposition", "attachment; filename=ascii_sample.zip")
    file, err := os.Open("./chap3/test.zip")
    if err != nil {
        panic(err)
    }
    defer file.Close()
    io.Copy(w, file)
}

Q3.5 CopyN

package main

import (
    "io"
    "os"
    "strings"
)

func main() {
    r := strings.NewReader("strings reader example")
    CopyN(os.Stdout, r, 5)
}

func CopyN(dst io.Writer, src io.Reader, n int64) (written int64, err error) {
    written, err = io.Copy(dst, io.LimitReader(src, n))
    if written == n {
        return n, nil
    }
    if written < n && err == nil {
        // src stopped early; must have been EOF.
        err = io.EOF
    }
    return
}

実装はこちらのコピー。 https://golang.org/src/io/io.go?s=11939:12009#L329

Q3.6 ストリーム総集編

package main

import (
    "io"
    "os"
    "strings"
)

var (
    computer = strings.NewReader("COMPUTER")
    system = strings.NewReader("SYSTEM")
    programming = strings.NewReader("PROGRAMMING")

)
func main() {
    var stream io.Reader

    charA := io.NewSectionReader(programming, 5, 1)
    charS := io.NewSectionReader(system, 0, 1)
    charC := io.NewSectionReader(computer, 0, 1)
    charI, pw := io.Pipe()
    w := io.MultiWriter(pw, pw)
    go func() {
        io.Copy(w, io.NewSectionReader(programming, 8, 1))
        pw.Close()
    }()
    stream = io.MultiReader(charA, charS, charC, charI)
    io.Copy(os.Stdout, stream)
}

同じwriterに2回書けるということに気づかず、時間を使ってしまった。 答えは下記のリンクにある(上記のものとは少し違う)

ASCII.jp:低レベルアクセスへの入り口(3):io.Reader後編 (2/2)|Goならわかるシステムプログラミング

同じ振る舞いが想像以上に威圧的にとられてしまう懸念

エンジニアを育てる環境と、コミュニティのありかたについて - 滞舎路日記

自分自身は、率直に物事を指摘する、勢いのある若手のつもりでいても、在籍年数を重ねるうちに、同じ振る舞いが想像以上に威圧的にとられてしまうこともあるので、みなさん注意しましょう。

これがすごい金言で、ありがちな状況を的確に言語化していると思う。他人がそういうふうであることを見かけることもままあるし、自分が他人からそう見えていないかどうか不安だ。本人は悪意が無いかつ無自覚なので