hkのweblog

ひよっこエンジニアがにわとりになるまでの軌跡

Bingの検索結果をcsvで出力するPythonスクリプトを書いた

先日、とあるwebメディア制作の会社から、「地域名×キーワード」でGoogle検索結果上位50件をcsvにまとめてほしいという依頼を受けました。
その時書いたPythonスクリプトをメモがてら公開します。

Pythonを書くのは久しぶりで細かい改善の余地はありそうですが、とりあえずこれで用は足りるだろうという程度のものです。
苦戦したポイントがいくつかあったのであげておきます。

GoogleとYahooはスクレイピングに厳しかった

最初はGoogleの検索結果ページ用のものを書いていたのですが、しばらくやっているうちに大量リクエストを検知されたようでIP制限をかけられてしまいました。
こうなると検索毎にreCaptchaのチェックを求められるので為す術がありません。
リクエストの間にランダムで5~10秒スリープする処理を入れたりもしてみたのですが、あまり効果はありませんでした。

Yahooに至っては最初のリクエストから弾かれてしまいました。
ということで、Googleで検索しきれなかった分についてはBingで処理しました。
Bingは優しいのかな?よく分かりませんが、スクレイピング自体グレーな行為ではあるので利用は常識的範囲に留めておきたいものです。

全角チルダ波ダッシュ問題

出力したcsvExcelで開いたときに文字化けしないように、Shift JISで出力するのですが、Shift JISに置き換えられない文字が結構ありました。
こちらの記事は代表例ですが、他にも色々引っかかるみたいです。 qiita.com

L68,71あたりで諦めています。

URL長すぎ問題

特に日本語がURLエンコードされた場合に顕著ですが、Excelの1セルにURLが収まらないことがありました。
そこでbit.lyを使って長過ぎるURLは短縮する処理を入れています。
余談ですが、古めのMacExcelではHYPERLINKがうまく機能しない場合があるようです。こちらは良い解決策が見つけられませんでした。

最後に使ったスクリプトを載せます。

canvasで画像ファイルを読み込む

プロ野球シーズンは家でコードを書く時間が少なくなりがちです。うーん。
ということで1ヶ月半ぶりに記事を書きます。


先日、html5で導入されたcanvasを使った実装をしたので、そのコードを残しておこうと思います。
公式のドキュメントはこちら↓
Canvas API - HTML: HyperText Markup Language | MDN

canvasを使うのは初めてで、ユーザーがローカルの画像ファイルをアップロードする機能を実装したのですが、なかなか楽しかったです。
この記事では選択した画像を表示し、その上に別の画像をかぶせてみます。
Vue.jsで画像をあげる部分だけをコンポーネント化したので、そのコンポーネントをまるっと載せてみます。

<template>
    <div>
        <label>写真:</label><br>
        <canvas id="canvas" height="0"></canvas><br>
        <label>
            <span class="btn btn-primary btn-sm">
                画像を選択
                <input type="file" name="photo" v-on:change="drawCanvas" style="display:none">
            </span>
        </label>
        <div id="result"></div>
    </div>
</template>

<script>
export default {
    methods: {
        drawCanvas(e) {
            let fileData = e.target.files[0]
            if (!fileData.type.match('image.*')) {
                alert('画像を選択してください')
                return
            }
            let canvas = document.getElementById('canvas');
            let canvasWidth = 400;
            let canvasHeight = 300;
            canvas.width = canvasWidth;
            canvas.height = canvasHeight;
            let ctx = canvas.getContext('2d');

            let reader = new FileReader();
            let that = this;
            reader.onload = function() {
                let uploadImgSrc = reader.result;
                // canvas上に画像を重ねて表示
                let img = new Image();
                img.src = uploadImgSrc;
                img.onload = function() {
                    ctx.drawImage(img, 0, 0, canvasWidth, this.height * (canvasWidth / this.width));
                    // imgをloadした後にframe.jpgをloadして乗せる
                    let frame = new Image();
                    frame.src = "img/frame.jpg";
                    frame.onload = function() {
                        ctx.drawImage(frame, 0, 200, 400, 100);
                        // canvasを画像に変換
                        let imgSrc = canvas.toDataURL();
                        that.$store.commit('setImage', imgSrc)
                    }
                }
            }
            reader.readAsDataURL(fileData);
        }
    }
}
</script>

画像が選択されるとdrawCanvasが呼ばれます。
最初に選択されたファイルが画像かどうかチェックしています。本当はサイズ等々もう少し丁寧にバリデーションをかけるべきでしょうね。

次にcanvasのサイズ等基本的な設定をしていきます。
canvas要素はデフォで中途半端に高さを取ってしまうようです。
<canvas id="canvas" height="0"></canvas>のように書いておくと余計な隙間をなくすことができます。
getContextのところで2dを指定していますが、3次元の画像もできるようです。canvasを直接書くのではなく、three.js等のライブラリを使ったほうが良いようです。いつかやってみたい。

次にFileReaderオブジェクトを生成し、読み込んだ画像をImageオブジェクトにあてていきます。
ctx.drawImage(img, 0, 0, canvasWidth, this.height * (canvasWidth / this.width)); 左上を基点とし、右に0、下に0の位置から、幅がcanvasWidth、高さをthis.height * (canvasWidth / this.width)の画像を描画しています。
高さ300幅400の領域に幅固定で高さは縦横比を崩さずに画像を読み込んでいるということになります。
同様に上からかぶせる画像frame.jpgを読み込んでいます。ここではユーザーが読み込んだ画像の下部にバナーのような感じでframe.jpgが乗ることになります。
このように入れ子にしてやることで必ずframe.jpgが後から読み込まれて上に乗るようになります。

最後にreader.readAsDataURL(fileData);で画像を表示しています。
読み込むとこんな感じになります↓ f:id:h2r4t:20180706230431p:plain
なお、その上の2行ではcanvasに表示したものを1枚の画像URLに変換しています。
自分の場合はこれを別コンポーネントに渡して表示したり保存したりしたかったので、Vuexを使って渡しています。

以上、雑な実装ですが誰か読んだ方の参考になれば。

Vue.jsでsetTimeoutする時の注意点

月2更新目標のこのブログ、だいぶ更新をサボってしまいました…
書けなかった理由は色々あるんですが、ライトなネタで再開します。

というわけで今日はVue.jsネタ。
プライベートで作っているサービスですが、

  1. 入力フォームで確認ボタンを押す
  2. 確認モーダルが表示される
  3. モーダル上の投稿ボタンを押す
  4. 成功にせよ失敗にせよモーダル内にメッセージが表示される
  5. 3秒後にモーダルを閉じる
    ということをしようと思いました。 この3~5のところをsetTimeoutを使って実装しようとしたのがこんな感じ

Modal.vue

<template>
    <transition name="modal">
        <div class="overlay" @click="$emit('close')">
            <div class="panel" @click.stop>
                <b>投稿してよろしいですか?</b>
                <p>お名前:{{form.name}}</p>
                <p>メールアドレス:{{form.mailAddress}}</p>
                <button v-on:click="postArticle">投稿する</button>
                <button @click="$emit('close')">閉じる</button>
                <p>{{message}}</p>
            </div>
        </div>
    </transition>
</template>

<script>
export default {
    data() {
        return {
            message: ''
        }
    },
    // 親コンポーネントから渡してるform
    props: ['form'],
    methods: {
        postArticle:function() {
            let self = this.form;
            let article = {
                name: self.name,
                mailAddress: self.mailAddress
            };
            this.$http.post('/api/article/', article).then(response => {
                this.message = "登録が完了しました";
                setTimeout(this.closeModal(), 3000);
            }).catch(error => {
                //サーバーからjsonでエラーメッセージを投げる
                this.message = error.response.data.error;
                setTimeout(this.closeModal(), 3000);
            });
        },
        closeModal:function() {
            //親コンポーネント側でモーダルを消す処理
            this.$parent.showModal = false;
        }
    }
}
</script>

これがだめでした。投稿するボタンを押すと3秒待たずすぐにcloseModal()が実行されてしまうのです。
で、解決策はこれだけ

//これを
setTimeout(this.closeModal(), 3000);
//こうする
setTimeout(this.closeModal, 3000);

はい。それだけでした。
ほんとドキュメント読めよって感じですね… developer.mozilla.org setTimeoutって有名だけど普段フロントエンド触らないマン的にはあんまり書く機会ないよね、という言い訳…
あれ?これVue関係なくね…?と今更思いました。

以上です。

rsyncについてのメモ

仕事をしているとサーバーに入ってrsyncを叩く機会がちょこちょこあります。
rsyncは便利ですが、失敗すると痛い目を見ることになるコマンドの一つなので使い方を軽くメモしておこうと思いました。

基本的な使い方

rsync -av /home/test/ dst

こう書くと、testの下にあるファイルが全てdstの下に同期されます。testの後にある「/」が鍵です。
「/」を入れるとtestの下にあるファイルが同期の対象となり、「/」を外すとtest自体が同期の対象になります。

なお、「-av」とオプションが付いていますが、「-a」はほぼ必須と言っていいオプションのようです。
ディレクトリを再帰的にコピーしたり、権限や所有者、変更時刻等をコピーするそうです。
次に「-v」ですが、こちらは転送したファイルのバイト数等々の詳細情報を表示してくれるオプションです。

注意したい使い方

上記のやり方で用が足りる場合も多いのですが、もう2つほど重要なオプションについて書いておきます。
1つ目は「--delete」です。こんなふうに使います。

rsync -av --delete /home/test/ dst

「--delete」が加わると、test配下で削除されたファイルをdstでも削除してくれます。
私は先日リリースの際にこのオプションを使ったのですが、一歩間違うと全部消えてしまうしまあ怖いわけです
(大半のデプロイでは内製したツールを使っていますが、たまたま触ったリポジトリが非常に更新頻度の低いリポジトリで置き去りにされていました)

そこで予行演習のようなオプションがあります。それが「-n」です。
これを付けてrsyncを実行すると、実際に転送は行わずに転送内容の表示だけを行ってくれます。dry-runというやつです。

rsync -avn --delete /home/test/ dst

これを事前に試しておけば、どのファイルを転送して、どのファイルが消えて、というのを確認できるのです。
これで安心して--deleteオプションを付けて実行できそうです。

ここまで書かなかったオプション(--excludeや--existingなど)もありますが、これもいずれ使う機会があるといいなと思います。

スレッドが競合した時のこと

今日は備忘録。

先日の業務中、「複数のプロセスが同時に一つのメソッドを通ったことが原因でデータがおかしなことになる」というバグを発見しました。
単純な例を出すとこんな感じでしょうか。

2018年1月25日0時30分時点で太郎さんのM銀行の口座の残高はちょうど100万円です。
太郎さんはA社とB社の2社から給料をもらっており、給料日はいずれも25日です。
1月25日1時00分ちょうどにA社から50万円の給与が振り込まれました。
と、全く同じタイミングでB社から70万円の給与が振り込まれました。
太郎さんの口座の残高は100万+50万+70万=220万円となるはずです。
ところが、実際の残高は150万円となっています。
おかしいですね…

無論、銀行のシステムがこんなポンコツ実装なはずはないですが、「スレッドセーフではない」というのはこういうことです。
次に実際に目撃したバグの状況を抽象的に書いてみます。

サイトの会員がログインし、トップ画面に入ってきました。
トップ画面では、会員に紐づく情報を取得するために2つのリクエストが飛びます。仮に/getと/gainの2つとします。
/getにアクセスするとGetクラスのAメソッドを通り、その中でCalledクラスのMメソッドを呼びます。
/gainにアクセスするとGainクラスのBメソッドを通り、その中でCalledクラスのMメソッドを呼びます。
このMメソッドは、会員に紐づく1つのレコードをDBから取得して返します。ただし、まだ会員に紐づくレコードが存在しない場合、新しくレコードを生成してからそのレコードを返します。
今回、会員に紐づくレコードが存在しない状態で2つのリクエストがほぼ同時に飛び、Mメソッドが1つしか存在してはいけないはずのレコードを2つ作ってしまっていました。
2つのプロセスがMメソッドを数十ミリ秒以内の絶妙なタイミングで通過した場合のみ発生する稀なバグでした。
幸い実害はなかったのですが、本来1件のレコードを返すはずのメソッドが2件のレコードを返してくるせいでフレームワークが大量にwarnを出していました。

過去の履歴を見ると、GetクラスとCalledクラスが同時に誕生し、その後数年を経てからGainクラスが実装された結果、こんなことが起こったようです。
1人の会員につき1つのレコードしか持たないのだから、DBで会員番号のカラムにunique制約を付けておけばいいのですが、そもそも同時にリクエストが飛ぶことは想定していなかったようです。まあ流石に仕方ない気もします。

最初はMメソッドにsynchronizedを付与して排他制御にすれば同時にレコードを更新することは防げるのでは?と思いましたが、リクエストが分かれているせいで別々にインスタンスを作ってクエリを実行してしまうので意味がありません。

そこで、DBに残ってしまった重複データを削除してから会員番号のカラムにunique制約を付けて、Mメソッドの中で例外をcatchし、そこでもう一度Mメソッドを通るようにしようと考えました。
が、そもそも同じ画面で1つの情報を取るために2つのリクエストを投げる実装そのものがよろしくないのです。投げるリクエストを1つに絞るために実装を改めるということで決着しました。

改めてここまでの文章を読み返してみるとあっさり対策できたような感じに見えますが、原因箇所を具体的に書いてくれないフレームワークのwarnログと大量のアクセスログを突合し、原因箇所を見つけるまでにはかなりの時間がかかりました。
おまけに「複数スレッドが1つのメソッドを同時に通った場合」と言っても実際テストしてみると20回に1回くらいしか起こらないもので、再現するのになかなか苦労しました。
そういうわけで、いつか同じようなことに出くわしたときに思い出せるよう、備忘録を書いたのでした。

BeautifulSoupで東証一部上場企業の株価をスクレイピングしてみた

東証一部上場企業の従業員数、売上高等のデータを収集し、これらの指標と株価にはどの程度の相関があるのか調べるために単回帰してみる、ということをやってみようと思っています。
この記事ではその工程の一部である「東証一部上場企業のデータをスクレイピングで集める」部分を書いてみます。
私は統計の専門家ではないし、日常業務ではPythonを全く使っていません。変なところがあったらすいません。

環境

  • macOS High Sierra 10.13.2
  • Python 3.5(Anacondaでインストール)
  • pyenv+virtualenv使用
  • BeautifuSoup(4.6.0) こんなもんです。

大雑把な手順

  1. 証券コードが格納されているcsvファイルを用意する
  2. csvから証券コードを取得する
  3. htmlを取得する
  4. 必要なデータを取り出す
  5. 使用しやすいようにデータを加工する

証券コードが格納されているcsvファイルを用意する

これは技術でも何でもないのですが、
私は日本取引所グループのページから拝借しました。
Excelの機能で業種や取扱市場を絞り込んで必要な証券コードを抽出、csvに貼り付けます。
何だかんだ言ってもExcelって便利ですね。

csvから証券コードを取得する

先ほど取得したcsvをcode.csvとし、同じディレクトリ内でcollect_data.pyを作成します。

# -*- coding: utf-8 -*-

import csv

# 証券コードを読み込む
with open('code.csv', 'r', encoding='sjis') as f:
    reader = csv.reader(f)
    code_list = [row[0] for row in reader]

こんな感じでcode_listというリストに証券コードを格納することができます。
with openで書いていくとcloseし忘れる…といったことがないので便利ですね。
Excelで作成したcsvファイルは文字コードがShift-JISになるみたいなので、encoding='sjis'を指定しています。

htmlを取得する

さて、スクレイピングを始めていきます。
今回は日経新聞のサイトからデータを拝借しました。(規約的にNGだったらごめんなさい…)

import urllib.request

for code in code_list:
    url = "https://www.nikkei.com/nkd/company/gaiyo/?scode=" + code
    html = urllib.request.urlopen(url)
    soup = BeautifulSoup(html, "html.parser")

先ほど取得したcode_listから各証券コードを取り出し、URLを作ります。
このURLからhtmlを取得、HTMLパーサーで読みます。

必要なデータを取り出す

import re
from bs4 import BeautifulSoup 
comma_pattern = r'([0-9,--]+)'

# ここからは先程のfor文の中に書いています
    stock_price = re.findall(comma_pattern, soup.find("dd", class_="now").text)[0] #株価
    employee = re.findall(comma_pattern, soup.find("td", text=re.compile("\s人")).text)[0] #従業員数

soup.find('HTMLタグ名', 条件)という形で条件に当てはまる最初のHTMLを取得できます。
findの部分をfind_allにすると条件に当てはまる全てのHTMLをリストの形で取得できます。
classやidで一つに絞れると非常にやりやすいのですが、必ずしもそうはなっていなかったりします。まあスクレイピングしやすいようにサイトを作る人なんていないですよね…
そんな時は上記の従業員数の例のように絞ることもできます。
さらに正規表現を使ってデータを取っています。
ちなみにcomma_patternにハイフンやら記号が含まれているのは、持株会社等で従業員数が書かれていない会社が散見されたからです。
他にも様々な抽出手段があるようですが、詳しくはBeautifulSoupのドキュメントをご参照ください。

使用しやすいようにデータを加工する

ここまででも必要なデータは揃いますが、この後回帰分析を行うことを考えて、数値データからカンマを取り除いておきます。

# ここからは先程のfor文の中に書いています
    stock_price = re.findall(comma_pattern, soup.find("dd", class_="now").text)[0].replace(",","") #株価
    employee = re.findall(comma_pattern, soup.find("td", text=re.compile("\s人")).text)[0].replace(",","") #従業員数

所感

いざやってみるとデータを集めづらいサイトが多いです…
BeautifulSoupだけで完結せずに正規表現も使わざるを得ない場合も多そうです。
とはいえ、これだけのコード量でデータを収集できてしまうのは素晴らしいなと思います。
気が向いたら回帰分析についても書いてみようと思います。

コメントアウトに惑わされるな

結局今月も1回しか記事を書いていないことに気付き、実家で記事を書く大晦日です。
今日はほんと小ネタですが一応。

しばらく前、仕事で古めのページのjspを書き換えていた時、コメントアウトに翻弄されて1時間ほど作業が止まったことがありました。

ハマっていたのはこれです。

<html>
    <head>
        <title>test page</title>
        <script>
            <!--
                (function() {
                    console.log("非コメントアウト");
                })();
                //-->
            var nextYear = 2018;
            (function() {
                    console.log(nextYear + "年まであと少し!");
                })();
        </script>
    </head>
    <body>
        <p>test</p>
    </body>
</html>

ちょっとこの画面では分かりづらいのですが、普段私が業務で使っているEclipseでこのソースを開くと、scriptタグ内の<!--//-->に囲まれた部分が全て薄いブルーになります。
私は<!--//-->に囲まれた部分は実行されないだろうと思って読み飛ばしていたのです。
が、このhtmlを開くとコンソールはこうなります。

非コメントアウト
2018年まであと少し!

つまり一見コメントアウトされているように見える部分も実行されるのです。
これはjavascriptが無効にされているブラウザへの対応だそうで、<!--//-->に囲まれた部分が画面に表示されてしまうことを防ぐためのコメントアウトだそうです。

このことを知らなかった私は、約1200行の所々にscriptタグが挿入され、その一部がコメントアウトされていることを確認し、「これがコメントアウトされているのになんでこの挙動になるんだろう…?どこか他の箇所でjsを読み込んでいる?それともこれはサーバーサイドで実装している動き…?」などと1時間ほど悩みました。

今時javascriptを無効にしているユーザーはあまりいないように思いますが、古くから残っているソースコードにはこういう書き方が多く残っているのかもしれませんね。

今年はずいぶん初歩的なことばかり書いてきましたが、来年はもっと高度なブログも書いていきたいと思います。では良いお年を。