0
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?

求人一覧(MT Data API+静的JSON)フル手順と覚え書き

Last updated at Posted at 2025-09-22

求人一覧(MT Data API+静的JSON)実装フル手順と覚え書き

これは「まずは静的JSONで確実に表示 → Data APIに切替」の二段構えで、Movable Type(以下 MT)のコンテンツタイプ「求人詳細」を絞り込み検索・一覧化する実装の手順と仕組み、コードの読み方、運用までまとめたものです。なぜ2段になったかというと、Data APIの401エラーが続き、静的にJSONを作ってから移行をしたからです。


0. ゴール(完成像)

  • 画面のチェックボックス(勤務先/職種/都道府県/雇用形態)で絞り込める求人一覧ページ。
  • 段階1:静的JSON(MTテンプレートが吐き出す <script type="application/json">)を読み込み、フロントでフィルタリング。
  • 段階2:Data API(サーバ側PHPのプロキシ /api/jobs2.php 経由)に切替。Lucene構文で IDベース の複合検索。
  • ページング、デバッグ出力、例外/エラー時のユーザ向けメッセージ。
  • 認証トークンは フロントに置かない。サーバ側でキャッシュ&隠蔽。

1. 全体構成(アーキテクチャ)

[MT8 (公開側テンプレート)]  ──>  静的HTML + JSON を出力(段階1)
         │
         └─> Data API へは **直接呼ばない**(認証情報を隠すため)

[Webサーバ /api/jobs2.php]   ──>  MT Data API v6 を代理呼び出し
                                 (認証/再試行/フォールバック/正規化を担当)
  • フロント(静的HTML):UI・描画・静的JSONでの検索。USE_API=false で起動。
  • フロント(APIモード)USE_API=truejobs2.php を叩く。Lucene query を組み立て。
  • サーバ(jobs2.php):認証・API呼び出し・レスポンス正規化・診断(?diag=)を提供。

2. 前提と準備

  • MT v6 Data API が有効。
  • コンテンツタイプ:求人詳細(CT_ID=3)
  • カテゴリーセット:施設名(field:33)/職種(34)/都道府県(35)/雇用形態(16)
  • タイトル(13)、キャッチ文(14)、仕事内容(15)、給与(18)等のフィールドを保有。
  • 公開側テンプレートの再構築が実行できること(静的JSONが更新される)。
  • Webサーバで PHP が動作し、.htaccessSetEnv が使えること。

フィールドIDの確認:管理画面 → コンテンツタイプの各フィールド編集時のURL末尾や API 診断 (jobs2.php?diag=ct) で確認可能。後述「9. 診断&デバッグ」を参照。


3. フロント(HTMLテンプレート)の要点

3.1 <mt:SetVars> で可変値をひとまとめ

  • site_id / api_base / ct_id など、環境で変わる値をMTタグでセット。
  • JS から <$mt:GetVar$> で参照できるため、テンプレートのポータビリティが上がる。

3.2 絞り込みUI(CategorySets → Categories)

<mt:CategorySets name="施設名">
  <mt:Categories include_subcategories="1">
    <label>
      <input type="checkbox" name="facility" value="<$mt:CategoryID$>" data-label="<$mt:CategoryLabel encode_html='1'$>">
      <$mt:CategoryLabel encode_html="1"$>
    </label>
  </mt:Categories>
</mt:CategorySets>
  • value は CategoryID(= Lucene 検索で投げる ID)。
  • data-label は表示ラベル(静的JSONフィルタ時の文字照合に使用)。
  • 職種/都道府県/雇用形態も同様のパターン。

3.3 静的JSONの埋め込み

<script type="application/json" id="jobData">
[
<mt:Contents content_type="求人詳細">
{
  "id": <$mt:ContentID$>,
  "title": "<mt:ContentField content_field='タイトル'><$mt:ContentFieldValue language='ja' encode_json='1'$></mt:ContentField>",
  "date": "<$mt:ContentCreatedDate format='%Y-%m-%d'$>",

  "施設名": "<mt:ContentField content_field='施設名'><$mt:CategoryLabel encode_json='1'$></mt:ContentField>",
  "施設名_ids": [<mt:ContentField content_field='施設名'><$mt:CategoryID$></mt:ContentField>],
  ...
}
<mt:Unless name="__last__">,</mt:Unless>
</mt:Contents>
]
</script>
  • JSON 文字列には encode_json='1' を使用(" のエスケープなどを自動処理)。
  • カテゴリは ラベル(文字列)ID配列 の両方を出力しておくと後工程が楽。
  • この静的JSONは 検索インデックス不要認証不要高速に表示 できる「安全な初期形」。

3.4 JS:2モードをトグルする設計

var USE_API = false; // ← 検索インデックス構築が済むまでは false のまま
  • false:静的JSONを JSON.parse → フィルタは ラベル部分一致
  • true/api/jobs2.php を叩く → フィルタは IDベース Lucene

3.5 Lucene クエリ構築(IDベース)

function buildLuceneByIds({ facility=[], job=[], pref=[], emp=[], other=[] }){
  const orIds = (fid, ids) => ids.length ? `(field:${fid}_ids:(${ids.join(' OR ')}))` : '';
  const parts = [];
  if (facility.length) parts.push(orIds(33, facility));
  if (job.length)      parts.push(orIds(34, job));
  if (pref.length)     parts.push(orIds(35, pref));
  if (emp.length)      parts.push(orIds(16, emp));
  if (other.length)    parts.push(orIds(36, other));
  return parts.join(' AND ');
}
  • field:<フィールドID>_ids:(ID1 OR ID2)AND で連結。
  • 文字列ラベルに比べて 誤マッチが無く精密、かつ APIインデックスを活用 できる。

3.6 API 呼び出し(jobs2.php プロキシ)

async function searchJobs(q,{siteId=SITE_ID, ctId=CT_ID, limit=20, offset=0}={}){
  let url = `${API_BASE}?site_id=${encodeURIComponent(siteId)}&ct_id=${encodeURIComponent(ctId)}&limit=${limit}&offset=${offset}`;
  if (q && q.trim() !== "") url += `&q=${encodeURIComponent(q)}`;
  if (!q) url += `&list=1&status=all`;
  const res = await fetch(url,{headers:{'Accept':'application/json'}});
  if (!res.ok) throw new Error(`API ${res.status}`);
  return await res.json();
}
  • q が空のときは 一覧モードcontentData → contents → search の順にフォールバックして全件取得)。
  • 具体的な検索時は q を付与(Lucene 構文)。

3.7 API → 画面用オブジェクトへ正規化

normalizeFromApi(item){
  const d = item.data || {};
  const joinLabels = a => Array.isArray(a) ? a.map(x=>x.label||x.name||'').filter(Boolean).join(', ') : '';
  const idsOf      = a => Array.isArray(a) ? a.map(x=>x.id).filter(Boolean) : [];
  return {
    id: item.id,
    title: d['13'] || item.label || item.title || 'タイトル未設定',
    date: (item.date || item.createdDate || '').slice(0,10),
    '施設名': joinLabels(d['33']), '施設名_ids': idsOf(d['33']),
    '職種'  : joinLabels(d['34']), '職種_ids'  : idsOf(d['34']),
    '都道府県': joinLabels(d['35']), '都道府県_ids': idsOf(d['35']),
    '雇用形態': joinLabels(d['16']), '雇用形態_ids': idsOf(d['16']),
    'キャッチ文': d['14']||'', '仕事内容': d['15']||'', '給与': d['18']||''
  };
}
  • Data API の contentDatadata にフィールドIDをキー としたオブジェクトを返す想定。
  • 画面で使いやすい共通キーに揃えておくと描画がシンプルになる。

3.8 フィルタ・描画・ページング

  • 静的モード:選択ラベル配列と job['施設名'] 等の 部分一致 で判定。
  • APIモード:選択ID配列から Lucene を作り サーバ検索結果ごと 置換。
  • ページングは currentPage, PAGE_SIZE で単純スライス。
  • エスケープは textContent を使った DOM経由のサニタイズ で安全に(escapeHtml)。

4. サーバ(/api/jobs2.php)の要点

4.1 役割

  • フロントから 唯一の Data API 入口。トークンはここで取得・キャッシュ。
  • list=1 の時は 全件相当 を安全に取得(contentData → contents → search(*))。
  • 検索(q 付き)は search?cdSearch=1 を使用。
  • 返却は {items:[], totalResults:n} に正規化 してクライアント側を単純化。

4.2 認証とトークンキャッシュ

  • /v6/authenticationusername/password/clientId を POST。
  • 成功時の accessTokenAPCu もしくは /tmp ファイル に5分キャッシュ。
  • 401 が来たら 再認証→再試行

4.3 一覧フォールバック戦略(堅牢化)

  1. /v6/sites/{site}/contentTypes/{ct}/contentData(正式)
  2. 404 の場合 /contents(互換)
  3. それも404なら /search?search=contentData&q=*(最終手段)

4.4 診断エンドポイント(開発時のみ使用)

  • ?diag=range:1..50 の site を舐めて存在確認。
  • ?diag=cts:指定 site の CT 一覧。
  • ?diag=ct:CT 詳細(フィールド構成など)。
  • ?diag=contentscontentData の 5件取得(status 指定可)。
  • ?diag=search&q=...:search 直叩きでクエリ検証。

運用注意:本番では diag無効化 するか、IP制限を推奨。

4.5 .htaccess で認証情報を隠す

SetEnv MT_API_USER "gajumaro"
SetEnv MT_API_PASS "********"
  • PHP から getenv('MT_API_USER') などで取得。Gitに載せない
  • 予備としてサーバ環境変数や .user.ini でも可。

5. フロント実装:コードの読み方(行動原理)

  1. 初期化USE_API をログ出し → loadData() 実行。

  2. 静的モード#jobData の JSON を JSON.parsefilteredJobs = allJobs

  3. APIモードsearchJobs('') を呼び、一覧モードで items を取得 → normalizeFromApi

  4. フォーム操作submit または change(checkbox)で performFilter()

  5. フィルタ

    • 静的:ラベル配列で some/includes
    • API:buildLuceneByIds()q を生成 → サーバ検索結果をまるごと置換。
  6. 描画renderResults() で件数、記事カード、renderPager() でページボタン生成。

  7. 安全性escapeHtml() で XSS を回避。APIエラーはユーザ向け文面で表示。


6. Lucene 構文(実例集)

  • 勤務先=12 OR 18、職種=3、都道府県=5、雇用形態=8
(field:33_ids:(12 OR 18)) AND (field:34_ids:(3)) AND (field:35_ids:(5)) AND (field:16_ids:(8))
  • 都道府県=奈良(5)のみ
(field:35_ids:(5))
  • 職種=看護師(3) AND 雇用形態=パート(8)
(field:34_ids:(3)) AND (field:16_ids:(8))

ポイント:カテゴリ ID を使う。ラベル文字列は環境依存・表記揺れの影響を受けるため検索に不向き。


7. よくあるハマりどころと対処

7.1 403 Forbidden(search 直叩き)

  • X-MT-Authorization ヘッダが無い/無効。必ずサーバ側で付与。フロントから直叩き禁止。

7.2 404 Unknown endpoint(/v6/authentication が 404)

  • ベースURLかバージョンが誤り。$MT_BASE/{$API_VER}/authentication を再確認。

7.3 JSON.parse 失敗(静的)

  • encode_json='1' を忘れると崩れる。改行・ダブルクォート・スラッシュ等に注意。

7.4 検索でヒットしない(API)

  • フィールドIDの取り違えが多い。?diag=ct で ID を確認。
  • インデックス未構築(または古い)。再構築→数分待機。

7.5 CORS / トークン露出

  • そもそも フロントから MT を呼ばない必ず jobs2.php 経由

8. 運用フロー(おすすめ)

  1. 開発開始USE_API=false で静的JSON版を完成 → 画面仕様を固める。
  2. Data API 準備jobs2.php 設置、.htaccessSetEnv?diag= で接続検証。
  3. 検索インデックス再構築:CT/サイトを再構築 → searchJobs('') で一覧が返るか確認。
  4. 切替USE_API=true に変更。ラベルではなく IDベース検索 に移行。
  5. 本番?diag= を閉じる/制限。ログ基盤で jobs2.php のエラーログを監視。

9. 診断&デバッグの実践

  • /api/jobs2.php?diag=cts&site_id=2 … CT 一覧。CT_IDを特定
  • /api/jobs2.php?diag=ct&site_id=2&ct_id=3 … フィールド一覧。field:33 等を確認
  • /api/jobs2.php?diag=search&site_id=2&ct_id=3&q=(field:35_ids:(5)) … クエリ検証。
  • /api/jobs2.php?diag=contents&status=All … 下層の JSON 形を把握して正規化を調整。

Tipdebug=1 を付けると upstream のURLやヘッダなどトレースを返すよう実装してある(開発時のみ)。


10. セキュリティとパフォーマンスの注意

  • 認証情報はサーバ側のみ.htaccess/環境変数で安全に供給。
  • トークンは 短期キャッシュ。失効時は自動再認証。
  • limit/offset の上限は 意図的に絞る(DoS回避)。
  • q 長すぎ(>1000)は 400 で拒否。
  • API結果は必要最小限のフィールドにサニタイズしてフロントへ返す。

11. 追加アイデア(拡張)

  • ソート(新着順/施設名/都道府県)… クエリで sortBy があれば活用、無ければクライアント側で。
  • 保存可能な検索条件… クエリストリング化して共有URLを生成。
  • SSR/ビルド… 一覧HTMLも静的に書き出して SEO をさらに強化(APIは詳細検索専用)。
  • アクセシビリティ<fieldset><legend> は既に良い。aria-live で件数更新の通知など。
  • UI… 選択バッジ表示、件数バッジ、即時反映ON/OFF トグルなど。

12. よく使うコード断片(コピペ用)

12.1 静的JSON 1件の出力スニペット

{
  "id": <$mt:ContentID$>,
  "title": "<mt:ContentField content_field='タイトル'><$mt:ContentFieldValue language='ja' encode_json='1'$></mt:ContentField>",
  "date": "<$mt:ContentCreatedDate format='%Y-%m-%d'$>",
  "施設名": "<mt:ContentField content_field='施設名'><$mt:CategoryLabel encode_json='1'$></mt:ContentField>",
  "施設名_ids": [<mt:ContentField content_field='施設名'><$mt:CategoryID$></mt:ContentField>]
}

12.2 Lucene 生成関数(汎用)

const byIds = (fid, arr) => arr?.length ? `(field:${fid}_ids:(${arr.join(' OR ')}))` : '';
function toLucene(filters){
  const parts = [];
  for (const [fid, ids] of Object.entries(filters)) {
    const q = byIds(fid, ids);
    if (q) parts.push(q);
  }
  return parts.join(' AND ');
}
// 使い方: toLucene({33:[12,18], 34:[3], 35:[5], 16:[8]})

12.3 .htaccess(環境変数)

SetEnv MT_API_USER "your-user"
SetEnv MT_API_PASS "your-pass"

13. 仕組み理解(FAQ)

Q. なぜまず静的JSON?

  • インデックスや認証に依存せず 確実に表示 できる。デザイン/UX を先に固めるため。

Q. Data API へ直アクセスしない理由?

  • 認証トークンの露出/CORSの問題を避けるため。必ずサーバプロキシを使うべき。

Q. ラベルではなくIDで検索する理由?

  • ラベルの表記揺れや翻訳差異による 取りこぼし を避け、正確・高速 だから。

Q. 再構築の影響は?

  • 静的JSONは再構築しないと更新されない。Data APIモードに移行すれば即時性が上がる。

14. 導入チェックリスト

  • テンプレートを設置し、静的JSONで一覧が出る
  • .htaccessMT_API_USER/PASS を設定
  • /api/jobs2.php?diag=cts で CT 一覧が見える
  • /api/jobs2.php?diag=ct でフィールドIDを確認
  • USE_API=true にして API 一覧が出る
  • チェックボックスで複合条件が効く
  • ページングが期待通り
  • 本番で ?diag= を閉じる or IP制限

15. 変更履歴

  • v1.0(初版):静的→APIの二段構え、サーバプロキシの堅牢化(トークンキャッシュ/フォールバック/診断)をドキュメント化。

以上。随時、実際の運用で得た知見(タイムアウト値の最適化、ログの取り方、UI調整など)を追記していく想定です。


付録:行ごとの注釈つきコード(フロント&サーバ)

可能な限り 1行ごと に短く要点コメントを付けました。長いCSSや単純なマークアップはブロック単位でまとめて注釈しています。実運用で読みやすいよう、コードはそのまま貼り替え可能です。


A. フロント(HTML/JS)行コメント版

<!doctype html> <!-- HTML5宣言。ブラウザに標準モードでの解析を指示 -->
<html lang="ja"> <!-- 言語は日本語。スクリーンリーダーや検索に影響 -->
<head>
  <meta charset="utf-8"> <!-- UTF-8。MTの出力と一致させる -->
  <title>求人一覧(完成版)</title> <!-- タイトル。SEO/ブックマーク用 -->
  <meta name="viewport" content="width=device-width,initial-scale=1"> <!-- レスポンシブ基本設定 -->
  <style>
    /* ここはスタイル。UIに影響するだけなのでブロック注釈 */
    .wrap{max-width:1100px;margin:40px auto;padding:0 16px;} /* レイアウトの最大幅・中央寄せ */
    .filters{border:1px solid #eee;border-radius:12px;padding:16px;margin:0 0 24px;} /* 絞り込み枠 */
    .filters h2{margin:0 0 12px;font-size:1.1rem}
    fieldset{margin:12px 0;padding:10px;border:1px dashed #ddd;border-radius:10px} /* グルーピング */
    legend{font-weight:700;font-size:.95rem;padding:0 .3em}
    .checkgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:8px 14px} /* チェック群 */
    .actions{margin-top:12px;display:flex;gap:8px;flex-wrap:wrap}
    .job{border-bottom:1px solid #e5e5e5;padding:18px 0;} /* 結果カード */
    .job h2{margin:0 0 6px;font-size:1.2rem}
    .meta{color:#666;font-size:.9rem}
    .empty{padding:24px 0;color:#666}
    .pager{display:flex;gap:8px;flex-wrap:wrap;margin:16px 0} /* ページャ */
    .pager button{padding:6px 10px;border:1px solid #ddd;border-radius:8px;background:#fff;cursor:pointer}
    .debug{background:#f0f0f0;padding:10px;margin:10px 0;font-family:monospace;font-size:0.8em;border-radius:5px;white-space:pre-wrap;} /* デバッグ欄 */
    .error{background:#f8d7da;padding:15px;border-radius:8px;color:#721c24;margin:10px 0;} /* エラーUI */
    .warning{background:#fff3cd;padding:15px;border-radius:8px;color:#856404;margin:10px 0;} /* 注意UI */
  </style>
</head>
<body>
<div class="wrap"> <!-- 中央寄せのメインラッパー -->
  <h1>求人一覧(完成版)</h1> <!-- ページ見出し -->

  <!-- ==== 設定値(MTタグ) ==== -->
  <mt:SetVars> <!-- JSに渡す環境値をMT側で定義 -->
    site_id=<$mt:SiteID$> <!-- サイトID(Data API用) -->
    api_base=/api/jobs2.php <!-- プロキシの相対パス。直叩き禁止のため必須 -->
    api_ver= <!-- 予備。将来のバージョン切替に備える -->
    ct_id=3 <!-- コンテンツタイプID(求人詳細) -->
  </mt:SetVars>

  <div class="warning"> <!-- 利用者向けの切替ガイダンス -->
    <strong>使い方:</strong> まずは静的JSONから表示(<code>USE_API=false</code>)。検索インデックス再構築後、<code>USE_API=true</code>にするとData APIに切替。
  </div>

  <!-- ==== フィルターフォーム ==== -->
  <form id="filterForm" class="filters"> <!-- 絞り込みフォーム。submitとchangeをJSで監視 -->
    <h2>絞り込み</h2>

    <!-- 勤務先(施設名: フィールドID=33) -->
    <fieldset> <!-- チェック群の意味的グループ化 -->
      <legend>勤務先</legend>
      <div class="checkgrid">
        <mt:CategorySets name="施設名"> <!-- カテゴリーセット(施設名)を列挙 -->
          <mt:Categories include_subcategories="1"> <!-- サブカテゴリも表示 -->
            <label>
              <input type="checkbox"
                     name="facility" <!-- JS側のname=facilityで取得 -->
                     value="<$mt:CategoryID$>" <!-- Data API検索に使うID値 -->
                     data-label="<$mt:CategoryLabel encode_html='1'$>"> <!-- 静的モードのラベル比較用 -->
              <$mt:CategoryLabel encode_html="1"$> <!-- 表示ラベル -->
            </label>
          </mt:Categories>
        </mt:CategorySets>
      </div>
    </fieldset>

    <!-- 職種(フィールドID=34) -->
    <fieldset>
      <legend>職種</legend>
      <div class="checkgrid">
        <mt:CategorySets name="職種">
          <mt:Categories include_subcategories="1">
            <label>
              <input type="checkbox" name="job" value="<$mt:CategoryID$>" data-label="<$mt:CategoryLabel encode_html='1'$>">
              <$mt:CategoryLabel encode_html="1"$>
            </label>
          </mt:Categories>
        </mt:CategorySets>
      </div>
    </fieldset>

    <!-- 都道府県(フィールドID=35) -->
    <fieldset>
      <legend>都道府県</legend>
      <div class="checkgrid">
        <mt:CategorySets name="都道府県">
          <mt:Categories include_subcategories="1">
            <label>
              <input type="checkbox" name="pref" value="<$mt:CategoryID$>" data-label="<$mt:CategoryLabel encode_html='1'$>">
              <$mt:CategoryLabel encode_html="1"$>
            </label>
          </mt:Categories>
        </mt:CategorySets>
      </div>
    </fieldset>

    <!-- 雇用形態(フィールドID=16) -->
    <fieldset>
      <legend>雇用形態</legend>
      <div class="checkgrid">
        <mt:CategorySets name="雇用形態">
          <mt:Categories include_subcategories="1">
            <label>
              <input type="checkbox" name="emp" value="<$mt:CategoryID$>" data-label="<$mt:CategoryLabel encode_html='1'$>">
              <$mt:CategoryLabel encode_html="1"$>
            </label>
          </mt:Categories>
        </mt:CategorySets>
      </div>
    </fieldset>

    <div class="actions"> <!-- 操作ボタン群 -->
      <button type="submit">検索</button> <!-- submitで実行。JS側でpreventDefault -->
      <button type="button" id="clearBtn">条件クリア</button> <!-- チェック全解除 -->
    </div>
  </form>

  <!-- ==== デバッグ情報 ==== -->
  <div id="debug" class="debug" style="display:none;"></div> <!-- ログ表示用 -->

  <!-- ==== 検索結果 ==== -->
  <div id="result"></div> <!-- 結果一覧の描画ターゲット -->
  <div class="pager" id="pager"></div> <!-- ページャ描画ターゲット -->

  <!-- ==== フィールド構造のミニダンプ(任意) ==== -->
  <mt:Contents content_type="求人詳細" limit="1"> <!-- 1件だけ構造をダンプして開発確認 -->
    <div style="background:#fffbe6;padding:10px;border:1px solid #eed;margin:10px 0;">
      <h4>デバッグ: フィールド構造確認</h4>
      <pre><mt:ContentFields>
フィールド: "<$mt:ContentFieldLabel$>" (type: <$mt:ContentFieldType$>)
<mt:If tag="ContentFieldType" eq="categories">
カテゴリ:<$mt:CategoryLabel encode_html='1'$>
</mt:If>
<mt:Unless tag="ContentFieldType" eq="categories">
値: "<$mt:ContentFieldValue language='ja' encode_html='1'$>"
</mt:Unless>
---
</mt:ContentFields></pre> <!-- MTのContentFields反復で構成を確認 -->
    </div>
  </mt:Contents>

  <!-- ==== 静的JSON:ラベルとIDの両方 ==== -->
  <script type="application/json" id="jobData"> <!-- 安全な生JSONを埋め込むコンテナ -->
[
<mt:Contents content_type="求人詳細"> <!-- 全求人を列挙公開データのみ -->
{
  "id": <$mt:ContentID$>,
  "title": "<mt:ContentField content_field='タイトル'><$mt:ContentFieldValue language='ja' encode_json='1'$></mt:ContentField>",
  "date": "<$mt:ContentCreatedDate format='%Y-%m-%d'$>",

  "施設名": "<mt:ContentField content_field='施設名'><$mt:CategoryLabel encode_json='1'$></mt:ContentField>",
  "施設名_ids": [<mt:ContentField content_field='施設名'><$mt:CategoryID$></mt:ContentField>],

  "職種": "<mt:ContentField content_field='職種'><$mt:CategoryLabel encode_json='1'$></mt:ContentField>",
  "職種_ids": [<mt:ContentField content_field='職種'><$mt:CategoryID$></mt:ContentField>],

  "都道府県": "<mt:ContentField content_field='都道府県'><$mt:CategoryLabel encode_json='1'$></mt:ContentField>",
  "都道府県_ids": [<mt:ContentField content_field='都道府県'><$mt:CategoryID$></mt:ContentField>],

  "雇用形態": "<mt:ContentField content_field='雇用形態'><$mt:CategoryLabel encode_json='1'$></mt:ContentField>",
  "雇用形態_ids": [<mt:ContentField content_field='雇用形態'><$mt:CategoryID$></mt:ContentField>],

  "キャッチ文": "<mt:ContentField content_field='キャッチ文'><$mt:ContentFieldValue language='ja' encode_json='1'$></mt:ContentField>",
  "仕事内容": "<mt:ContentField content_field='仕事内容・PR'><$mt:ContentFieldValue language='ja' encode_json='1'$></mt:ContentField>",
  "給与": "<mt:ContentField content_field='給与'><$mt:ContentFieldValue language='ja' encode_json='1'$></mt:ContentField>"
}<mt:Unless name="__last__">,</mt:Unless> <!-- 最後の要素にはカンマを出さないための制御 -->
</mt:Contents>
]
  </script>

  <!-- ==== JS(静的JSON ←→ Data API トグル) ==== -->
  <script>
  var USE_API = false; // 初期は静的JSON運用。インデックス構築後にtrueへ

  const API_BASE = "<$mt:GetVar name='api_base'$>"; // /api/jobs2.php(サーバプロキシ)
  const API_VER  = "<$mt:GetVar name='api_ver'$>";  // 今は未使用
  const SITE_ID  = "<$mt:GetVar name='site_id'$>";  // MTのサイトID
  const CT_ID    = "<$mt:GetVar name='ct_id'$>";    // コンテンツタイプID
  // トークンはフロントに置かない(jobs2.php内で完結)

  // Lucene(IDベース)クエリ構築関数
  function buildLuceneByIds({ facility = [], job = [], pref = [], emp = [], other = [] }) {
    const parts = []; // 条件を積む配列
    const orIds = (fid, ids) => ids.length ? `(field:${fid}_ids:(${ids.join(' OR ')}))` : ''; // OR列挙
    if (facility.length) parts.push(orIds(33, facility)); // 33:施設名
    if (job.length)      parts.push(orIds(34, job));      // 34:職種
    if (pref.length)     parts.push(orIds(35, pref));     // 35:都道府県
    if (emp.length)      parts.push(orIds(16, emp));      // 16:雇用形態
    if (other.length)    parts.push(orIds(36, other));    // 36:その他(将来拡張)
    return parts.join(' AND '); // すべてANDで連結
  }

  // 1ページ分だけAPI検索(jobs2.phpを叩く)
  async function searchJobs(q, { siteId = SITE_ID, ctId = CT_ID, limit = 20, offset = 0 } = {}) {
    let url = `${API_BASE}?site_id=${encodeURIComponent(siteId)}&ct_id=${encodeURIComponent(ctId)}&limit=${limit}&offset=${offset}`; // 基本クエリ
    if (q && q.trim() !== "") url += `&q=${encodeURIComponent(q)}`; // Luceneクエリがある場合
    if (!q) url += `&list=1&status=all`; // q空=一覧モード。フォールバックで全件取得
    const res = await fetch(url, { headers: { 'Accept': 'application/json' } }); // JSON期待
    if (!res.ok) throw new Error(`API ${res.status}`); // HTTPエラーを例外化
    return await res.json(); // {items, totalResults} に正規化済みの想定
  }

  // すべてのページを取得(大量データ時に使用可能。通常は未使用)
  async function fetchAllPages(q, { siteId = SITE_ID, ctId = CT_ID, limit = 100 } = {}) {
    let offset = 0, all = []; // 連結用バッファ
    while (true) {
      const data = await searchJobs(q, { siteId, ctId, limit, offset }); // 取得
      const items = data.items || data.data || []; // 念のため後方互換
      all = all.concat(items); // 配列結合
      if (!items.length || items.length < limit) break; // 次ページなしで終了
      offset += limit; // 次オフセットへ
    }
    return { items: all, totalResults: all.length }; // まとめレスポンス
  }

  // アプリ本体(状態とメソッドを束ねる)
  var jobSearchApp = {
    allJobs: [],        // 全取得データ(現在ページング前)
    filteredJobs: [],   // フィルタ後の配列(これを描画)
    currentPage: 0,     // 表示中ページ index(0始まり)
    PAGE_SIZE: 20,      // 1ページ件数

    debug: function(msg) { // 画面&コンソール両方にログ
      console.log(msg);
      const el = document.getElementById('debug');
      if (el) { el.style.display = 'block'; el.textContent += `${new Date().toLocaleTimeString()}: ${msg}
`; }
    },

    // チェックされた値(ID配列)を取得(APIモード用)
    getCheckedIds: function(name) {
      return Array.from(document.querySelectorAll(`input[name="${name}"]:checked`))
        .map(el => el.value).filter(Boolean);
    },
    // チェックされたラベル配列(静的モード用)
    getCheckedLabels: function(name) {
      return Array.from(document.querySelectorAll(`input[name="${name}"]:checked`))
        .map(el => el.dataset.label).filter(Boolean);
    },

    // 静的モードで使う:レコード上のラベル文字列を取り出す
    getLabelString: function(job, key) {
      const map = { facility: '施設名', job: '職種', pref: '都道府県', emp: '雇用形態' }; // キー→項目名対応
      const labelKey = map[key];
      return (job && job[labelKey]) ? String(job[labelKey]).trim() : '';
    },

    // ========= データ読込 =========
    async loadData() {
      if (!USE_API) {
        // 静的JSON
        const script = document.getElementById('jobData'); // 埋め込みJSON要素
        const txt = script ? (script.textContent || script.innerHTML) : '[]'; // テキスト抽出
        this.debug('静的JSONの先頭200: ' + txt.substring(0, 200)); // 先頭の内容をログ
        try {
          this.allJobs = JSON.parse(txt) || []; // JSONパース
          this.debug(`静的データ件数: ${this.allJobs.length}`);
        } catch(e) {
          this.debug('JSON.parse 失敗: ' + e.message);
          document.getElementById('result').innerHTML =
            `<div class="error">JSONの読み込みに失敗しました: ${this.escapeHtml(e.message)}</div>`; // 画面表示
          return; // 続行不能
        }
        this.filteredJobs = this.allJobs.slice(); // 初回は全件表示
        this.renderResults(); // 描画
      } else {
        // Data API(jobs2.php)
        try {
          const data = await searchJobs('', { limit: 50 }); // 一覧モード(q無し)
          this.allJobs = (data.items || []).map(this.normalizeFromApi); // 画面用に正規化
          this.filteredJobs = this.allJobs.slice();
          this.debug(`APIデータ件数: ${this.allJobs.length}`);
          this.renderResults();
        } catch (e) {
          this.debug('APIエラー: ' + e.message);
          document.getElementById('result').innerHTML =
            `<div class="error">APIエラー: ${this.escapeHtml(e.message)}<br><small>検索インデックスの再構築やjobs2.phpのログを確認してください。</small></div>`;
        }
      }
    },

    // APIレスポンス1件を画面用オブジェクトへ変換
    normalizeFromApi(item) {
      const d = item.data || {}; // contentDataの本体
      const joinLabels = (arr) => Array.isArray(arr) ? arr.map(x => x.label || x.name || '').filter(Boolean).join(', ') : ''; // ラベル連結
      const idsOf = (arr) => Array.isArray(arr) ? arr.map(x => x.id).filter(Boolean) : []; // ID配列化

      return {
        id: item.id,
        title: d['13'] || item.label || item.title || 'タイトル未設定', // 13=タイトル
        date: (item.date || item.createdDate || '').slice(0,10), // 先頭10文字=YYYY-MM-DD

        '施設名': joinLabels(d['33']), '施設名_ids': idsOf(d['33']), // 33
        '職種':   joinLabels(d['34']), '職種_ids':   idsOf(d['34']), // 34
        '都道府県': joinLabels(d['35']), '都道府県_ids': idsOf(d['35']), // 35
        '雇用形態': joinLabels(d['16']), '雇用形態_ids': idsOf(d['16']), // 16

        'キャッチ文': d['14'] || '', // 14
        '仕事内容':   d['15'] || '', // 15
        '給与':       d['18'] || ''  // 18
      };
    },

    // ========= フィルタ処理 =========
    async performFilter() {
      if (!USE_API) {
        // 静的:ラベル部分一致
        const filters = {
          facility: this.getCheckedLabels('facility'),
          job:      this.getCheckedLabels('job'),
          pref:     this.getCheckedLabels('pref'),
          emp:      this.getCheckedLabels('emp')
        };
        this.debug('静的フィルタ: ' + JSON.stringify(filters));

        const keys = ['facility','job','pref','emp'];
        this.filteredJobs = this.allJobs.filter(job => {
          return keys.every(k => { // すべてのキーで条件を満たすか
            const selected = filters[k];
            if (!selected.length) return true; // 条件なし → 合格
            const s = this.getLabelString(job, k); // レコードのラベル文字列
            if (!s) return false; // 値なしは不一致
            return selected.some(x => s.indexOf(x) !== -1); // 部分一致
          });
        });
        this.currentPage = 0;
        this.renderResults();
      } else {
        // API:IDベースLucene
        const filters = {
          facility: this.getCheckedIds('facility'),
          job:      this.getCheckedIds('job'),
          pref:     this.getCheckedIds('pref'),
          emp:      this.getCheckedIds('emp')
        };
        const q = buildLuceneByIds(filters); // Lucene文字列
        this.debug('API検索 q=' + q);

        try {
          const data = await searchJobs(q, { limit: 50 }); // 検索実行
          this.allJobs = (data.items || []).map(this.normalizeFromApi); // 結果を採用
          this.filteredJobs = this.allJobs.slice();
          this.currentPage = 0;
          this.renderResults();
        } catch(e) {
          this.debug('API検索エラー: ' + e.message);
          document.getElementById('result').innerHTML =
            `<div class=\"error\">API検索エラー: ${this.escapeHtml(e.message)}</div>`;
        }
      }
    },

    // ========= 描画 =========
    renderResults() {
      const start = this.currentPage * this.PAGE_SIZE; // 開始index
      const end   = start + this.PAGE_SIZE;            // 終了index(開区間)
      const page  = this.filteredJobs.slice(start, end); // 現在ページ分

      if (!this.filteredJobs.length) { // ヒットなしUI
        document.getElementById('result').innerHTML =
          `<div class="empty">該当する求人が見つかりませんでした。${this.allJobs.length ? '' : '<br><small>データが空の可能性があります。</small>'}</div>`;
        document.getElementById('pager').innerHTML = '';
        return;
      }

      const esc = this.escapeHtml.bind(this); // HTMLエスケープ関数を束縛
      const html = page.map(job => { // 各レコードをカードHTML化
        return `<article class="job">
          <h2>${esc(job.title || 'タイトル未設定')}</h2>
          <p class="meta">公開日:${esc(job.date || '')}</p>
          <p class="meta">施設名:${esc(job['施設名'] || '')}</p>
          <p class="meta">職種:${esc(job['職種'] || '')}</p>
          <p class="meta">勤務地:${esc(job['都道府県'] || '')}</p>
          <p class="meta">雇用形態:${esc(job['雇用形態'] || '')}</p>
          ${job['キャッチ文'] ? `<p>${esc(job['キャッチ文'])}</p>` : ''}
        </article>`;
      }).join('');

      document.getElementById('result').innerHTML =
        `<p><strong>検索結果: ${this.filteredJobs.length}件</strong> (表示中: ${start+1}${Math.min(end,this.filteredJobs.length)}件)</p>${html}`;

      this.renderPager(); // ページャ更新
    },

    renderPager() {
      const totalPages = Math.ceil(this.filteredJobs.length / this.PAGE_SIZE); // 総ページ数
      if (totalPages <= 1) { document.getElementById('pager').innerHTML = ''; return; }

      const btn = [];
      if (this.currentPage > 0) { // 先頭/前へ
        btn.push(`<button onclick="jobSearchApp.changePage(0)">« 先頭</button>`);
        btn.push(`<button onclick="jobSearchApp.changePage(${this.currentPage-1})">‹ 前へ</button>`);
      }
      const s = Math.max(0, this.currentPage - 2); // 近傍のみ表示
      const e = Math.min(totalPages, this.currentPage + 3);
      for (let i = s; i < e; i++) {
        const active = i === this.currentPage ? ' style="background:#007bff;color:#fff;"' : '';
        btn.push(`<button onclick="jobSearchApp.changePage(${i})"${active}>${i+1}</button>`);
      }
      if (this.currentPage < totalPages - 1) { // 次へ/最後
        btn.push(`<button onclick="jobSearchApp.changePage(${this.currentPage+1})">次へ ›</button>`);
        btn.push(`<button onclick="jobSearchApp.changePage(${totalPages-1})">最後 »</button>`);
      }
      document.getElementById('pager').innerHTML = btn.join('');
    },

    changePage(p) { this.currentPage = p; this.renderResults(); window.scrollTo(0, document.getElementById('result').offsetTop - 20); }, // ページ変更とスクロール

    escapeHtml(s){ if(s==null) return ''; const d=document.createElement('div'); d.textContent=String(s); return d.innerHTML; }, // XSS回避

    init() {
      // submitで検索(ページ遷移を防ぐ)
      document.getElementById('filterForm').addEventListener('submit', e => { e.preventDefault(); this.performFilter(); });
      // チェック変更で即時検索(UX向上)
      document.getElementById('filterForm').addEventListener('change', e => { if (e.target.type === 'checkbox') this.performFilter(); });
      // 条件クリア
      document.getElementById('clearBtn').addEventListener('click', () => {
        document.querySelectorAll('input[type="checkbox"]').forEach(cb => cb.checked = false);
        this.filteredJobs = this.allJobs.slice(); // 全件に戻す
        this.currentPage = 0;
        document.getElementById('debug').textContent = '';
        this.renderResults();
      });

      this.debug('初期化開始 USE_API=' + USE_API); // 起動ログ
      this.loadData(); // データ読込を開始
    }
  };

  document.addEventListener('DOMContentLoaded', () => jobSearchApp.init()); // DOM準備後に初期化
  </script>
</div>
</body>
</html>

B. サーバ(jobs2.php)行コメント版

<?php
// jobs.php - 安定&デバッグ強化版(v1.2)
declare(strict_types=1); // 厳格型。型の不一致を検出しやすくなる

header('Content-Type: application/json; charset=utf-8'); // 返却はJSON
header('Cache-Control: no-cache, must-revalidate'); // キャッシュ抑制(開発向け)

ini_set('display_errors', '0'); // 本番は画面にエラーを出さない
error_reporting(E_ALL); // ログにはすべて出す

// ===== 設定 =====
$MT_BASE = 'https:/xxxxxx.jp/mt/mt-data-api.cgi'; // Data APIベースURL
$API_VER = 'v6'; // APIバージョン
$SITE_ID = '2';  // 既定サイトID(GETで上書き可)
$CT_ID   = '3';  // 既定コンテンツタイプID
$CLIENT  = 'jobs-ui'; // 認証時のclientId(任意の識別子)

// 環境変数から認証情報(.htaccess の SetEnv で供給)
$MT_USER = getenv('MT_API_USER') ?: '';
$MT_PASS = getenv('MT_API_PASS') ?: '';
if (!$MT_USER || !$MT_PASS) {
  http_response_code(500);
  echo json_encode(['error'=>'Authentication credentials not configured']); exit; // 認証設定が無い
}

// ===== 入力 =====
$q      = isset($_GET['q']) ? trim((string)$_GET['q']) : ''; // Luceneクエリ(任意)
$limit  = isset($_GET['limit']) ? max(1, min(100, (int)$_GET['limit'])) : 20; // 1..100に制限
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0; // 0以上
$list   = isset($_GET['list']);   // 一覧モードフラグ(q無し時に使う)
$status = isset($_GET['status']) ? $_GET['status'] : 'Publish'; // 一覧モード時の公開状態
$debug  = isset($_GET['debug']); // トレース出力を返すか

// site_id/ct_id をURLで上書き(デバッグ用途)
if (isset($_GET['site_id']) && ctype_digit($_GET['site_id'])) $SITE_ID = $_GET['site_id'];
if (isset($_GET['ct_id'])   && ctype_digit($_GET['ct_id']))   $CT_ID   = $_GET['ct_id'];

// DoS対策:クエリ過長は拒否
if (strlen($q) > 1000) { http_response_code(400); echo json_encode(['error'=>'Query too long']); exit; }

// ===== ユーティリティ =====
function send(int $code, array $payload) {
  http_response_code($code);
  echo json_encode($payload, JSON_UNESCAPED_UNICODE); exit; // 日本語そのまま
}

function httpRequest(string $method, string $url, array $headers = [], $body = null, bool $debug=false): array {
  $ch = curl_init();
  if ($ch === false) { throw new Exception('Failed to initialize cURL'); }

  // 受け取りはJSON想定。圧縮転送も受ける
  $headers = array_merge(['Accept: application/json'], $headers);

  $opts = [
    CURLOPT_URL            => $url,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_CUSTOMREQUEST  => $method,
    CURLOPT_HTTPHEADER     => $headers,
    CURLOPT_CONNECTTIMEOUT => 10,   // 接続タイムアウト
    CURLOPT_TIMEOUT        => 30,   // 全体タイムアウト
    CURLOPT_SSL_VERIFYPEER => true, // SSL検証有効
    CURLOPT_SSL_VERIFYHOST => 2,
    CURLOPT_FOLLOWLOCATION => false,
    CURLOPT_ENCODING       => '',   // gzip等を自動解凍
    CURLOPT_USERAGENT      => 'JobsAPI/1.2',
  ];
  if ($body !== null && in_array(strtoupper($method), ['POST','PUT','PATCH'], true)) {
    $opts[CURLOPT_POSTFIELDS] = $body; // フォームボディ
  }
  curl_setopt_array($ch, $opts);

  $response = curl_exec($ch);
  $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
  $error    = curl_error($ch);
  curl_close($ch);

  if ($response === false) { throw new Exception('cURL Error: '.$error); }

  if ($debug) {
    return [$httpCode, $response, ['url'=>$url, 'headers'=>$headers, 'hasBody'=>$body!==null]]; // トレース返却
  }
  return [$httpCode, $response, null]; // 通常はトレース無し
}

// 超簡易トークンキャッシュ(APCu or /tmp)
function token_get_cached(): ?string {
  if (function_exists('apcu_fetch')) {
    $t = apcu_fetch('mt_access_token', $ok); return $ok ? $t : null; // APCu
  }
  $f = sys_get_temp_dir().'/mt_token.cache';
  if (is_readable($f)) {
    $j = json_decode((string)@file_get_contents($f), true);
    if (isset($j['token'],$j['exp']) && $j['exp'] > time()+30) return $j['token']; // 30秒以上残
  }
  return null; // キャッシュ無し
}
function token_set_cached(string $token, int $ttl=300): void {
  if (function_exists('apcu_store')) { apcu_store('mt_access_token', $token, $ttl); return; }
  $f = sys_get_temp_dir().'/mt_token.cache';
  @file_put_contents($f, json_encode(['token'=>$token,'exp'=>time()+$ttl])); // /tmpに保存
}

// ===== 認証 =====
function authenticate(string $base, string $ver, string $user, string $pass, string $client, bool $debug=false): string {
  if ($tok = token_get_cached()) return $tok; // キャッシュ命中
  $url  = "{$base}/{$ver}/authentication"; // 認証エンドポイント
  $body = http_build_query(['username'=>$user,'password'=>$pass,'clientId'=>$client]); // x-www-form-urlencoded
  [$code,$res,$trace] = httpRequest('POST', $url, ['Content-Type: application/x-www-form-urlencoded'], $body, $debug);
  if ($code !== 200) {
    send($code, ['error'=>'Authentication failed','upstream'=>$res,'trace'=>$trace]); // 上流レスポンスも返す
  }
  $j = json_decode($res,true);
  if (!isset($j['accessToken'])) {
    send(500, ['error'=>'Invalid auth response','upstream'=>$res,'trace'=>$trace]); // 想定外形式
  }
  token_set_cached($j['accessToken'], 300); // 5分キャッシュ
  return $j['accessToken'];
}

// ===== API呼び分け =====
function call_search(string $base,string $ver,string $siteId,string $ctId,string $q,int $limit,int $offset,string $token,bool $debug=false): array {
  $params = [
    'search'             => 'contentData', // contentData検索
    'cdSearch'           => 1,             // CD検索フラグ
    'site_id'            => $siteId,
    'SearchContentTypes' => $ctId,
    'limit'              => $limit,
    'offset'             => $offset,
  ];
  if ($q !== '') $params['q'] = $q; // q空なら付けない(環境差対策)
  $url = "{$base}/{$ver}/search?".http_build_query($params);
  $headers = ['X-MT-Authorization: MTAuth accessToken='.$token]; // 認可ヘッダ
  return httpRequest('GET', $url, $headers, null, $debug);
}

function call_list(string $base,string $ver,string $siteId,string $ctId,string $status,int $limit,int $offset,string $token,bool $debug=false): array {
  $qs = http_build_query([
    'status' => $status ?: 'Publish', // Publish/All/all
    'limit'  => $limit,
    'offset' => $offset,
  ]);
  $headers = ['X-MT-Authorization: MTAuth accessToken='.$token];

  // 1) 正式: contentData
  $url1 = "{$base}/{$ver}/sites/{$siteId}/contentTypes/{$ctId}/contentData?{$qs}";
  [$code1,$res1,$trace1] = httpRequest('GET', $url1, $headers, null, $debug);
  if ($code1 !== 404) {
    if ($debug) $trace1['hint'] = 'contentData';
    return [$code1, $res1, $trace1]; // 200なら即返却
  }

  // 2) 念のため: contents(古い互換API)
  $url2 = "{$base}/{$ver}/sites/{$siteId}/contentTypes/{$ctId}/contents?{$qs}";
  [$code2,$res2,$trace2] = httpRequest('GET', $url2, $headers, null, $debug);
  if ($code2 !== 404) {
    if ($debug) $trace2['hint'] = 'contents-fallback';
    return [$code2, $res2, $trace2];
  }

  // 3) 最終手段: search で q=* 相当
  $params = [
    'search'             => 'contentData',
    'cdSearch'           => 1,
    'site_id'            => $siteId,
    'SearchContentTypes' => $ctId,
    'q'                  => '*',
    'limit'              => $limit,
    'offset'             => $offset,
  ];
  $url3 = "{$base}/{$ver}/search?".http_build_query($params);
  [$code3,$res3,$trace3] = httpRequest('GET', $url3, $headers, null, $debug);
  if ($debug) {
    $trace3['hint'] = 'search-fallback';
    $trace3['fallback'] = [ // どの経路がどうだったか可視化
      'contentData' => ['url'=>$url1, 'status'=>$code1],
      'contents'    => ['url'=>$url2, 'status'=>$code2],
      'search'      => ['url'=>$url3, 'status'=>$code3],
    ];
  }
  return [$code3, $res3, $trace3];
}

// ====== 診断系 ( ?diag=... )  ======
if (isset($_GET['diag'])) {
  $token = authenticate($MT_BASE, $API_VER, $MT_USER, $MT_PASS, $CLIENT, true); // 診断はtrace有効
  $diag  = $_GET['diag'];

  if ($diag === 'range') { // 1..50のsite探索
    $found = [];
    for ($i=1; $i<=50; $i++) {
      [$code,$res,$trace] = httpRequest('GET', "{$MT_BASE}/{$API_VER}/sites/{$i}", ['X-MT-Authorization: MTAuth accessToken='.$token], null, true);
      if ($code === 200) {
        $j = json_decode($res, true);
        $found[] = ['id'=>$i, 'name'=>$j['name'] ?? null, 'class'=>$j['class'] ?? null];
      }
    }
    send(200, ['sites'=>$found]);
  }

  if ($diag === 'cts') { // 指定サイトのCT一覧
    $sid = isset($_GET['site_id']) ? $_GET['site_id'] : $SITE_ID;
    [$code,$res,$trace] = httpRequest('GET', "{$MT_BASE}/{$API_VER}/sites/{\$sid}/contentTypes?limit=200", ['X-MT-Authorization: MTAuth accessToken='.$token], null, true);
    send($code, ['site_id'=>$sid, 'result'=>json_decode($res, true), 'trace'=>$trace]);
  }

  if ($diag === 'ct') { // CT詳細
    $sid = isset($_GET['site_id']) ? $_GET['site_id'] : $SITE_ID;
    $cid = isset($_GET['ct_id'])   ? $_GET['ct_id']   : $CT_ID;
    [$code,$res,$trace] = httpRequest('GET', "{$MT_BASE}/{$API_VER}/sites/{\$sid}/contentTypes/{\$cid}", ['X-MT-Authorization: MTAuth accessToken='.$token], null, true);
    send($code, ['site_id'=>$sid,'ct_id'=>$cid,'result'=>json_decode($res,true),'trace'=>$trace]);
  }

  if ($diag === 'contents') { // contentDataのサンプル
    $sid = isset($_GET['site_id']) ? $_GET['site_id'] : $SITE_ID;
    $cid = isset($_GET['ct_id'])   ? $_GET['ct_id']   : $CT_ID;
    $st  = isset($_GET['status'])  ? $_GET['status']  : 'Publish';
    $qs  = http_build_query(['status'=>$st, 'limit'=>5, 'offset'=>0]);
    [$code,$res,$trace] = httpRequest('GET', "{$MT_BASE}/{$API_VER}/sites/{\$sid}/contentTypes/{\$cid}/contentData?{$qs}", ['X-MT-Authorization: MTAuth accessToken='.$token], null, true);
    send($code, ['site_id'=>$sid,'ct_id'=>$cid,'status'=>$st,'result'=>json_decode($res, true),'trace'=>$trace]);
  }

  if ($diag === 'search') { // search直叩き
    $sid = isset($_GET['site_id']) ? $_GET['site_id'] : $SITE_ID;
    $cid = isset($_GET['ct_id'])   ? $_GET['ct_id']   : $CT_ID;
    $qv  = isset($_GET['q']) ? $_GET['q'] : '*';
    $params = [
      'search'=>'contentData','cdSearch'=>1,'site_id'=>$sid,'SearchContentTypes'=>$cid,
      'q'=>$qv,'limit'=>5,'offset'=>0
    ];
    $u = "{$MT_BASE}/{$API_VER}/search?".http_build_query($params);
    [$code,$res,$trace] = httpRequest('GET', $u, ['X-MT-Authorization: MTAuth accessToken='.$token], null, true);
    send($code, ['params'=>$params,'result'=>json_decode($res,true),'trace'=>$trace]);
  }

  send(400, ['error'=>'unknown diag']); // 未知のdiag
}

// ===== 実行 =====
try {
  $token = authenticate($MT_BASE, $API_VER, $MT_USER, $MT_PASS, $CLIENT, $debug); // 認証&キャッシュ

  if ($list) { // 一覧モード
    [$code,$res,$trace] = call_list($MT_BASE,$API_VER,$SITE_ID,$CT_ID,$status,$limit,$offset,$token,$debug);
  } else {     // 検索モード
    [$code,$res,$trace] = call_search($MT_BASE,$API_VER,$SITE_ID,$CT_ID,$q,$limit,$offset,$token,$debug);
    if ($code === 401) { // トークン期限切れ→再認証して再試行
      $token = authenticate($MT_BASE, $API_VER, $MT_USER, $MT_PASS, $CLIENT, $debug);
      [$code,$res,$trace] = call_search($MT_BASE,$API_VER,$SITE_ID,$CT_ID,$q,$limit,$offset,$token,$debug);
    }
  }

  $j = json_decode($res,true); // 上流JSON
  if (!is_array($j)) {
    send(502, ['error'=>'Invalid JSON from MT','upstream'=>$res,'trace'=>$trace]); // 体裁不正
  }

  // 正規化(items/totalResultsに揃える)
  $items = [];
  $total = null;
  if (isset($j['items']) && is_array($j['items'])) $items = $j['items'];
  elseif (isset($j['data']) && is_array($j['data'])) $items = $j['data'];

  if (array_key_exists('totalResults',$j)) $total = $j['totalResults'];
  elseif (array_key_exists('totalResultsCount',$j)) $total = $j['totalResultsCount'];
  elseif (isset($j['count'])) $total = $j['count'];
  if ($total === null) $total = count($items); // 最後の保険

  $out = ['items'=>$items, 'totalResults'=>$total]; // クライアントに優しい形
  if ($debug) $out['trace'] = ['status'=>$code, 'http'=>$trace, 'site_id'=>$SITE_ID, 'ct_id'=>$CT_ID, 'mode'=>$list?'list':'search']; // 追加情報

  send($code, $out); // 最終送出

} catch (Throwable $e) { // 予期せぬ例外
  send(500, [
    'error' => $e->getMessage(),
    'params'=> ['q'=>$q,'limit'=>$limit,'offset'=>$offset,'mode'=>$list?'list':'search']
  ]);
}

詳しく説明します。

📋 概要

このコードはMovable Type(MT)のData APIをラップするPHPプロキシです。フロントエンド(JavaScript)から直接MTのAPIを叩くのではなく、このPHPを経由することで以下のメリットがあります:

  • 認証情報の隠蔽(ユーザー名/パスワードをブラウザに露出させない)
  • CORS問題の回避
  • レスポンスの正規化
  • トークンのキャッシュによる効率化

全体像(役割の俯瞰)

[Browser/UI]  ─── GET /jobs.php?q=...&list=... ──▶  [jobs.php]
                                                     │
                                                     ├─ 認証: POST /v6/authentication(アクセストークン、5分キャッシュ)
                                                     │
                                                     ├─ list=1     → /sites/{site}/contentTypes/{ct}/contentData
                                                     │                 404→ /contents
                                                     │                 404→ /search?cdSearch=1&q=*
                                                     │
                                                     └─ list=なし  → /search?cdSearch=1&q=...(401時は再認証して再試行)
                                                      
                              ◀── JSON(items/totalResults に正規化して返却) ───

処理フロー(フローチャート)

┌──────────────────────────────────────────────────────────────┐
│ リクエスト受領(GET: q, limit, offset, list, status, debug, │
│ site_id/ct_id で上書き可。DoS対策: q 長さ <= 1000)          │
└───────────────┬──────────────────────────────────────────────┘
                │
                v
       環境変数から MT_USER / MT_PASS 取得
                │
        ┌───────┴────────┐
        │ 認証情報なし    │→ 500 JSON {error:"Authentication credentials not configured"}
        └─────────────────┘
                │
                v
        アクセストークン取得 authenticate()
        (APCu or /tmp に5分キャッシュ)
                │
        ┌───────┬───────────────┬─────────────────────────┐
        │ diag有 │               │                          │
        │        v               v                          v
        │  診断モード      list=1(一覧モード)        listなし(検索モード)
        │  ・sites range   call_list():                 call_search():
        │  ・cts一覧       ① /contentData              /search?cdSearch=1
        │  ・ct詳細        ② /contents(404なら)       (401なら再認証→再試行)
        │  ・contents検証  ③ /search(q=*) フォールバック
        │  ・search直叩き
        │      └→ そのまま send(code, payload)
        │
        └───────────────────────────────────────────────────┐
                                                            v
                                         上流応答JSONを検査&正規化
                                         items = items or data
                                         totalResults = totalResults
                                                         or totalResultsCount
                                                         or count
                                                         or items数
                                                            │
                                                            v
                                           send(code, {items, totalResults, debug時trace})

通信シーケンス(時系列)

Browser                      jobs.php                             MT Data API (v6)
   |         GET /jobs.php?...  (list/search/diag)                     |
   |---------------------------->|                                      |
   |                             | authenticate():                      |
   |                             |  1) token_get_cached()               |
   |                             |  2) キャッシュ無 → POST /authentication
   |                             |------------------------------------->|
   |                             |                200 {accessToken}     |
   |                             |<-------------------------------------|
   |                             | token_set_cached(5分)                |
   |                             |                                      |
   |                             | list=1 ? call_list() : call_search() |
   |                             |   例)GET /sites/{sid}/contentTypes/{cid}/contentData
   |                             |------------------------------------->|
   |                             |        404なら次へ                   |
   |                             |<-------------------------------------|
   |                             |   例)GET /sites/{sid}/contentTypes/{cid}/contents
   |                             |------------------------------------->|
   |                             |        404なら次へ                   |
   |                             |<-------------------------------------|
   |                             |   例)GET /search?cdSearch=1&q=*     |
   |                             |------------------------------------->|
   |                             |                      200 {items...}  |
   |                             |<-------------------------------------|
   |                             | JSON正規化(items/totalResults)     |
   |        200 {items,total}    |                                      |
   |<----------------------------|                                      |

主要モードの挙動

1) 検索モード(list なし)

  • エンドポイント:
    /v6/search?search=contentData&cdSearch=1&site_id={sid}&SearchContentTypes={ctid}&q={q}&limit={n}&offset={m}

  • 特徴:

    • q が空の場合は パラメータ自体を付けない(環境差対策)。
    • 401 が来たら 再認証→即リトライ
    • gzip対応・SSL検証ON・UA固定(JobsAPI/1.2)。

2) 一覧モード(list=1

  • 優先順に叩く(最初の404以外のコードで確定

    1. /sites/{sid}/contentTypes/{ctid}/contentData?status=...(正式)
    2. /sites/{sid}/contentTypes/{ctid}/contents(旧名互換)
    3. /search?cdSearch=1&q=*最終フォールバック、全件相当)
  • statusPublish が既定(All/allも可)。

3) 診断モード(?diag=...

  • diag=range : /sites/1..50 を総当りし、存在サイトを列挙
  • diag=cts : /sites/{sid}/contentTypes でCT一覧
  • diag=ct : /sites/{sid}/contentTypes/{ctid} でCT詳細
  • diag=contents : 指定 site/ct の contentData を試験取得
  • diag=search : パラメータを明示した search 直叩き
  • 返り値には trace(実際のURL/ヘッダ) を含めてデバッグしやすく

例(呼び出しパターン)

  • 検索(複合条件クエリを渡す)
    /jobs.php?q=field%3A34_ids%3A(3)%20AND%20field%3A35_ids%3A(5)&limit=20&offset=0

  • 一覧(公開のみ・全件ページング)
    /jobs.php?list=1&status=Publish&limit=20&offset=0

  • デバッグ(HTTPトレース同梱)
    /jobs.php?debug=1&list=1

  • 診断:CT存在確認
    /jobs.php?diag=ct&site_id=2&ct_id=3


正規化ロジック(出力の揃え方)

  • items: 上流が items ならそれを、なければ data を採用。
  • totalResults: totalResultstotalResultsCountcount の順で拾う。
    どれもなければ count(items) を採用。
  • 返却JSONは常に:
{
  "items": [...],
  "totalResults": 123,
  "trace": { ... } // debug=1 のときだけ
}

安全性・堅牢化ポイント

  • DoS対策: q の最大長1000。
  • 認証キャッシュ: APCu(なければ /tmp/mt_token.cache)に5分
  • 再認証: 401時に自動。
  • TLS検証: CURLOPT_SSL_VERIFYPEER/VERIFYHOST 有効。
  • タイムアウト: 接続10秒・全体30秒。
  • JSON妥当性: パースNGは 502 で upstream を返す。
  • ヘッダ: Accept: application/json 明示、圧縮自動解凍。

🔧 主要な機能

1. 認証システム

$MT_USER = getenv('MT_API_USER');
$MT_PASS = getenv('MT_API_PASS');
  • 環境変数から認証情報を取得
  • トークンを5分間キャッシュ(APCuまたは/tmp)
  • 有効期限切れ時は自動再認証

2. 2つの動作モード

🔍 検索モード(デフォルト)

GET /jobs.php?q=営業&limit=10
  • Lucene形式のクエリで検索
  • qパラメータでキーワード指定

📑 一覧モード

GET /jobs.php?list=1&status=Publish
  • コンテンツタイプの全データを取得
  • statusで公開状態を指定(Publish/Draft等)

3. フォールバック機能

一覧モードでは3段階のフォールバック:

1. /contentData (正式API) → 404なら
2. /contents (互換API) → 404なら  
3. /search?q=* (検索APIで代替)

MT環境の違いに柔軟に対応できる設計です。


🛠️ パラメータ一覧

パラメータ 説明 デフォルト
q 検索クエリ(Lucene形式) (空)
limit 取得件数(1-100) 20
offset 開始位置 0
list 一覧モード有効化 false
status 公開状態(一覧時) Publish
site_id サイトID上書き 2
ct_id コンテンツタイプID上書き 3
debug トレース情報付加 false

🔍 診断機能(diag)

デバッグ用の診断エンドポイント:

サイト一覧取得

GET /jobs.php?diag=range
→ サイトID 1-50を探索して存在するサイトを列挙

コンテンツタイプ一覧

GET /jobs.php?diag=cts&site_id=2
→ 指定サイトの全CTを取得

CT詳細情報

GET /jobs.php?diag=ct&site_id=2&ct_id=3
→ CTの構造(フィールド定義等)を取得

サンプルデータ確認

GET /jobs.php?diag=contents&ct_id=3&status=Publish
→ 実際のcontentDataを5件取得

検索APIテスト

GET /jobs.php?diag=search&q=営業&ct_id=3
→ search APIを直接テスト

🔒 セキュリティ対策

  1. クエリ長制限: 1000文字超は拒否(DoS対策)
  2. SSL検証: 証明書を厳格にチェック
  3. タイムアウト: 接続10秒/全体30秒
  4. 型安全: declare(strict_types=1) で型不一致を検出
  5. エラー非表示: display_errors=0 で情報漏洩防止

📤 レスポンス形式

成功時

{
  "items": [
    {
      "id": 123,
      "label": "営業職募集",
      "data": [
        {"label": "職種", "data": "営業"},
        {"label": "給与", "data": "300万円〜"}
      ]
    }
  ],
  "totalResults": 42
}

エラー時

{
  "error": "Authentication failed",
  "upstream": "...",
  "trace": {...}
}

デバッグモード(?debug=1

{
  "items": [...],
  "totalResults": 42,
  "trace": {
    "status": 200,
    "http": {
      "url": "...",
      "headers": [...],
      "hint": "contentData"
    },
    "site_id": "2",
    "ct_id": "3",
    "mode": "search"
  }
}

💡 使用例

基本的な検索

fetch('/jobs.php?q=東京 AND 正社員&limit=20')
  .then(r => r.json())
  .then(data => {
    console.log(`${data.totalResults}件見つかりました`);
    data.items.forEach(job => {
      console.log(job.label);
    });
  });

ページネーション

// 2ページ目を取得(21-40件目)
fetch('/jobs.php?q=営業&limit=20&offset=20')

全件取得

fetch('/jobs.php?list=1&status=Publish&limit=100')

⚙️ 設定方法

1. 環境変数設定(.htaccess)

SetEnv MT_API_USER "your_username"
SetEnv MT_API_PASS "your_password"

2. PHP設定確認

  • cURLが有効か確認
  • APCuが使えればパフォーマンス向上
  • SSL証明書が正しく設定されているか

3. MTのData API設定

  • Data APIが有効化されているか
  • サイトID・コンテンツタイプIDの確認
  • アクセス権限の設定

🐛 トラブルシューティング

エラーが出る場合

GET /jobs.php?debug=1&diag=ct&site_id=2&ct_id=3

これでCTの構造を確認できます。

データが取れない場合

  1. ?diag=range でサイトIDを確認
  2. ?diag=cts&site_id=X でCT IDを確認
  3. ?diag=contents&ct_id=Y でデータの有無確認

認証エラー

  • 環境変数が正しく設定されているか
  • MTのユーザー権限は十分か
  • Data APIが有効化されているか


C. .htaccess(環境変数)

SetEnv MT_API_USER "gajumaro"   # Data APIユーザ名
SetEnv MT_API_PASS "******"      # パスワード(Git管理禁止)

ポイント:Apache + PHP-FPM 環境で SetEnv が有効かはサーバ設定依存。無効な場合は vhost 設定や .user.ini / systemd 環境変数などに切り替えてください。


使い方メモ

  • コードを読んで学ぶ:コメントを目印に該当処理を素早く特定できます。
  • デバッグjobs2.php?debug=1&list=1 でフォールバックの経路やURLが返るので、接続不具合時の一次切り分けに最適。
  • 安全運用:本番では ?diag= を閉じる/IP制限すること。USE_API=false の静的運用は緊急時のセーフモードにもなります。

初心者(私)向け甲斐悦

1) 全体像(何をしているコード?)

  • ブラウザやフロントエンドが jobs.php にアクセス
  • jobs.phpMovable Type Data API v6 に代理でアクセス
  • アクセストークンを取得(5分キャッシュ)して、検索 or 一覧をAPIに投げる
  • 返ってきたJSONを 同じ形(itemstotalResults)に正規化して、フロントへ返す

つまり「フロントから直接MTに触らず、PHPが“門番”になって安全に検索結果を返す」仕組みです。


2) 先に覚えるキーワード(超ざっくり)

  • list モード?list=1 を付けると「一覧取得」。MTの /contentData → だめなら /contents → 最後に /search?q=*三段フォールバック
  • search モード?q=... を付けると「条件検索」。Lucene風のクエリ文字列を q に入れます(例:(field:35_ids:(5 OR 7)) AND (field:16_ids:(8)))。
  • diag(診断)?diag=... を付けると、実際のURLや応答をそのまま見て確認できる“点検モード”。配線・ID間違いを洗いやすいです。
  • トークンキャッシュ:APCu があればメモリ、無ければ /tmp/mt_token.cache401になったら再認証してリトライ

3) ファイルの前半:初期設定と入力の受け取り

declare(strict_types=1);
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-cache, must-revalidate');
ini_set('display_errors', '0');
error_reporting(EALL);
  • strict_types=1:型ミスに気づきやすくする(安全)。
  • JSONで返す宣言キャッシュ抑制(開発中向け)。
  • エラーは画面に出さず(本番向け)ログには全部出す
$MT_BASE = 'https://.../mt-data-api.cgi';
$API_VER = 'v6';
$SITE_ID = '2';  // 既定値(?site_id=3 みたいにURLで上書き可)
$CT_ID   = '3';  // 既定のコンテンツタイプID
$CLIENT  = 'jobs-ui';
  • どのAPIに、どのサイト/CTを叩くかの既定値。
$MT_USER = getenv('MT_API_USER') ?: '';
$MT_PASS = getenv('MT_API_PASS') ?: '';
if (!$MT_USER || !$MT_PASS) { 500エラー ... }
  • 認証用ID/パスワードは 環境変数 から読む(.htaccess やサーバ設定でセット)。
$q      = isset($_GET['q']) ? trim((string)$_GET['q']) : '';
$limit  = isset($_GET['limit']) ? max(1, min(100, (int)$_GET['limit'])) : 20;
$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0;
$list   = isset($_GET['list']);
$status = $_GET['status'] ?? 'Publish';
$debug  = isset($_GET['debug']);
  • クエリを受け取る。limitは1〜100に制限offsetは0以上
  • list があれば一覧モード。statusPublish が既定。
if (isset($_GET['site_id']) && ctype_digit($_GET['site_id'])) $SITE_ID = $_GET['site_id'];
if (isset($_GET['ct_id'])   && ctype_digit($_GET['ct_id']))   $CT_ID   = $_GET['ct_id'];
  • URLで site/ctを上書きできる(診断や開発で便利)。
if (strlen($q) > 1000) { 400 "Query too long" }
  • DoS対策:極端に長い q は拒否。

4) 共通の小道具(ユーティリティ)

function send(int $code, array $payload) { ... }
  • HTTPコードJSON本文をまとめて返す関数。いつでも終了。
function httpRequest($method,$url,$headers=[],$body=null,$debug=false): array { ... }
  • cURLでMT APIにアクセス。Accept: application/jsongzip対応TLS検証ON
  • タイムアウト:接続10秒・全体30秒。
  • debug=true の時は、URLやヘッダなどのトレース情報を一緒に返す

5) 認証(アクセストークンの取得とキャッシュ)

function token_get_cached(): ?string { ... } // APCu or /tmp から取り出し
function token_set_cached(string $token, int $ttl=300): void { ... } // 保存
  • トークンは 5分(300秒) キャッシュ。期限ギリギリ30秒を切ってたら無効扱いにする実装。
function authenticate($base,$ver,$user,$pass,$client,$debug=false): string {
  if ($tok = token_get_cached()) return $tok;
  $url  = "{$base}/{$ver}/authentication";
  $body = http_build_query(['username'=>$user,'password'=>$pass,'clientId'=>$client]);
  [$code,$res,$trace] = httpRequest('POST', $url, ['Content-Type: application/x-www-form-urlencoded'], $body, $debug);
  if ($code !== 200) send($code, ['error'=>'Authentication failed', 'upstream'=>$res, 'trace'=>$trace]);
  $j = json_decode($res,true);
  if (!isset($j['accessToken'])) send(500, ['error'=>'Invalid auth response', ...]);
  token_set_cached($j['accessToken'], 300);
  return $j['accessToken'];
}
  • 初回はPOSTでトークン発行キャッシュ→以降は使い回し。
  • 形式が想定外/失敗なら、そのまま上流レスポンスも返すので原因が追いやすい。

6) 実際のAPI呼び出し

(A) 検索モード:call_search()

$params = [
  'search'=>'contentData','cdSearch'=>1,
  'site_id'=>$siteId,'SearchContentTypes'=>$ctId,
  'limit'=>$limit,'offset'=>$offset
];
if ($q !== '') $params['q'] = $q; // ← qが空なら付けない
$url = "{$base}/{$ver}/search?".http_build_query($params);
headers = ['X-MT-Authorization: MTAuth accessToken='.$token];
  • /v6/search を使う。q が空のときは パラメータ自体を付けない(環境差・未対応対策)。
  • 401(期限切れ)時は、下で再認証→即リトライします。

(B) 一覧モード:call_list()

  1. 正式:/sites/{sid}/contentTypes/{ctid}/contentData
  2. 旧互換:/sites/{sid}/contentTypes/{ctid}/contents
  3. 最終:/search?cdSearch=1&q=*全件相当

404でなければその結果を採用します。つまり「使える最初の経路」を自動的に選びます。


7) 診断機能(?diag=...

つまずきやすい設定やIDの確認に超便利debug=true 扱いになり、traceが返ります。

  • diag=range/sites/1..50 を総当たりして、存在するサイトIDを列挙
  • diag=cts/sites/{sid}/contentTypesコンテンツタイプ一覧
  • diag=ct/sites/{sid}/contentTypes/{cid}CT詳細(フィールドID確認に使える)
  • diag=contents:指定 site/ct の contentData試し取得
  • diag=search:任意の qそのまま叩いて確認

※注意(小さなバグ)
あなたの貼ってくれたコードの diag=cts / ct / contents の URL で、
"{$MT_BASE}/{$API_VER}/sites/{\$sid}/..." のように \$ が入っています
正しくは {$sid} / {$cid} です(バックスラッシュ不要)。修正してください。


8) メイン処理(try/catch の中)

$token = authenticate(...); // まずトークン

if ($list) {
  [$code,$res,$trace] = call_list(...);
} else {
  [$code,$res,$trace] = call_search(...);
  if ($code === 401) { // 期限切れ対策
    $token = authenticate(...);
    [$code,$res,$trace] = call_search(...);
  }
}
  • モードで分岐。検索モードは必ず401の再試行を持つ(親切設計)。
$j = json_decode($res, true);
if (!is_array($j)) send(502, ['error'=>'Invalid JSON from MT', ...]);
  • 上流のJSON体裁チェック。ダメなら 502 で上流そのまま返すので原因が見える。
// 正規化(items/totalResults に揃える)
$items = $j['items'] ?? $j['data'] ?? [];
$total = $j['totalResults'] ?? $j['totalResultsCount'] ?? ($j['count'] ?? count($items));

$out = ['items'=>$items, 'totalResults'=>$total];
if ($debug) $out['trace'] = [...];
send($code, $out);
  • 返りの形がAPIによって微妙に違うので、どれが来ても同じ形で返します。
  • debug=1 のときは trace も含めて返す。

9) 使い方(URLの例)

  • 一覧(公開のみ、20件ずつ)

    /jobs.php?list=1&status=Publish&limit=20&offset=0
    
  • 検索(Lucene風クエリを q に)

    /jobs.php?q=(field:35_ids:(5%20OR%207))%20AND%20(field:16_ids:(8))&limit=20&offset=0
    
    • %20 はスペースのURLエンコードです
  • 診断(CTの存在確認)

    /jobs.php?diag=ct&site_id=2&ct_id=3
    
  • デバッグ(trace付きで返す)

    /jobs.php?list=1&debug=1
    

10) q を作るコツ(初心者向け)

  • 同じ項目の中で複数選択:OR

    • 例)都道府県ID 5 または 7 → (field:35_ids:(5 OR 7))
  • 違う項目どうしの組み合わせ:AND

    • 例)上に加えて、雇用形態ID 8 →
      (field:35_ids:(5 OR 7)) AND (field:16_ids:(8))
  • 全部空ならq を付けない(あなたのPHPはそう処理する)
    全件を見たいときは ?list=1 を使うのが簡単

(JS側で使うなら、前にお渡しした buildLuceneByIds() 関数がそのまま使えます)


11) サーバ設定例(環境変数)

  • Apache(.htaccess)

    SetEnv MT_API_USER "admin@example.com"
    SetEnv MT_API_PASS "your-strong-password"
    
  • nginx + php-fpm の場合は fastcgi_param 等で環境変数を渡すか、
    あるいは .env を読ませて getenv() させる実装に変える。


12) デバッグのやり方(順番)

  1. ?diag=ct&site_id=2&ct_id=3 が 200 になるか
    → サイトID/CTIDがそもそも正しいか確認
  2. ?diag=cts&site_id=2 で CT の一覧が見えるか
    → フィールドIDを目視で拾える(UI上のIDと一致確認)
  3. ?list=1&debug=1 で経路(contentData/contents/search)がどれになったか確認
  4. ?diag=search&q=... でクエリを素で叩いて挙動確認
  5. ?debug=1 を付けて、実際に叩いたURL・ヘッダが trace に出るか確認

13) よくある落とし穴・対策

  • diagのURL内の {$sid}{\$sid} になっている(typo)
    バックスラッシュを削除して {$sid} / {$cid} に直す。

  • CORSで止まる(フロントが別ドメイン)
    jobs.phpAccess-Control-Allow-Origin 等のヘッダを付けるか、同一オリジンから呼ぶ。

  • APCuが無い
    → 自動で /tmp/mt_token.cache に落ちるのでOK。権限だけ注意。

  • 401が出る
    → 検索時は自動で再認証→再試行される。出続けるなら ユーザー/パス を再確認。

  • q が長すぎて 400
    1000文字を超えると拒否(DoS対策)。選択数を絞る or サーバ側にID配列をPOSTして組み立てる。

  • status の綴り
    Publish(P大文字)推奨。All/all もコード上は通すが、環境により挙動差がある場合あり。


14) 発展:フロント実装のヒント

  • ページングlimitoffset をフロントで管理(例:offset = page * limit)。
  • ローディング/リトライstatus!==200 のときは trace を見せるとユーザーにも親切(debug=1時)。
  • qの生成:UIのチェックボックス群 → ID配列 → buildLuceneByIds()encodeURIComponent(q)/jobs.php?q=...

0
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
0
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?