構造化データ(JSON-LD)が埋め込まれていれば楽勝だろう、と思って着手した求人サイトスクレイピング。結果として3回ハマって、最後は JSON.parse を捨てました。同じ罠を踏みそうな方の参考に、具体的なコード例と判断の流れを共有します。
環境
- 実行環境: Google Apps Script(V8 ランタイム)
- 取得先: 某求人サイト(WordPress + SEO系プラグインで JSON-LD を出力)
- 使用ライブラリ:
UrlFetchApp, 標準のJSON.parse, 正規表現 - 出力先: Google Sheets
前提:なぜ JSON-LD 前提で組んだか
求人ページは JobPosting スキーマの JSON-LD を出力していました。<script type="application/ld+json"> の中に JobPosting 情報が入っており、タイトル・勤務地・雇用形態・職種カテゴリが機械可読な形で揃う、はずでした。
const html = UrlFetchApp.fetch(url).getContentText();
const match = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
const json = JSON.parse(match[1]); // ← ここが3回壊れる
ハマり①:JSON-LD 内に JavaScriptコメントが混入
プラグインが吐き出す JSON-LD に、なぜか // ... という行コメントが混じっていました。当然 JSON.parse は即死。最初の対処は単純なコメント除去。
const cleaned = match[1]
.replace(/\/\/[^\n]*\n/g, "\n") // 行コメント除去
.replace(/\/\*[\s\S]*?\*\//g, ""); // ブロックコメント除去
const json = JSON.parse(cleaned);
これで直った、と思いきや次のサイトで別のエラーが出ました。
ハマり②:制御文字+処理順序の罠
次に出たのは Unexpected token in JSON at position ...。調査すると、JSON の文字列値の中にタブや制御文字(\u0000-\u001F のうち改行以外)が混入していました。
安易に制御文字もまとめて空文字へ置換したところ、今度は改行まで消えて①のコメント除去が壊れる二次被害が発生。行コメントは「// から改行まで」で定義されるため、改行を先に消してはいけません。
const cleaned = match[1]
.replace(/\/\/[^\n]*\n/g, "\n") // 1. 行コメントを先に処理(改行を温存)
.replace(/\/\*[\s\S]*?\*\//g, "") // 2. ブロックコメント
.replace(/[\u0000-\u0008\u000B-\u001F]/g, ""); // 3. 改行・タブを除く制御文字のみ除去
教訓:サニタイズは「何を残すか」を先に定義しないと、下流の処理を壊します。
ハマり③:結局 JSON.parse を諦めた
2回目までで動いたように見えたものの、新しいサイトを追加するたびに別のパース不能ケースが現れます。JSON の形式的妥当性に依存する限り、プラグインの実装差分に延々振り回されると判断し、JSONをパースせず、必要なフィールドだけ正規表現で直接抜く方式に切り替えました。
// 例:タイトルだけ抜きたい
const titleMatch = match[1].match(/"title"\s*:\s*"((?:[^"\\]|\\.)*)"/);
const title = titleMatch ? JSON.parse(`"${titleMatch[1]}"`) : null; // エスケープ復元
- パース失敗でスクリプト全体が落ちるリスクが消える
- 欲しいフィールドだけピンポイント取得で、構造差分に強くなる
- 「JSON全体の正しさ」を前提にしなくて済む
この方針に切り替えてから、追加サイトで同じ種類のエラーは再発していません。
罠その4:jobLocation は本社所在地
ここからは「JSONが読めた後」の意味論の罠です。JSON-LD の jobLocation.address は求人の勤務地…ではなく、運営会社の本社所在地でした。正しい勤務地は HTML の「勤務地」セクションに別記されていたケースです。
同じ「住所」でも、データソースによって意味が違う。構造化データが出ている = 正しいマッピングが取れる、ではないことを痛感しました。
// BAD:JSON-LD だけ信じる
const workLocation = json.jobLocation.address.streetAddress; // 本社住所が入る
// GOOD:HTML側の明示セクションを優先、JSON-LDはフォールバック
const workLocation =
extractFromHtml(html, "勤務地") ||
json.jobLocation?.address?.streetAddress;
罠その5:CSSクラス名の部分一致
地味ですが怖いのがこれ。職種を取るため entry-meta-data-list--job を引こうとしたら、先に entry-meta-data-list--jobstyle(雇用形態)にマッチして「営業」のはずが「正社員」になる事故。
// BAD:部分一致
const jobCat = html.match(/entry-meta-data-list--job[^"]*">([^<]+)</)?.[1];
// GOOD:境界を明示
const jobCat = html.match(/entry-meta-data-list--job"[^>]*>([^<]+)</)?.[1];
--job は --jobstyle の prefix でもあるため、境界文字(" や空白)を明示しないと部分一致で持っていかれます。
FAQ
Q. Cheerio など DOM パーサを使えば済む話では?
A. GAS 単体では DOM パーサが標準装備されていません。外部ライブラリも制限があり、今回は依存追加せず正規表現だけで回す方針でした。DOM 操作が複雑化するなら Node 移行を検討する段階と考えています。
Q. そもそもスクレイピングせず API を使えない?
A. 使えれば最優先。ただし「公開 API がない」「利用規約で自動取得が明示禁止されていない」前提で、取得元に負荷をかけない頻度に絞って運用しています。
まとめ
- 構造化データを信じすぎない:プラグイン実装によって品質はまちまち
- サニタイズは処理順序で壊れる:残すもの・消すものを先に決める
- JSON.parse を諦める選択肢を持つ:フィールドだけ正規表現で抜く方が堅牢な場面がある
- 意味論の罠:同じラベルでもデータソースで意味が違う(住所など)
- CSSクラス部分一致に注意:境界を明示する
結局、教科書通りの「構造化データ → JSON.parse → 属性取得」という流れが、現実の Web では 3 回中 3 回は通らないと思っておくくらいがちょうどよかったです。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 業務自動化サービス — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中