Blow Up by Black Swan

Python-フリーランス案件を探すためにクラウドワークスをスクレイピング

[2019/7/15追記: コード修正]
[2019/7/18追記: コード修正2]

実は、ココナラでフリーランス案件を探すサービスを出品していたのですが(こちら)、この案件はいいねはたくさんついているくせに、今まで販売はおろか一度も問い合わせさえ来たことがありませんでした。基本線はクラウドワークスやランサーズなどのクラウドソーシングサイトからスクレイピングするだけですが、それを依頼者の要望に合わせてGCPやGASなどと連携させることを考えていました。しかし、特にプロモーションをしてきたわけではなかったのですが、出品して数ヶ月が経ち、こんな状況なのでブログのネタにすることにしました。

このスクレイピングプログラムは自分ではずっと利用してきたのですが、今回の記事化を期にリファクタリングをしたところ、seleniumを使う必要がなくなり、コードも簡潔にすることができ、かなりすっとしたプログラムにすることができました(ログインしてアクセスする場合はseleniumの利用は必要になりますが、今回はログインせず、公開データのみを取得するプログラムになります)。

なお、この記事ではサイトからHTMLデータをダウンロードして取得することをクローリング、このHTMLデータから必要となるデータを取得することをスクレイピングと明確に区別しています。ただ、クローリングとスクレイピングを合わせてスクレイピングと読んでいる場面も無きにしも非ずですので、そちらは雰囲気で読み取って頂ければと思います。笑 この記事がどなたかの参考になれば幸いです。

1. 実行環境、フローなど

今回のプログラムの前提となる環境等についてここでまとめています。

1-1.実行環境

今回の実行環境は以下になります。

  • OS: MAC OSX
  • Python: 3.7.3
  • Pythonモジュール
    • requests: 2.22.0
    • bs4(BeautifulSoup): 0.0.1
    • lxml: 4.3.4

またファイル構成は以下になります。

project
├── config
│   └── config.py
├── crowdworks.py
├── functions.py
├── search_words.py
└── venv

それぞれのフォルダやファイルですが、頻繁には変更しない設定項目はconfigディレクトリのconfig.pyに記載し、function.pyは補助用の関数を格納し、search_words.pyが検索用語を設定するためのファイルです。そして仮想環境はvenvで構築し、実行ファイルはcrowdworks.pyです。今後、他のクラウドソーシングサイトからもスクレイピングするプログラムを追加する場合に備えて、あえて拡張性高いファイル構造にしています。今回のプログラムはPycharmを利用しているので、自動で仮想環境が構築されますが、Pycharmを使わずとも簡単に仮想環境を構築できます。インターネット上にたくさん構築方法が載っていますので、検索してみてください。

1-2. 実行フロー

実行フローは次の流れになります。

  1. robots.txtの解析
  2. クローリング
  3. スクレイピング
  4. csvに保存

いたってシンプルな流れですが、クローラーが遵守すべきrobots.txtの解析は独自にプログラムしています。robots.txtの解析については以前、記事にしていますので参考にして頂ければと思います。

また、クローリングやスクレイピングの注意事項については下記記事が非常にわかりやすいので、興味のある方はそちらも参照下さい。

1-3. 実行プログラムの前提

今回のプログラムは以下を前提としています。

  • 会員としてログインせず、非会員も見れる公開データのみを取得
  • 検索ワードにヒットした案件のみを抽出し、カテゴリ単位での検索や詳細検索への対応は行わない
  • ヒット件数が多く複数ページある場合でも、最初の1ページ目の案件だけを抽出(新着順の最大50件が取得できる)
  • 発注者のURLを指定することで、一部の発注者を除外できる
  • 結果はデスクトップにCSVファイルで出力される(results.csv)

2. プログラム

それでは、実際のプログラムについてです。

2-1. 補完ファイル(config.py、functions.py、search_words.py)

まずは実行プログラムで利用されるファイルの内容についてです。config/config.pyファイルは、基本的に一度設定した場合、その後変更しないような設定項目や秘匿性の高い設定項目を記述するためのファイルです。今回のファイル内容は下記になります。

・config/config.py

[2019/7/16追記: 変数名変更]

# ドメイン
cw_domain = "https://crowdworks.jp"

# デフォルトのクロール間隔
defalt_crawl_delay = 1

# ユーザーエージェント
user_agent = "Crawler/1.0.0"

# 除外するサプライヤー(ユーザー名で可)
cw_except_users = []

次は、functions.pyですが、こちらは実行プログラムに対して補助的な役割を果たす関数を格納するためのファイルです。クローリングするサイトが増えた場合は共通するプログラムを抽象化した関数を保存することも想定しています。今回は、robots.txtを解析するための関数だけが保存されています。

・functions.py

import os
import urllib.robotparser


# robot.txtをパースする
def parse_robots(domain):
    robots_url = os.path.join(domain, 'robots.txt')
    rp = urllib.robotparser.RobotFileParser()
    rp.set_url(robots_url)
    rp.read()
    return rp

検索ワードはsearch_words.pyに記述します。こちらはその時々の状況に合わせて変更することがあると思うので、別ファイルにすることで他のプログラムへの影響をなくすことを意図しています。テキストやcsvファイルなど、テキスト性の高いファイル形式でもよかったのですが、解析するためのプログラムを増やすとコードがどんどん煩雑になってしまう可能性があるため、Pythonファイルにしました。

・search_words.py

# 記述ルール
# 1. 検索したい語句はシングルクオーテーション(')か、ダブルクオーテーション(")で囲む
# 2. アンド検索をする場合は各語句をプラス記号(+)でつなぐ
#
# 記載例
# search_word = ["スクレイピング", "wordpress+SEO"]

search_words = ["スクレイピング", 'Python']

2-2. 実行ファイル(crowdworks.py)

最後は実行ファイルです。実行フローに沿ってコードを書いていますが、実行コードが冗長にならないよう、クローリング、スクレイピングはそれぞれ別の関数にしています。今後、拡張性や抽象生を高める必要があれば、クラス設計なども考えたいと思いますが、今回はシンプルなファイルですので、関数で自走しています。

・crowdworks.py

[2019/7/15追記: config.pyで変更した変数名を反映]
[2019/7/18追記: 取得データに報酬タイプ(fee_type)を追加]

import re
import time

from bs4 import BeautifulSoup as bs
import requests

from config import config
from functions import parse_robots


# 実行プログラム
def crowdworks(search_words):
    print("Start scraping for CrowdWorks...")

    # 1) robots.txtの確認
    domain = config.cw_domain
    robot_parser = parse_robots(domain)
    crawl_delay = robot_parser.crawl_delay("*")
    if not crawl_delay:
        crawl_delay = config.defalt_crawl_delay

    # ヘッダー設定
    result = [{"service": "検索サービス", "word": "検索ワード", "id": "ID", "job_title": "案件名", "url": "URL",
           "fee_type": "報酬体系", "fee": "報酬", "start": "掲載日", "end": "募集終了日"}]

    # 2) 検索ワードごとにクローリングとスクレイピング
    for word in search_words:
        print("Start searching for '{}' in CrowdWorks...".format(word))

        # クロール間隔の設定
        time.sleep(crawl_delay)

        # クローリングの実行 -> クローリング関数へ
        html = crawling(word, robot_parser)

        # クロールできなかった場合(html=はNone)        if not html:
            result.append({"service": "CrowWorks", "word": word, "id": "",
                            "job_title": '「{}」に該当する案件はありませんでした。'.format(word), "url": "",
                            "fee_type": "", "fee": "", "start": "", "end": ""})
            continue
        # スクレイピング -> スクレイピング関数へ
        result += scraping(html, word)
    return result


# クローリングするプログラム
def crawling(word, robot_parser):
    headers = {'User-Agent': config.user_agent, "Accept-Language": "ja,en-US;q=0.9,en;q=0.8"}
    search_url = "https://crowdworks.jp/public/jobs/search?search%5Bkeywords%5D={}&keep_search_criteria=false&" \
                 "order=new&hide_expired=true".format(word)

    # 1)robots.txtのチェック
    if not robot_parser.can_fetch('*', search_url):  # falseの場合
        return None
    res = requests.get(search_url, headers=headers)

    # 2)ステータスコードチェック
    if str(res.status_code) != "200":
        return None

    return res.text


# スクレイピングするプログラム
def scraping(html, word):
    result = []

    # 1) 検索結果が存在しない場合
    soup = bs(html, 'lxml')
    if soup.find_all('div', class_="nodata"):
        result.append({"service": "CrowdWorks", "word": word, "id": "",
                        "job_title": '「{}」に該当する案件はありませんでした。'.format(word), "url": "",
                        "fee_type": "", "fee": "", "start": "", "end": ""})
        return result

    # 2) 各要素の検索
    num = 0
    for elem in soup.select('.search_results .jobs_lists > li'):

        # 2-1) ユーザーチェック
        user_url = elem.find('span', class_='user-name').a["href"]
        if user_url in config.cw_except_users:
            continue

        # 2-2) 該当する案件情報の取得
        num += 1
        job_title = elem.find('h3', class_="item_title").a.text  # ジョブタイトル
        job_url = config.cw_domain + elem.find('h3', class_="item_title").a['href']
        fee_type = elem.find(class_="payment").find(class_="payment_label").text
        fee = ''.join(elem.find(class_="payment").find(class_="amount").text.split())
        start = elem.find(class_="post_date").span.next_sibling.strip()
        end = re.findall(r'.*((.*)まで)', elem.find('span', class_="absolute_date").text)[0]
        result.append({"service": "CrowdWorks", "word": word, "id": num, "job_title": job_title, "url": job_url,
                        "fee_type": fee_type, "fee": fee, "start": start, "end": end})
        print('id {}'.format(num))
    if not result:
        result.append({"service": "CrowdWorks", "word": word, "id": "",
                        "job_title": '「{}」に該当する案件はありませんでした。'.format(word), "url": "",
                        "fee_type": "", "fee": "", "start": "", "end": ""})
    return result


# 実行用のバッチ
if __name__ == '__main__':
    import csv
    import os
    from search_words import search_words

    s = time.time()
    result = crowdworks(search_words)
    with open(os.path.join(os.environ['HOME'], 'Desktop/results.csv'), 'w') as csvfile:
        fieldnames = list(result[0].keys())
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writerows(result)
    print("Total time: {}".format(time.time()-s))

2-3. プログラムの実行

最後にプログラムの実行です。ターミナルやIDEなどから実行してみてください。デスクトップにresults.csvというファイルが作成され、そこに案件が書き込まれていると思います。

3. 最後に

以上がクラウドワークスから案件を抽出するスクレイピングするプログラムです。検索用語は複数設定できるので、設定してプログラムを実行してしまえば、自動で案件を拾ってきてくれます。また、今回は手動で実行していますが、これを応用していき、シェルスクリプトを使ってターミナル上で1コマンドで実行する方法やGASとGCPのCloud Functionsを利用して、毎日定時に自動で実行され、スプレッドシートに反映される方法などについても記事にしていきたいと考えています。

読んで頂き、ありがとうございました。当記事が参考になりましたら、幸いです。