テクニック

WordPress採用サイトの作り方|求人カスタム投稿タイプの実装手順

この記事の対象: WordPressで採用サイトを新規構築・改修する制作者、社内でWeb担当を兼ねている人事担当者
読了時間: 約12分

採用サイトをWordPressで作るとき、コーポレートサイトと同じ作り方をすると必ず行き詰まります。求人情報は「募集職種」「雇用形態」「勤務地」「給与」といった構造を持ったデータで、しかも募集の開始と終了で出したり下げたりを繰り返します。固定ページで1枚ずつ作れば、更新のたびにHTMLを触ることになります。この記事では、カスタム投稿タイプとカスタムフィールドで求人情報を管理し、募集終了の扱いまで含めて運用が回る形にする手順を、コード付きで解説します。費用や外注判断の話には触れず、実装の中身だけを扱います。


WordPressで採用サイトを作るときの設計方針

求人情報は固定ページで作らない

まず判断を1つ済ませます。求人情報を固定ページ(page)で作るか、カスタム投稿タイプで作るか。答えは後者です。

固定ページで作ると、次の3つが全部手作業になります。

  • 求人一覧ページへの追加(新しい募集が増えたらHTMLかブロックを手で足す)
  • 募集終了時の処理(ページを消すか、下書きに戻すか、毎回判断が要る)
  • 条件での絞り込み(「東京」「正社員」で絞る機能が作れない)

カスタム投稿タイプにしておけば、一覧はループで出るので追加作業がゼロになります。絞り込みもタクソノミーのアーカイブで済みます。

求人情報の管理方法による差

カスタム投稿タイプ(推奨)

  • 一覧は自動生成される
  • 雇用形態・勤務地で絞り込める
  • 募集終了をステータスで扱える
  • 構造化データを一括で出せる

固定ページで1枚ずつ

  • 一覧に手で追記が必要
  • 絞り込み機能が作れない
  • 終了処理の手順が属人化する
  • 1件ずつ構造化データを書く

初期の実装コストは前者のほうがやや大きくなりますが、募集職種が増えていくフェーズに入ればすぐに回収できます。逆に「募集は年に1回、1職種だけ」と決まっているなら固定ページで十分です。判断の分かれ目は職種数の増減があるかどうかです。

分けるもの・まとめるものを決める

採用サイトは中身が複数の性質に分かれます。設計の最初にここを整理しておくと、後から投稿タイプを増やす手戻りが出ません。

採用サイトのコンテンツ構造

カスタム投稿タイプ: recruit(求人)

  • タクソノミー: 職種
  • タクソノミー: 雇用形態
  • タクソノミー: 勤務地

カスタム投稿タイプ: interview(社員インタビュー)

  • タクソノミー: 所属部署

固定ページ

  • 数字で見る会社
  • 福利厚生・制度
  • 選考フロー
  • エントリー

求人とインタビューを同じ投稿タイプにまとめたくなりますが、分けます。求人には「給与」「勤務時間」といったフィールドが要り、インタビューには要りません。同じ投稿タイプに両方のフィールドを持たせると、編集画面に使わない欄が並んで運用側が混乱します。

福利厚生や選考フローは増減しないので固定ページで構いません。「数字で見る会社」のような更新頻度が低いものも同じです。


求人情報をカスタム投稿タイプで実装する

投稿タイプとタクソノミーの登録

子テーマの functions.php に書きます。有料テーマを使っている場合、親テーマを直接編集すると更新で消えるので必ず子テーマです。

<?php
/**
 * 求人情報のカスタム投稿タイプとタクソノミーを登録
 */
function recruit_register_post_type() {
    register_post_type( 'recruit', array(
        'label'         => '求人情報',
        'public'        => true,
        'has_archive'   => true,
        'menu_position' => 5,
        'menu_icon'     => 'dashicons-groups',
        'supports'      => array( 'title', 'editor', 'thumbnail', 'revisions' ),
        'rewrite'       => array( 'slug' => 'recruit', 'with_front' => false ),
        'show_in_rest'  => true, // ブロックエディタとREST APIで使う
    ) );

    // 職種
    register_taxonomy( 'recruit_job', 'recruit', array(
        'label'             => '職種',
        'hierarchical'      => true, // 親子を持たせる(技術職 > フロントエンド)
        'public'            => true,
        'show_admin_column' => true,
        'rewrite'           => array( 'slug' => 'recruit/job', 'with_front' => false ),
        'show_in_rest'      => true,
    ) );

    // 雇用形態
    register_taxonomy( 'recruit_type', 'recruit', array(
        'label'             => '雇用形態',
        'hierarchical'      => true,
        'public'            => true,
        'show_admin_column' => true,
        'rewrite'           => array( 'slug' => 'recruit/type', 'with_front' => false ),
        'show_in_rest'      => true,
    ) );

    // 勤務地
    register_taxonomy( 'recruit_area', 'recruit', array(
        'label'             => '勤務地',
        'hierarchical'      => true,
        'public'            => true,
        'show_admin_column' => true,
        'rewrite'           => array( 'slug' => 'recruit/area', 'with_front' => false ),
        'show_in_rest'      => true,
    ) );
}
add_action( 'init', 'recruit_register_post_type' );

実装のポイント

hierarchical => true にしているのは、タグ型(false)だと管理画面で自由入力になり、「正社員」「正社員(総合職)」のような表記ゆれが入るからです。チェックボックス型にすれば選ぶだけになります。採用情報は表記の統一が効くので、3つとも true にしています。

show_admin_column => true を入れると、求人一覧の管理画面に職種・雇用形態・勤務地の列が出ます。募集件数が増えたときに一覧で状況が見えるかどうかは運用の負担に直結します。

つまずきやすい点

register_post_type を足した直後は個別ページが404になります。管理画面の「設定 > パーマリンク」を開いて保存ボタンを押すとリライトルールが再生成されて直ります。開発中に何度も踏むので覚えておいてください。

もう1つ、rewrite の with_front => false を省略すると、投稿のパーマリンク設定に /blog/ などの接頭辞が入っている場合に /blog/recruit/... というURLになります。意図しないURLになるので明示的に切ります。

一覧テンプレートの作り方は別記事で手順を通してあるので、アーカイブ側の実装はそちらを読んでください。

カスタムフィールドで求人条件を持たせる

給与・勤務時間・応募資格などは本文に書かず、フィールドに分けます。分ける理由は3つあります。一覧に条件を出せる、構造化データに流せる、運用側が入力漏れに気づける。

プラグインを使うなら ACF(Advanced Custom Fields)が定番ですが、依存を増やしたくない場合は標準機能だけで書けます。

<?php
/**
 * 求人条件のメタボックスを追加
 */
function recruit_add_meta_box() {
    add_meta_box(
        'recruit_detail',
        '募集条件',
        'recruit_meta_box_html',
        'recruit',
        'normal',
        'high'
    );
}
add_action( 'add_meta_boxes', 'recruit_add_meta_box' );

// 扱うフィールドの定義(ここを足せば項目が増える)
function recruit_fields() {
    return array(
        'salary'      => '給与',
        'work_hours'  => '勤務時間',
        'holidays'    => '休日・休暇',
        'requirement' => '応募資格',
        'benefits'    => '待遇・福利厚生',
        'deadline'    => '応募締切(YYYY-MM-DD)',
    );
}

function recruit_meta_box_html( $post ) {
    wp_nonce_field( 'recruit_save_meta', 'recruit_meta_nonce' );
    echo '<table class="form-table">';
    foreach ( recruit_fields() as $key => $label ) {
        $value = get_post_meta( $post->ID, '_recruit_' . $key, true );
        printf(
            '<tr><th><label for="recruit_%1$s">%2$s</label></th>
             <td><textarea id="recruit_%1$s" name="recruit_%1$s" rows="3" class="large-text">%3$s</textarea></td></tr>',
            esc_attr( $key ),
            esc_html( $label ),
            esc_textarea( $value )
        );
    }
    echo '</table>';
}

/**
 * 保存処理
 */
function recruit_save_meta( $post_id ) {
    // 自動保存・リビジョンでは走らせない
    if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
        return;
    }
    // nonce検証
    if ( ! isset( $_POST['recruit_meta_nonce'] )
        || ! wp_verify_nonce( $_POST['recruit_meta_nonce'], 'recruit_save_meta' ) ) {
        return;
    }
    // 権限確認
    if ( ! current_user_can( 'edit_post', $post_id ) ) {
        return;
    }

    foreach ( array_keys( recruit_fields() ) as $key ) {
        $name = 'recruit_' . $key;
        if ( isset( $_POST[ $name ] ) ) {
            update_post_meta(
                $post_id,
                '_recruit_' . $key,
                sanitize_textarea_field( wp_unslash( $_POST[ $name ] ) )
            );
        }
    }
}
add_action( 'save_post_recruit', 'recruit_save_meta' );

実装のポイント

フィールド定義を recruit_fields() に切り出しているのは、項目を足すときに1か所だけ直せばHTMLも保存処理も追随するからです。メタボックスの実装でありがちなのが、HTMLと保存処理に同じキー名を2回書いてしまい、片方だけ直して保存されなくなる事故です。

メタキーに _recruit_ のアンダースコア接頭辞を付けているのは、カスタムフィールド欄(標準の「カスタムフィールド」UI)に出さないためです。専用のメタボックスと標準UIの両方に同じ項目が出ると、どちらで編集したか分からなくなります。

save_post_recruit という投稿タイプ付きのフックを使うと、他の投稿タイプの保存時に無駄に走りません。save_post で受けて投稿タイプを判定する書き方より短く済みます。

つまずきやすい点

wp_unslash() を挟まないと、入力した引用符の前にバックスラッシュが残ります。WordPressは $_POST に自動でスラッシュを追加するので、サニタイズの前に必ず外します。これを忘れると「株式会社\”○○\”」のような文字列がDBに入ります。

sanitize_textarea_field() は改行を保持しつつタグを除去します。1行入力なら sanitize_text_field()、HTMLを許可したいなら wp_kses_post() に替えてください。給与欄にHTMLを入れたい要件が出ることは滅多にないので、テキストで十分です。

テンプレート側で出力する

single-recruit.php に条件表を出します。値が空のフィールドは行ごと出さないのが肝です。

<?php
/**
 * single-recruit.php の条件表部分
 */
$fields = array(
    'salary'      => '給与',
    'work_hours'  => '勤務時間',
    'holidays'    => '休日・休暇',
    'requirement' => '応募資格',
    'benefits'    => '待遇・福利厚生',
);
$rows = array();
foreach ( $fields as $key => $label ) {
    $value = get_post_meta( get_the_ID(), '_recruit_' . $key, true );
    if ( '' !== trim( (string) $value ) ) {
        $rows[ $label ] = $value;
    }
}
?>
<?php if ( $rows ) : ?>
<table class="recruit-detail">
  <tbody>
    <?php
    // 職種・雇用形態・勤務地はタクソノミーから
    $terms = array(
        '職種'     => get_the_term_list( get_the_ID(), 'recruit_job', '', '、' ),
        '雇用形態' => get_the_term_list( get_the_ID(), 'recruit_type', '', '、' ),
        '勤務地'   => get_the_term_list( get_the_ID(), 'recruit_area', '', '、' ),
    );
    foreach ( $terms as $label => $html ) {
        if ( ! is_wp_error( $html ) && $html ) {
            printf( '<tr><th>%s</th><td>%s</td></tr>', esc_html( $label ), $html );
        }
    }
    foreach ( $rows as $label => $value ) {
        printf(
            '<tr><th>%s</th><td>%s</td></tr>',
            esc_html( $label ),
            nl2br( esc_html( $value ) )
        );
    }
    ?>
  </tbody>
</table>
<?php endif; ?>

nl2br( esc_html( $value ) ) の順番を逆にしないでください。esc_html() を後にかけると <br /> まで文字列化されて画面に &lt;br /&gt; が見えます。エスケープしてから改行変換、が正しい順序です。

条件表はスマートフォンで横並びのままだと th が潰れます。幅の狭い画面では縦積みに切り替えます。

.recruit-detail {
  width: 100%;
  border-collapse: collapse;
}
.recruit-detail th,
.recruit-detail td {
  padding: 1rem;
  text-align: left;
  vertical-align: top;
  border-bottom: 1px solid #e5e5e5;
}
.recruit-detail th {
  width: 12em;
  font-weight: 600;
  white-space: nowrap;
}

@media (width < 768px) {
  .recruit-detail th,
  .recruit-detail td {
    display: block;
    width: auto;
    white-space: normal;
  }
  .recruit-detail th {
    padding-bottom: 0.25rem;
    border-bottom: none;
    font-size: 0.875rem;
    color: #666;
  }
}

@media (width < 768px) のレンジ構文は主要ブラウザの現行版で動きますが、古い環境を拾う必要があれば max-width: 767px に書き換えてください。


募集終了と応募締切の扱いを実装で決める

終了した求人を消してはいけない

運用で必ず問題になるのがここです。募集が終わったとき、その求人ページをどうするか。選択肢は3つあります。

対応 URLの扱い 向いている場面
投稿を削除 404になる 誤って公開したものだけ
下書きに戻す 404になる 二度と再開しない職種
「募集終了」表示のまま残す 200で残る 定期的に再募集する職種

削除や下書き化を選ぶと、外部サイトや求人媒体から張られたリンクが全部404になります。しかも同じ職種を翌年また募集するなら、URLを作り直すことになって検索評価もゼロから積み直しです。

原則は3番目です。ステータスをフィールドで持ち、終了していれば応募フォームを隠して「この職種は現在募集しておりません」と出す。ページ自体は残します。

募集終了時の処理フロー
締切日を判定deadline を今日と比較する
表示を切り替え応募ボタンを終了メッセージに
一覧から下げるアーカイブの並びを後ろへ
構造化データを外すJobPostingの出力を止める

4段目を忘れると、終了した求人の構造化データが残り続けます。検索側に「まだ募集中」と伝えたままになるので、判定は1つの関数にまとめて全箇所から呼びます。

締切判定を1か所にまとめる

<?php
/**
 * 募集中かどうかを判定する
 * 締切が未入力なら「募集中」として扱う(通年募集を許容)
 *
 * @param int|null $post_id
 * @return bool
 */
function recruit_is_open( $post_id = null ) {
    $post_id  = $post_id ? $post_id : get_the_ID();
    $deadline = get_post_meta( $post_id, '_recruit_deadline', true );

    if ( '' === trim( (string) $deadline ) ) {
        return true;
    }

    // wp_timezone() でサイトのタイムゾーンを使う(UTCで判定するとズレる)
    $tz  = wp_timezone();
    $end = date_create_immutable_from_format( 'Y-m-d', $deadline, $tz );
    if ( ! $end ) {
        return true; // 書式が壊れていたら募集中扱い(誤って隠さない)
    }
    // 締切日は当日いっぱい有効にする
    $end = $end->setTime( 23, 59, 59 );

    return new DateTimeImmutable( 'now', $tz ) <= $end;
}

実装のポイント

wp_timezone() を使うのは、date() や time() だけで比較するとサーバのタイムゾーン(多くはUTC)で判定されて、日本時間の締切日が9時間早く切れるからです。「締切日の朝に見たら終了になっていた」という不具合の原因はほぼこれです。

書式が壊れていた場合に true(募集中)で返しているのは、入力ミスで募集が勝手に隠れるほうが被害が大きいためです。フェイルセーフの向きを間違えないようにします。

テンプレート側では次のように使います。

<?php if ( recruit_is_open() ) : ?>
  <a class="recruit-entry-btn" href="<?php echo esc_url( home_url( '/recruit/entry/' ) ); ?>">
    この職種にエントリーする
  </a>
<?php else : ?>
  <p class="recruit-closed" role="status">
    この職種の募集は終了しました。
    <a href="<?php echo esc_url( get_post_type_archive_link( 'recruit' ) ); ?>">現在募集中の職種を見る</a>
  </p>
<?php endif; ?>

終了表示のときに一覧へのリンクを添えておくのは離脱を防ぐためです。終了メッセージだけで終わると、そこで閲覧が止まります。他の職種に興味を持つ可能性を残します。

一覧の並び順で募集中を先に出す

アーカイブで募集終了を後ろに送るには、meta_query ではなく表示側で仕分けるほうが単純です。締切未入力を許容している設計だと、meta_query の比較が複雑になります。

<?php
/**
 * archive-recruit.php — 募集中と終了を分けて出す
 */
$open   = array();
$closed = array();

if ( have_posts() ) {
    while ( have_posts() ) {
        the_post();
        if ( recruit_is_open() ) {
            $open[] = get_the_ID();
        } else {
            $closed[] = get_the_ID();
        }
    }
}

foreach ( array( '募集中の職種' => $open, '募集を終了した職種' => $closed ) as $heading => $ids ) {
    if ( ! $ids ) {
        continue;
    }
    printf( '<h2>%s</h2><ul class="recruit-list">', esc_html( $heading ) );
    foreach ( $ids as $id ) {
        printf(
            '<li><a href="%s">%s</a></li>',
            esc_url( get_permalink( $id ) ),
            esc_html( get_the_title( $id ) )
        );
    }
    echo '</ul>';
}

1回のループで両方の配列に振り分けているので、クエリは1本のままです。ページネーションを使う場合は、pre_get_posts で posts_per_page を十分に取るか、ページ内での仕分けとして割り切ってください。


求人の構造化データ(JobPosting)を出力する

求人の構造化データ(JobPosting)を出力する

求人検索に拾わせるための最低条件

求人情報は構造化データを入れると求人向けの検索結果に拾われる可能性が出ます。必須項目が決まっているので、フィールド設計をそこに合わせておくのが効率的です。

JobPosting で必須になるのは次の項目です。

  • title — 職種名(会社名や修飾語を入れない)
  • description — 業務内容のHTML
  • datePosted — 掲載日
  • hiringOrganization — 雇用する組織
  • jobLocation — 勤務地の住所

締切(validThrough)は必須ではありませんが、入れると期限切れの扱いが明確になります。

<?php
/**
 * single-recruit.php の <head> 内、または wp_head フックで出力
 */
function recruit_output_jobposting() {
    if ( ! is_singular( 'recruit' ) || ! recruit_is_open() ) {
        return; // 募集終了なら出力しない
    }

    $post_id  = get_the_ID();
    $deadline = get_post_meta( $post_id, '_recruit_deadline', true );
    $salary   = get_post_meta( $post_id, '_recruit_salary', true );

    $data = array(
        '@context'    => 'https://schema.org',
        '@type'       => 'JobPosting',
        'title'       => get_the_title( $post_id ),
        'description' => wp_kses_post( get_post_field( 'post_content', $post_id ) ),
        'datePosted'  => get_the_date( 'c', $post_id ),
        'hiringOrganization' => array(
            '@type' => 'Organization',
            'name'  => get_bloginfo( 'name' ),
            'sameAs' => home_url( '/' ),
        ),
        'jobLocation' => array(
            '@type'   => 'Place',
            'address' => array(
                '@type'           => 'PostalAddress',
                'addressCountry'  => 'JP',
                'addressRegion'   => '東京都',        // 実際の値に差し替える
                'addressLocality' => '千代田区',      // 実際の値に差し替える
                'streetAddress'   => '丸の内1-1-1',   // 実際の値に差し替える
            ),
        ),
    );

    // 雇用形態をタクソノミーから拾ってマッピングする
    $map = array(
        '正社員'     => 'FULL_TIME',
        '契約社員'   => 'CONTRACTOR',
        'アルバイト' => 'PART_TIME',
        'パート'     => 'PART_TIME',
        'インターン' => 'INTERN',
    );
    $types = wp_get_post_terms( $post_id, 'recruit_type', array( 'fields' => 'names' ) );
    if ( ! is_wp_error( $types ) ) {
        $mapped = array();
        foreach ( $types as $name ) {
            if ( isset( $map[ $name ] ) ) {
                $mapped[] = $map[ $name ];
            }
        }
        if ( $mapped ) {
            $data['employmentType'] = array_values( array_unique( $mapped ) );
        }
    }

    if ( '' !== trim( (string) $deadline ) ) {
        $data['validThrough'] = $deadline . 'T23:59:59+09:00';
    }

    printf(
        '<script type="application/ld+json">%s</script>' . "\n",
        wp_json_encode( $data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES )
    );
}
add_action( 'wp_head', 'recruit_output_jobposting' );

実装のポイント

employmentType はマッピング表を通しています。日本語のタクソノミー名をそのまま入れても解釈されないので、schema.org の列挙値に変換します。マッピングに無い名前は黙って落としているので、タクソノミーに新しい雇用形態を追加したら表も足してください。

recruit_is_open() で早期 return しているのが前の節と繋がる部分です。終了した求人の構造化データを出し続けると、実態と合わない情報を検索側に渡し続けることになります。

つまずきやすい点

description には業務内容の説明が入っている必要があります。本文が「詳細は面談でお伝えします」の1行しかないと、必須項目は満たしていても中身が空に近く、求人として扱われない可能性があります。構造化データを入れる前に、本文が最低限の情報量を持っているか見てください。

jobLocation の住所をハードコードしているのは、自社1拠点の採用サイトを想定しているためです。複数拠点なら勤務地タクソノミーに住所をタームメタで持たせて引きます。

JSON_UNESCAPED_UNICODE を付けないと日本語が \uXXXX に変換されます。動作はしますが、検証ツールで目視確認するときに読めません。

構造化データとAI検索の関係については、考え方を別記事にまとめています。


採用サイトのUI実装でよく求められるもの

採用サイトのUI実装でよく求められるもの

職種一覧の絞り込みをJSなしで作る

「勤務地で絞りたい」「雇用形態で絞りたい」は必ず要望に出ます。タクソノミーを登録してあれば、アーカイブページへのリンクを並べるだけで実装できます。JavaScriptは不要です。

<?php
/**
 * タクソノミーの絞り込みリンクを出す
 */
function recruit_filter_links( $taxonomy, $label ) {
    $terms = get_terms( array(
        'taxonomy'   => $taxonomy,
        'hide_empty' => true, // 求人が0件のタームは出さない
    ) );
    if ( is_wp_error( $terms ) || ! $terms ) {
        return;
    }

    $current = get_queried_object();
    printf( '<div class="recruit-filter"><p class="recruit-filter__label">%s</p><ul>', esc_html( $label ) );
    printf(
        '<li><a href="%s"%s>すべて</a></li>',
        esc_url( get_post_type_archive_link( 'recruit' ) ),
        is_post_type_archive( 'recruit' ) ? ' aria-current="page"' : ''
    );
    foreach ( $terms as $term ) {
        $is_current = ( $current instanceof WP_Term && $current->term_id === $term->term_id );
        printf(
            '<li><a href="%s"%s>%s</a></li>',
            esc_url( get_term_link( $term ) ),
            $is_current ? ' aria-current="page"' : '',
            esc_html( $term->name )
        );
    }
    echo '</ul></div>';
}

hide_empty => true が効いています。募集していない職種のリンクが並んで、押したら0件のページが出るのが一番まずい状態です。

aria-current="page" を入れているのは、現在絞り込んでいる条件を支援技術にも伝えるためです。色だけで現在地を示すと、読み上げ環境では区別できません。CSSも aria-current を選択子に使えば、クラス名を足さずに済みます。

.recruit-filter a[aria-current="page"] {
  background: #1a1a1a;
  color: #fff;
}

複数条件のAND絞り込み(「東京」かつ「正社員」)が要件に入るときは、タクソノミーアーカイブでは足りません。pre_get_posts でクエリパラメータを受けて tax_query を組む形になります。ただし要件として本当に必要かは先に確認したほうがいいです。募集職種が少ないサイトでは、絞り込みを使わず全件スクロールされる傾向があります。

社員インタビューのカードとエントリーフォーム

インタビュー記事のカード一覧は、画像の比率が揃っていないと崩れます。撮影した写真の縦横がばらつくのは避けられないので、CSSで固定します。

エントリーフォームは Contact Form 7 や Snow Monkey Forms のようなプラグインで作るのが普通ですが、プラグインが出力するHTMLはそのままでは入力しづらいことが多いです。ラベルとエラー表示の設計は自前で整えます。

採用サイトのフォームで特に効くのは次の3点です。

  • 必須項目を色以外でも示す(「必須」のテキストを入れる)
  • autocomplete 属性を入れる(name・email・tel の入力が一気に楽になる)
  • エラーを入力欄の直上に出す(下に出すとキーボード表示で隠れる)
<div class="form-row">
  <label for="entry-name">お名前 <span class="req">必須</span></label>
  <p class="form-error" id="entry-name-error" role="alert" hidden>お名前を入力してください</p>
  <input type="text" id="entry-name" name="entry-name"
         autocomplete="name" required
         aria-describedby="entry-name-error">
</div>

aria-describedby でエラーと入力欄を紐づけておくと、フォーカスを当てたときにエラー内容が読み上げられます。視覚的に赤枠を付けるだけでは伝わらない情報です。

スマートフォンでの追従エントリーボタン

採用サイトはスマートフォンからの閲覧比率が高くなるので、画面下部に固定のエントリーボタンを置く実装をよく求められます。

.recruit-fixed-cta {
  position: sticky;
  bottom: 0;
  z-index: 10;
  padding: 0.75rem 1rem;
  padding-bottom: calc(0.75rem + env(safe-area-inset-bottom));
  background: rgb(255 255 255 / 0.95);
  backdrop-filter: blur(8px);
  border-top: 1px solid #e5e5e5;
}

@media (width >= 768px) {
  .recruit-fixed-cta {
    position: static;
    padding-bottom: 0.75rem;
    background: none;
    backdrop-filter: none;
    border-top: none;
  }
}

env(safe-area-inset-bottom) を足しているのは、ホームボタンのない iPhone でボタンが下端に食い込むのを避けるためです。これを入れないと、実機で見たときにタップしづらくなります。

position: sticky が効かない場合、原因はほぼ祖先要素の overflow です。テーマ側のコンテナに overflow: hidden が入っていると無効になります。


公開前に確認する項目

実装が終わってから見落としが出やすい箇所をまとめます。採用サイトは応募という明確なゴールがあるので、そこに至る経路が全部通っているかを確認します。

確認項目 見る場所 落ちていたときの影響
個別ページが404にならない /recruit/xxx/ を直接開く パーマリンク未保存のまま公開
一覧に全件出る アーカイブページ posts_per_page の既定10件で途切れる
締切判定が日本時間 締切を今日の日付にして確認 1日早く募集が終わる
構造化データが通る リッチリザルトテストで検証 求人検索に拾われない
終了求人に構造化データが出ない 過去日の締切を入れて確認 実態と違う情報を出し続ける
フォームの送信先 テスト送信して着信を見る 応募が誰にも届かない
noindex が残っていない 検索避け設定とテーマ設定 公開したのに出てこない

一番怖いのは最後の2つです。フォームの送信先がテスト用アドレスのまま公開されたケースと、開発中の noindex が残ったままのケースは、どちらも「動いているように見える」ので発覚が遅れます。公開日に必ずテスト送信を1本通してください。

求人検索向けの構造化データは、Search Console のインデックス作成レポートに求人情報の項目が出るかでも確認できます。ただし反映に時間がかかるので、公開直後の確認はリッチリザルトテストで済ませます。

採用サイト全体の必要性判断や予算の考え方については、別の記事で整理しています。実装に入る前の段階で迷っているなら、先にそちらを読んでください。


よくある質問

WordPressの採用サイトはプラグインだけで作れますか

求人管理プラグインを使えばカスタム投稿タイプの登録とフィールド追加は省けます。ただし出力されるHTMLとCSSはプラグインの仕様に縛られるので、デザインに合わせた表示を作りたいならテンプレートの上書きが必要です。結果として実装量はあまり減りません。フィールド構成を自分で決めたいなら、この記事のように functions.php に書くほうが後の改修が楽です。

募集が終わった求人ページは削除すべきですか

削除しないほうが無難です。外部の求人媒体やSNSから張られたリンクが404になり、同じ職種を再募集するときにURLを作り直すことになります。ページは残したまま応募ボタンを終了表示に切り替え、構造化データの出力だけ止めるのが実装としても運用としても安全です。

求人情報の構造化データを入れると必ず求人検索に出ますか

出ません。構造化データは「検索側が理解できる形で情報を渡す」ためのもので、掲載を保証する仕組みではありません。必須項目を満たしていても、業務内容の記述が薄い場合や、サイト全体の評価が低い場合は拾われないことがあります。まず必須項目を正しく満たし、業務内容を具体的に書くところから始めてください。

採用サイトはコーポレートサイトの中に作るべきか、別ドメインにすべきか

同じドメインのサブディレクトリ(/recruit/)に置くのが基本です。別ドメインにすると検索評価を新規から積み直すことになり、会社名で検索したときに採用サイトが出にくくなります。デザインを大きく変えたい場合でも、WordPressなら投稿タイプ単位でテンプレートとCSSを切り替えられるので、別サイトにする必要はありません。

社員インタビューは何本くらい必要ですか

本数より、読む人が自分と重ねられる人が載っているかどうかが効きます。新卒採用なら入社1〜3年目、中途なら同じ職種の人が載っているほうが、役員インタビューだけを並べるより届きます。実装側では本数が増えても崩れないカード一覧を作っておき、あとから追加できる状態にしておけば十分です。


まとめ

WordPressで採用サイトを作るときの要点は3つです。

  1. 求人はカスタム投稿タイプで管理する — 固定ページで作ると一覧・絞り込み・終了処理が全部手作業になる
  2. 募集終了をページ削除で処理しない — ステータス判定を1つの関数にまとめ、表示・並び順・構造化データの3箇所から呼ぶ
  3. 締切判定は必ずサイトのタイムゾーンで行う — wp_timezone() を挟まないと日本時間の締切が9時間早く切れる

この3つを最初の設計で決めておけば、募集が増えても職種が入れ替わっても、テンプレートを触らずに運用が回ります。逆に固定ページで作り始めてしまうと、募集職種が増えたあたりで作り直しになります。着手前に構造を決めておくのが一番効きます。