リゾートソート > 記事

リゾートバイト求人を4社から集めて横断検索を作るまで|構造化データと2つの罠

リゾートソート(ZAX)/ 求人データの実測にもとづく記事です

※この記事は執筆時点のデータセット(14,328件)にもとづく調査記録です。 数字をあとから書き換えると調査の記録として成立しなくなるため、本文はそのままにしています。 現在の集計値は調査データのページでご確認ください。

resort sort(リゾートソート):リゾートバイトの求人を、派遣会社をまたいで条件で絞り込めるWebアプリです。時給・寮タイプ・食事・通勤条件で横断検索でき、各社の公式求人ページへ直接遷移します。現在3社・6,704件を掲載しています。

https://sort.fumiproject.dev/resort/

作るにあたって4社から14,328件の求人を取得し、共通のスキーマに正規化しました。この記事は、その過程で分かったことの記録です。

結論として、こうなりました

求人サイトは構造化データを公開している

最初はHTMLを正規表現で解析するつもりでした。実際、最初に開いた求人ページはJavaScriptで中身を描画していて、取得したHTMLには時給も勤務地も入っていませんでした。

ところが別の求人ページを開いたら、application/ld+jsonJobPosting が丸ごと入っていました。Googleしごと検索に載せるために、各社が機械可読な形で公開しているものです。


def job_posting(html: str) -> dict | None:
    """HTML内のJSON-LDから JobPosting を1つ取り出す。
    @graph や配列でネストされている場合があるのでスタックで探索する。"""
    for s in re.findall(r'<script[^>]*application/ld\+json[^>]*>(.*?)</script>', html, re.S):
        try:
            d = json.loads(s.strip())
        except Exception:
            continue
        stack = [d]
        while stack:
            x = stack.pop()
            if isinstance(x, list):
                stack.extend(x)
            elif isinstance(x, dict):
                if x.get("@type") == "JobPosting":
                    return x
                stack.extend(x.values())
    return None

JSON-LDは配列だったり @graph の下にネストされていたりと構造が会社ごとに違うので、決め打ちで辿らずスタックで全探索しています。

取れる中身はこうでした。


{
  "@type": "JobPosting",
  "title": "調理",
  "baseSalary": {"@type": "MonetaryAmount", "currency": "JPY",
    "value": {"@type": "QuantitativeValue", "value": 1600, "unitText": "HOUR"}},
  "jobLocation": {"@type": "Place", "address": {"@type": "PostalAddress",
    "addressRegion": "栃木県", "addressLocality": "那須郡那須町",
    "postalCode": "325-0302", "streetAddress": "高久丙..."}},
  "datePosted": "2026-08-25T13:17:59.000+09:00",
  "validThrough": null,
  "employmentType": "TEMPORARY"
}

職種、時給とその単位、都道府県、市区町村、郵便番号、番地、掲載日、掲載期限。必要なものがほぼ揃っています。単位が HOUR / MONTH で明示されているので、時給と月給の取り違えも起きません。

ここで一度、判断を間違えました。ある1社について「JSON-LDが無い」と結論して、その会社だけHTMLのラベル解析を書いたのですが、後で30件調べたら28件にJSON-LDが入っていました。最初に開いた1件がたまたま例外だっただけでした。1件見て構造を決めてはいけない、という当たり前の話です。

罠①:選択肢の説明が全求人に並んでいる

寮が完全個室か相部屋かは、このサービスの中心的な比較軸です。最初はこう書きました。


if "個室" in html:
    dorm = "完全個室"

結果、12,900件すべてが「完全個室」になりました。

原因はページの作りでした。ある会社は寮タイプの欄に、選択肢の説明を3種類とも並べていたのです。

寮タイプ 相部屋 複数人で1部屋を共有します / 個室 トイレ・お風呂が部屋の外で共用となります。 / 1ルーム個室 部屋内にトイレ・お風呂完備の1ルーム個室寮です。

「相部屋」の求人にも「個室」の文字列が必ず含まれます。ページ全体に対する in は、この構造の前では意味を持ちません。

対処としては、実際に選択されている値だけを取るようにしました。この会社ではツールチップのトリガー要素に入っていたので、そこを抜き出しています。


# 寮タイプの欄(td)の中から、ツールチップのトリガーになっている
# 実際の選択値だけを拾う。説明文(tip_body)は無視する。
triggers = []
pos = 0
while True:
    s = td_content.find("tip_trigger_text", pos)
    if s == -1:
        break
    tag_end = td_content.find(">", s)
    close = td_content.find("</span>", tag_end)
    triggers.append(td_content[tag_end + 1:close].strip())
    pos = close + 7

罠②:選択肢マスタがHTMLに埋まっている

別の会社では、募集終了の判定で同じことが起きました。


is_closed = "募集終了" in html   # 全件 True になる

HTMLの中に、管理画面の選択肢マスタがそのまま埋まっていたためです。


"Option_P_Id":"10254","Option_P_Name":"🟪募集終了(掲載中)"
"Option_P_Id":"41256","Option_P_Name":"⬛募集終了(非公開)※基本使用なし"

これは「この求人が募集終了である」という意味ではなく、単に選択肢の定義です。文字列の存在だけで判定すると全件終了になります。

結局、募集終了はJSON-LDの validThrough で判定するのが最も確実でした。日付なので曖昧さがありません。


def is_closed(j, text, agency):
    """募集終了か。判断できなければ None を返す。不明を「募集中」に丸めない。"""
    if j:
        vt = j.get("validThrough")
        if isinstance(vt, str) and vt.strip():
            try:
                return datetime.fromisoformat(vt.replace("Z", "+00:00")).date() < date.today()
            except ValueError:
                pass
    if agency == "Wakutori":
        # 「募集終了(掲載中)」等は選択肢マスタとして全ページに存在する。
        # マスタ定義(Option_P_Name の中)以外の場所に出た時だけ終了と見なす。
        stripped = re.sub(r"Option_P_Name[^,]{0,60}", " ", text)
        return "募集終了" in stripped
    ...

判定できないときに False(募集中)ではなく None(不明)を返しているのは、不明を募集中に丸めると、終了済みの求人をそのまま掲載することになるからです。

この判定を入れた結果、取得した14,328件のうち3,075件(21.5%)が募集終了済みでした。サイトマップには終了した求人のURLが長く残るので、これを弾かないとサイトの2割が死んだリンクになります。

施設名は伏せられているが、住所は公開されている

リゾートバイトの求人ページには、勤務先の施設名が書かれていないことがほとんどです。「千葉県 白浜」「沖縄県 石垣島」のように地名までしか出ません。派遣会社にとっては、施設名を出すと直接応募されてしまうので当然です。

これは横断比較にとっては困ります。「同じホテルなのに会社によって時給が違う」を出したくても、同じホテルだと判定できません。

ところが、JSON-LDの PostalAddress には postalCodestreetAddress が入っていました。表示されていない情報が構造化データには入っている、という状態です。そこで郵便番号と番地を結合キーにしました。


key = None
if p.get("postal") and p.get("street"):
    key = re.sub(r"\s+", "", f"{p['postal']}{p['street']}")

結果はこうでした。

件数
住所キーが取れた求人3,288件 / 1,154住所
複数社が同じ住所に出していた施設9件

9件では機能になりません。同一施設の会社間比較は、主役から降ろしました。

取れなかった理由は2つです。住所が取れるのが全体の44%しかないこと(市区町村までしか出さない会社がある)、そして各社の住所表記が揃っていないことです。完全一致でしか突き合わせていないので、表記が少し違えば別施設として扱われます。

ただ、一致した9件では差が実在しました。

勤務地A社B社
北海道 虻田郡留寿都村2,000円1,600円400円
北海道 勇払郡占冠村1,700円1,400円300円
静岡県 下田市1,500円1,450円50円

時給400円の差は、月176時間の勤務で70,400円です。仮説は正しくて、カバレッジが足りませんでした。

想定される指摘について

スクレイピングして良いのか。 取得前にrobots.txtと利用規約を確認しました。対象各社のrobots.txtは求人ページを Disallow しておらず、うち1社は求人URLを列挙したサイトマップ(1,000件以上)を自ら公開していました。クローラー向けの Crawl-delay: 5 が指定されていたのでそれに従い、取得は事実(時給・勤務地・期間・寮・食事)のみに限って、求人票の説明文はコピーしていません。説明文は著作物になり得るので、構造化データの description も保存していません。

なぜ求人ごとの詳細ページを作らないのか。 JobPostingの構造化データを自分のサイトに実装すればGoogleしごと検索に載る可能性がありますが、そのためには求人1件ごとの詳細ページが必要になります。6,704枚の薄いページを作ることになり、しかも元の求人ページと同じ内容で競合します。2026年3月のコアアップデートで「大規模なコンテンツの不正利用」が制裁対象として明示されたこともあり、この方向は取りませんでした。代わりに、元サイトが持ち得ない「複数社をまたいだ集計」を都道府県別に出しています。

取れなかったものもあります。 寮タイプは、最初の実装では取得率40%でした。会社ごとのHTML構造に合わせて書き直して96.5%まで上げましたが、残りは判定できていません。判定できないものは推測で埋めず null のままにしています。埋めた瞬間、また全件「個室」に戻るからです。寮費・最低保証時間も、求人ページに記載がないことが多く取れていません。

最初の判断に戻ると

「JavaScriptで描画されているから機械では取れない」という最初の見立ては間違いでした。取れないどころか、各社は検索エンジンのために構造化データを整えて公開していました。

同じように、「1件見てJSON-LDが無かったから、この会社は無い」も間違いでした。求人サイトを相手にするときは、まずJSON-LDを疑う。そして1件で結論を出さない。この2つだけでも、最初からやっていれば数日は縮まったと思います。

コードは resort sort の中で動いています。実測データ(時給の分布、募集終了率、会社間の時給差)は下にまとめました。

https://sort.fumiproject.dev/resort/data/

求人を時給順で比較する