リゾートバイト求人を4社から集めて横断検索を作るまで|構造化データと2つの罠
resort sort(リゾートソート):リゾートバイトの求人を、派遣会社をまたいで条件で絞り込めるWebアプリです。時給・寮タイプ・食事・通勤条件で横断検索でき、各社の公式求人ページへ直接遷移します。現在3社・6,704件を掲載しています。
https://sort.fumiproject.dev/resort/
作るにあたって4社から14,328件の求人を取得し、共通のスキーマに正規化しました。この記事は、その過程で分かったことの記録です。
結論として、こうなりました
- 求人サイトの大半は JobPosting の JSON-LD を公開している。14,328件中14,314件(99.9%)に入っていた。正規表現でHTMLを漁る必要はほぼ無い
- ただし 素朴な文字列マッチは2種類の罠で壊れる。最初の実装では寮タイプが全件「個室」になった
- 求人ページは施設名を伏せていることが多いが、構造化データには郵便番号と番地が入っている。これで会社をまたいだ同一施設の突き合わせができた
- ただし突き合わせられたのは 1,154住所のうち9件(0.8%) だけだった。この機能は主役にできなかった
求人サイトは構造化データを公開している
最初はHTMLを正規表現で解析するつもりでした。実際、最初に開いた求人ページはJavaScriptで中身を描画していて、取得したHTMLには時給も勤務地も入っていませんでした。
ところが別の求人ページを開いたら、application/ld+json に JobPosting が丸ごと入っていました。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 には postalCode と streetAddress が入っていました。表示されていない情報が構造化データには入っている、という状態です。そこで郵便番号と番地を結合キーにしました。
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/
求人を時給順で比較する