1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GAS × JSON-LDスクレイピングで「動くけど正しくない」を3回繰り返した話

1
Posted at

構造化データ(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) — 日々の知見を発信中

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?