求人一覧(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=trueでjobs2.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 が動作し、
.htaccessにSetEnvが使えること。
フィールド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 の
contentDataはdataにフィールド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/authenticationにusername/password/clientIdを POST。 - 成功時の
accessTokenを APCu もしくは /tmp ファイル に5分キャッシュ。 - 401 が来たら 再認証→再試行。
4.3 一覧フォールバック戦略(堅牢化)
-
/v6/sites/{site}/contentTypes/{ct}/contentData(正式) - 404 の場合
/contents(互換) - それも404なら
/search?search=contentData&q=*(最終手段)
4.4 診断エンドポイント(開発時のみ使用)
-
?diag=range:1..50 の site を舐めて存在確認。 -
?diag=cts:指定 site の CT 一覧。 -
?diag=ct:CT 詳細(フィールド構成など)。 -
?diag=contents:contentDataの 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. フロント実装:コードの読み方(行動原理)
-
初期化:
USE_APIをログ出し →loadData()実行。 -
静的モード:
#jobDataの JSON をJSON.parse→filteredJobs = allJobs。 -
APIモード:
searchJobs('')を呼び、一覧モードで items を取得 →normalizeFromApi。 -
フォーム操作:
submitまたはchange(checkbox)でperformFilter()。 -
フィルタ:
- 静的:ラベル配列で
some/includes。 - API:
buildLuceneByIds()でqを生成 → サーバ検索結果をまるごと置換。
- 静的:ラベル配列で
-
描画:
renderResults()で件数、記事カード、renderPager()でページボタン生成。 -
安全性:
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. 運用フロー(おすすめ)
-
開発開始:
USE_API=falseで静的JSON版を完成 → 画面仕様を固める。 -
Data API 準備:
jobs2.php設置、.htaccessでSetEnv、?diag=で接続検証。 -
検索インデックス再構築:CT/サイトを再構築 →
searchJobs('')で一覧が返るか確認。 -
切替:
USE_API=trueに変更。ラベルではなく IDベース検索 に移行。 -
本番:
?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 形を把握して正規化を調整。
Tip:
debug=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で一覧が出る
-
.htaccessでMT_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以外のコードで確定)
-
/sites/{sid}/contentTypes/{ctid}/contentData?status=...(正式) -
/sites/{sid}/contentTypes/{ctid}/contents(旧名互換) -
/search?cdSearch=1&q=*(最終フォールバック、全件相当)
-
-
statusはPublishが既定(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:
totalResults→totalResultsCount→countの順で拾う。
どれもなければ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を直接テスト
🔒 セキュリティ対策
- クエリ長制限: 1000文字超は拒否(DoS対策)
- SSL検証: 証明書を厳格にチェック
- タイムアウト: 接続10秒/全体30秒
-
型安全:
declare(strict_types=1)で型不一致を検出 -
エラー非表示:
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の構造を確認できます。
データが取れない場合
-
?diag=rangeでサイトIDを確認 -
?diag=cts&site_id=XでCT IDを確認 -
?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.phpが Movable Type Data API v6 に代理でアクセス - アクセストークンを取得(5分キャッシュ)して、検索 or 一覧をAPIに投げる
- 返ってきたJSONを 同じ形(
itemsとtotalResults)に正規化して、フロントへ返す
つまり「フロントから直接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.cache。401になったら再認証してリトライ。
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があれば一覧モード。statusはPublishが既定。
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/json、gzip対応、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()
- 正式:
/sites/{sid}/contentTypes/{ctid}/contentData - 旧互換:
/sites/{sid}/contentTypes/{ctid}/contents - 最終:
/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))
- 例)都道府県ID 5 または 7 →
-
違う項目どうしの組み合わせ:AND
- 例)上に加えて、雇用形態ID 8 →
(field:35_ids:(5 OR 7)) AND (field:16_ids:(8))
- 例)上に加えて、雇用形態ID 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) デバッグのやり方(順番)
-
?diag=ct&site_id=2&ct_id=3が 200 になるか
→ サイトID/CTIDがそもそも正しいか確認 -
?diag=cts&site_id=2で CT の一覧が見えるか
→ フィールドIDを目視で拾える(UI上のIDと一致確認) -
?list=1&debug=1で経路(contentData/contents/search)がどれになったか確認 -
?diag=search&q=...でクエリを素で叩いて挙動確認 -
?debug=1を付けて、実際に叩いたURL・ヘッダがtraceに出るか確認
13) よくある落とし穴・対策
-
✅ diagのURL内の
{$sid}が{\$sid}になっている(typo)
→ バックスラッシュを削除して{$sid}/{$cid}に直す。 -
✅ CORSで止まる(フロントが別ドメイン)
→jobs.phpでAccess-Control-Allow-Origin等のヘッダを付けるか、同一オリジンから呼ぶ。 -
✅ APCuが無い
→ 自動で/tmp/mt_token.cacheに落ちるのでOK。権限だけ注意。 -
✅ 401が出る
→ 検索時は自動で再認証→再試行される。出続けるなら ユーザー/パス を再確認。 -
✅
qが長すぎて 400
→ 1000文字を超えると拒否(DoS対策)。選択数を絞る or サーバ側にID配列をPOSTして組み立てる。 -
✅
statusの綴り
→Publish(P大文字)推奨。All/allもコード上は通すが、環境により挙動差がある場合あり。
14) 発展:フロント実装のヒント
-
ページング:
limitとoffsetをフロントで管理(例:offset = page * limit)。 -
ローディング/リトライ:
status!==200のときはtraceを見せるとユーザーにも親切(debug=1時)。 -
qの生成:UIのチェックボックス群 → ID配列 →
buildLuceneByIds()→encodeURIComponent(q)→/jobs.php?q=...