意外と便利なcurlのwriteoutオプション(http_codeとurl_effectiveは便利過ぎる)
唐突にきた調査依頼
依頼主「ここにあるURLリストで正常にアクセスできると、URLが変わった物をリストアップしてほしい。」
私「はい、すぐに!お?10万件?スクリプトをサクッとやりますが、量が多いので実行に1日ぐらいかかります。」
できた物
#!/bin/bash USER_AGENT='Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/601.1.39 (KHTML, like Gecko) Version/10.1.2 Safari/601.1.39' cat ./url.txt | while read old_url do code=`curl -A '"{$USER_AGENT}"' -s -o /dev/null -w '%{http_code}' ${old_url}` if [ `echo "${code}" | grep '30'` ]; then new_url=`curl -A '"{$USER_AGENT}"' -sL -o /dev/null -w '%{url_effective}' ${old_url}` printf '"%s","%s"\n' "${new_url}" "${old_url}" elif [ "${code}" = '200' ]; then printf '"%s",""\n' "${old_url}" else printf '"%s","error url"\n' "${old_url}" fi done
説明
url.txt
を読み込んで、whileループで回す。- curlのsilentオプションで、outputオプションは/dev/nullで捨てて、writeoutオプションのhttp_codeでHTTPステータスコードを取得する。
- HTTPステータスコードで300系ならば、再度curlのsilentオプションで、outputオプションは/dev/nullで捨てて、locationオプションを使って最終的にアクセスしたURLに遷移させ、writeoutオプションのurl_effectiveで遷移したURLを取得し、そのURLと元のURLを表示する。
- HTTPステータスコードで200ならば、URLをそのまま表示する。
便利なwriteoutオプション
curlのwriteoutオプションはときどき追加があります、そのためcurlのtool_writeout.c
ファイルは時々見ると新たな発見があったりします。
github.com
余談ですが、writeoutオプションには、みんなが大好きなjson
というのもあり、サイトを見るときに指定して意味なく情報を見るのが楽しいです。
実行コマンドです。
curl -s -o /dev/null -w '%{json}' https://www.google.co.jp/ | jq
実行結果です。
{ "url_effective": "https://www.google.co.jp/", "http_code": 200, "response_code": 200, "http_connect": 0, "time_total": 0.220751, "time_namelookup": 0.002085, "time_connect": 0.012867, "time_appconnect": 0.138509, "time_pretransfer": 0.138608, "time_starttransfer": 0.218738, "size_header": 938, "size_request": 80, "size_download": 12052, "size_upload": 0, "speed_download": 54781, "speed_upload": 0, "content_type": "text/html; charset=Shift_JIS", "num_connects": 1, "time_redirect": 0, "num_redirects": 0, "ssl_verify_result": 0, "proxy_ssl_verify_result": 0, "filename_effective": "/dev/null", "remote_ip": "2404:6800:4004:80d::2003", "remote_port": 443, "local_ip": "にゃ~ん", "local_port": にゃ~ん, "http_version": "1.1", "scheme": "HTTPS" }
あれ?Googleのサイトでcontent_type
が懐かしのShift_JIS
だ!?
"content_type": "text/html; charset=Shift_JIS"
こんなcontent_type
を返すのは変だな、よし!試しにUser Agentを付加してやってみよう!
curl -A 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/601.1.39 (KHTML, like Gecko) Version/10.1.2 Safari/601.1.3 9' -s -o /dev/null -w '%{json}' https://www.google.co.jp/ | jq
実行結果です。
{ "url_effective": "https://www.google.co.jp/", "http_code": 200, "response_code": 200, "http_connect": 0, "time_total": 0.324704, "time_namelookup": 0.004633, "time_connect": 0.015616, "time_appconnect": 0.168842, "time_pretransfer": 0.168961, "time_starttransfer": 0.280845, "size_header": 1017, "size_request": 186, "size_download": 188613, "size_upload": 0, "speed_download": 582138, "speed_upload": 0, "content_type": "text/html; charset=UTF-8", "num_connects": 1, "time_redirect": 0, "num_redirects": 0, "ssl_verify_result": 0, "proxy_ssl_verify_result": 0, "filename_effective": "/dev/null", "remote_ip": "2404:6800:4004:80b::2003", "remote_port": 443, "local_ip": "にゃ~ん", "local_port": にゃ~ん, "http_version": "1.1", "scheme": "HTTPS" }
変わった!content_type
がUTF-8
になりました。
"content_type": "text/html; charset=UTF-8"
これは私の推測ですが、Googleの日本サイトはガラケー全盛期に登場しているので、そのときの名残りかな?と思います。
こういう色々な発見があるのでcurlは楽しいです。
長いURLに対して Scrapy するときの覚書
結論
Scrapy で長いURLを対象にするときは、設定ファイルのsettings.py
にURLLENGTH_LIMIT
を書いてURLの最大長を記載する。
自分がやったときはURLの長さが3,800文字だったので、4,000文字に設定した。
# URL LENGTH URLLENGTH_LIMIT = 4000
ログレベルについて
あるサイトを対象にScrapyしてたとき、次のページを取らないというバグが発生する。
ログを眺めているとDEBUGの文字とともにURLが長いからリンクを無視と出ている。
[scrapy.spidermiddlewares.urllength] DEBUG: Ignoring link (url length > 2083): 対象URL
いや、気付けたから良いのですが、URLを無視するのはdebug
では無いと思っております。
私の考えですがdebug
は開発時に使うもので、本番リリースするときは全て取り除いてほしいので、今回の件はwarning
辺りに記載してほしいなと思いました。
warning
なんて見ない!という考え方もありますが、その場合はinfo
でもerror
でもかまいませんけど、今回の件ではdebug
では無いかなーと思います。
msmtp を使う
概要
過去記事やQiitaの記事で使用したmsmtp
は普通に便利なのでご紹介します。
過去記事 kawahara-ci.hatenablog.com
Qiitaの記事 qiita.com
ローカル開発環境でメール送信するという需要は少ないとは思いますが、知っていて損はないので記事を書きました。
これをDockerに展開することも可能です。
インストールして設定する
brew install msmtp
Windowsだと・・・ごめん、わからない。
インストールが終わったら設定します。
~/.msmtprc
に外部メールサーバーの設定をします。
みんなが大好きな Gmailの設定例を書きます。
host smtp.gmail.com port 587 user 使用するGmailアカウント password アプリパスワード from 使用するGmailアカウント tls on tls_starttls on tls_certcheck off auth on logfile ~/.msmtp.log
ここで重要なのはアプリパスワードです。
Gmailを外部メールサーバーとして使うためには、アプリパスワードを設定します。
アプリパスワードを使用するには2段階認証プロセスを有効にしないと使用できません。
まあ、エンジニアなら2段階認証プロセスを有効にしていると思うので、アプリパスワードの説明だけです。
アプリパスワードの設定はマイアカウントのセキュリティから設定します。
アプリパスワードを押下するとパスワード生成画面に遷移します。
アプリを選択、デバイスを選択の項目は、備忘録みたいなものなので、適当で良いです。
入力したら「生成」を押下します。
アプリパスワードが生成されました。(このアプリパスワードは破棄済み)
このアプリパスワードを先程の~/.msmtprc
に設定します。
使ってみる
過去記事やQiitaの記事を参考にどうぞ!
過去記事 kawahara-ci.hatenablog.com
Qiitaの記事 qiita.com
せっかくなのでPerlでもやってみましょう!
パイプでやればいけます。
下記サンプルでファイルを作りperl msmtp.pl
とか実行するとメールが送信されます。
#!/usr/bin/perl if (! open($mail, "| /usr/local/bin/msmtp -t")) { print "msmtp が無いです。¥n"; exit; } $mailtext = << "EOM"; From: sample\@sample.com To: sample\@sample.com Subject: test test EOM print $mail $mailtext; close $mail; exit;
なおPHPでもパイプで送信可能ですが、PHPならsendmail_pathを設定すればOKです。
使い終わったら
アプリパスワードが不要になったら削除しましょう。
ゴミ箱のアイコンを押下すると、確認ダイアログ無しで、速攻で削除されます。
Gmail アプリがダークモードに対応したのでCSSのprefers-color-schemeで試してみるが・・・
結論
Gmail アプリがダークモードに対応しました。
HTMLメールを受信するとどうなるか確認してみました。
結論から言うとダークモード判定に使うprefers-color-schemeは無視されます!!
Gmailアプリ iPhone版 バージョン 6.0.200412 で確認しました。
msmtpについてはこちらの記事をごらんください。
kawahara-ci.hatenablog.com
Media Queriesで試す
とりあえずダークモードとは関係ないけど、Media Queriesに反応するかということで、画面サイズで色が変わることを確認する。
HTMLメールをコマンドで送信します。
cat t1.txt | msmtp -t
t1.txt ファイルの中身です。
From: "test1" <sample@sample.com> To: sample@sample.com Subject: test1 Content-Type: text/html <html> <head> <meta http-equiv="content-type" charset="utf-8"> <style> @media (max-width: 768px) { .test1 { color: #f0f; } } @media (min-width: 769px) { .test1 { color: #0ff; } } </style> </head> <body> <h1 class="test1">テストメール</h1> </body> </html>
想定では #f0f で表示されると思います。
メールを開いてからダークモードとライトモードの切り替えをしてみます。
Media Queriesで試すのライトモード
Media Queriesで試すのダークモード
ライトモードもダークモードもmax-width: 768pxで指定された #f0f が表示されました。
ちなみにGmailアプリではダークモードのときメールの文面だけをライトモード(ライトテーマ)に変更して表示するモードがあります。(逆のパターンは無いです)
右メニューに「ライトテーマで表示」があります。
文面だけライトモードで表示されました。
prefers-color-schemで試す1
ダークモードの切り替えはprefers-color-schem
を使用します。
prefers-color-schem
を使用したHTMLファイルを添付してメールします。
その添付したファイルを読んでダークモードとライトモードの切り替えをしてみます。
t.html ファイルの中身です。
<html> <head> <meta http-equiv="content-type" charset="utf-8"> <style> @media (prefers-color-scheme: light) { .test2 { color: #f0f; } } @media (prefers-color-scheme: dark) { .test2 { color: #0ff; } } </style> </head> <body> <h1 class="test2">テストメール</h1> </body> </html>
想定ではライトモードでは #f0f で表示で、ダークモードでは #0ff で表示だと思います。
prefers-color-schemで試す1のライトモード
prefers-color-schemで試す1のダークモード
想定通りライトモードでは #f0f で表示で、ダークモードでは #0ff で表示されました。
prefers-color-schemで試す2
HTMLメールで色が変わることを確認する。
t.htmlファイルに送信先を追加してt2.txt ファイルを作成します。
そのHTMLメールをコマンドで送信します。
cat t2.txt | msmtp -t
t2.txt ファイルの中身です。
From: "test2" <sample@sample.com> To: sample@sample.com Subject: test2 Content-Type: text/html <html> <head> <meta http-equiv="content-type" charset="utf-8"> <style> @media (prefers-color-scheme: light) { .test2 { color: #f0f; } } @media (prefers-color-scheme: dark) { .test2 { color: #0ff; } } </style> </head> <body> <h1 class="test2">テストメール</h1> </body> </html>
想定ではライトモードでは #f0f で表示で、ダークモードでは #0ff で表示だと思いますが・・・。
メールを開いてからダークモードとライトモードの切り替えをしてみます。
prefers-color-schemで試す2のライトモード
prefers-color-schemで試す2のダークモード
ライトモードでは #000 で表示で、ダークモードでは #fff で表示されました。
残念ながらGmailアプリではprefers-color-schemeは無視されるようです。
無視されるのはGmailアプリでライトテーマ表示があるからと推測しております。
上記のメールをダークモードで起動して、Gmailアプリのライトテーマ表示をすると、こんな感じで表示されます。
アンカータグに入ったJavaScriptスキームのサイトにScrapyで挑む
Scrapyしにくいサイト
ここです。
jinzai.hellowork.mhlw.go.jp
ちょっとアクセスすると判るのですが、href
にJavaScript:
に入れまくっている(JavaScriptスキーム)平成初期の臭いが漂うサイトですね。
今回は、Scrapyを使ってスクレイピングしてみます。
Scrapyの下準備
Scrapyの説明も含めて、こちらをごらんください。
qiita.com
非常に解りやすいので、オススメです。
デベロッパーツールはともだち
Scrapyは何でもいけますが、リクエスト内容がわからないと画面遷移ができないので調査します。
サイトにアクセスしてから、デベロッパーツールを開きます。
※POSTMANなどツールもありますが、今回は使わないでいきます。
ご存知だと思いますが [表示] - [開発 / 管理] - [デベロッパーツール]でデベロッパーツールで開きます。(ショートカットキーでもOK)
そしてNetworkに切り替えます。
start_urlsのURLを探索する
調査したいリンクを押下します。
そして、Networkで表示されたリソースの一番上を押下して情報を所得したら、一番下までスクロールします。
Form Dataが必要な情報になります。
Form Dataにトークンらしきものが無ければ、このForm Dataのデータを使ってGet Methodにしてアクセスしてみます。
Get Methodにできればコードが減るので、このURLをとりあえず覚えておきます。
※Post Methodのみだったら、ここからコードを書くことになります。
さらにもっとできないか探索します。
調査を続けます、次に都道府県の東京都をチェックしてから検索を押下します。
同じようにForm Dataを見て、トークンらしきものが無ければForm Dataのデータを使ってGet Methodにしてアクセスをします。
そうすると、アクセスできたので、このURLもとりあえず覚えておきます。
トークンがある
検索結果を全て取得したいので、次ページのページネーションを確認します。
画面の下の2
を押下します。
そうすると、ついにトークンらしきものが現れました。
ここからは、Post Methodの方が良さそうなので、さきほど覚えたURLをScrapyのstart_urls
としてコードを書きます。
コードを書く
会社名を取得するので、items.py はcompany_name
と書く。
# -*- coding: utf-8 -*- # Define here the models for your scraped items # # See documentation in: # https://docs.scrapy.org/en/latest/topics/items.html import scrapy class Post(scrapy.Item): company_name = scrapy.Field()
spiders/hellowork.py はこんな感じです。
# -*- coding: utf-8 -*- import re import scrapy from .. items import Post class HelloworkSpider(scrapy.Spider): name = 'Hellowork' allowed_domains = ['jinzai.hellowork.mhlw.go.jp'] start_urls = ['https://jinzai.hellowork.mhlw.go.jp/JinzaiWeb/GICB102010.do?action=search&screenId=GICB102010&cbTokyo=1'] def parse(self, response): for post in response.css('table#search tr'): name = post.css('span#ID_lbJigyoshoName::text').extract_first() if name is None: continue yield Post( company_name = name ) # 現在のページ数を取得する for current in response.css('table tr td[style="width: 60%;"]'): current_page = current.css('span#ID_lbSearchCurrentPage::text').extract_first() if current_page is None: return # 次のページがあるか確認する next_page = int(current_page) + 1 has_next_page = False for id_pager_tag in response.css('table tr td[style="padding:5px;"]'): id_pager_href = id_pager_tag.css('a::attr(href)').extract_first() if id_pager_href is not None: find_array = re.findall('[0-9]+', id_pager_href) if len(find_array) == 0: continue if int(find_array[0]) == next_page: has_next_page = True next_page = str(next_page) break # 次ページが無ければ終了する if has_next_page is False: return # 次のページをリクエストして、解析用parseに渡す yield scrapy.FormRequest.from_response( response, formdata = dict( screenId = 'GICB102010', action = 'page', cbTokyo = '1', curPage = current_page, params = next_page), callback = self.parse, dont_click = True ) return
説明
parseの
response.css('table#search tr'):
から会社名を取得します。
会社名はID名がsearchのtableタグのの中にあるのでresponse.css('table#search tr')
というので検索せます。
会社名自体はspanタグのID名がID_lbJigyoshoNameのテキスト項目なのでspan#ID_lbJigyoshoName::text
で引っ張り出します。
なお、tableタグの中にはtrには何も定義が無いので、余分なtrも検索してしまうので、spanタグがうまく取れなかったらスルーします。次ページ
みたいのがないので、現在ページを取得+1を計算してページネーションと比較する方法を取ります。
現在ページ + 1 in ページネーション数値
を次ページとしたいので、まずは現在ページを取得します。
会社一覧の上に小さく現在ページが表示されているので、そこのタグspan#ID_lbSearchCurrentPage::text
がページ数らしいですので、それを取得します。次はページネーションのところを探索します。
table tr td[style="padding:5px;"]
を配列で取得して、a::attr(href)
のJavaScriptスキームを取得して、そこから正規表現で数値だけを取り出します。
なんで、こんな面倒なことをしているのかというと、上記のページネーションは世間一般のページネーションとは異なっているからで、次ページ表記っぽい>>
が実は「10*次ページ数+1」という謎ルールで、それなら素直にアンカーダグのJavaScriptスキームを読んだほうが早いからです。現在ページ + 1 in ページネーション数値
と判定できれば次ページが存在する、無ければ存在しないのでプログラムは終了とする。次ページに遷移させるところは、トークンらしきものがあるので、ここはFormRequestを使います。
formdata
に色々とデータを押し込んで、callback
はparse
を再帰呼び出しで次ページが無くなるまで繰り返すようにして、dont_click
をTrueにしてJavaScriptスキームを発火させています。
動作結果
scrapy crawl Hellowork
で実行すると、以下のように会社名が取れていることが確認できます。
なお、CSVファイルに出力したいときはscrapy crawl Hellowork -o company_name.csv
とします。
今回、このサイトをスクレイピングして思ったのは、JavaScriptスキームも面倒でしたが、それ以上に面倒と思ったのは、タグにID名が振っていないと、スクレイピングしにくいわ!と心底思いました。
そのため、自分でサイトを作るときは、なるべくタグにはID名を振ろうと反省するのでありました。