ぽっぺんしゃんにょろりんこ

匿名・非追跡型アダルト動画検索エンジンの設計ノート

検索機能をAPIに切り出して軽量化!個人製検索エンジンで試した責務分離の実践例

こんにちは、にょろりんこの備忘録的技術ブログです。

今回は、個人で開発・運用している検索エンジン「SaePorns」の内部構成を見直した話です。

検索機能のコードが次第に肥大化してきたため、思い切って検索処理を外部APIとして分離し、処理の責任を整理してみました。フロント側はより軽く、API側は再利用しやすくなり、結果として保守性やスケーラビリティも向上しました。

ピースするティアラ

この記事では、実際にどのように切り分けたのか、PHPスクリプトの変更点を具体的に紹介していきます。

そもそもAPI化とは何か?

APIという言葉はなんとなく聞いたことがある方も多いと思いますが、今回の文脈でいう「API化」とは、検索機能の中核部分を独立した処理として外に切り出すことを指します。もっとざっくり言えば、「検索の中身だけを、別の入口(URL)で呼び出せるようにする」ということです。

もともと私の検索エンジンでは、検索語の受け取りから、データベースのクエリ、結果の整形、画面への出力、さらには検索語の出現回数を更新する処理まで、すべてをひとつのPHPスクリプトに詰め込んでいました。最初はそれでも動きます。でも、使い込んでいくうちにコードはどんどん肥大化し、ちょっとした変更でもあちこちに影響が出るようになっていきます。

こうなると、たとえば「検索結果の取得だけを別の機能でも使いたい」と思っても、再利用が難しい。保守も面倒。そして、表示に必要のない処理まで毎回走ることで、ページの表示も遅くなってしまいます。

こりゃいかんと思い導入した手法が「API化」でした。検索語を受け取る処理、検索結果を取得する処理、検索語の出現回数を更新する処理、それぞれを役割ごとに責務分離をし、検索結果の取得だけを api540-fetch.php という専用のAPIに切り出してしまうことで、コードの見通しも良くなり、無駄な処理を避けることができるようになります。

API化は、いわば「やるべきことだけを、必要なときに呼び出す」ための設計です。個人開発でもじゅうぶんに使えるし、むしろ一人だからこそ、長期的にコードが膨らみすぎないように、こういった切り分けが効いてきます。

API化すると、なぜ軽くなるのか?

ここまで読んでお気づきの方もいっしゃると思うのですが、検索結果の取得を api540-fetch.php という別のエンドポイントに切り出すことで、フロント側はただ「結果を受け取る」だけに専念できます。つまり、表示用の処理と、データ処理の責任が分かれた状態になります。

つまり、やらなくていいことをやらない/後回しにできるわけですね。

従来の構成では、検索ページが表示されるたびに、サーバーは検索語の処理・DBへの問い合わせ・出現回数の記録などを全部まとめて一気に処理していました。その結果、画面が表示されるまでに余計な処理が走り、待ち時間が増えてしまうケースもあります。

こういったケースを避けるために、必要な処理だけをAPI化(責務分離)し、軽量&高速の両方を実現しようという試みなわけです。

まずは、責務分離される側(メイン側)のコード

まずは、検索結果を取得する api540-fetch.php を呼び出している側、つまりメインの shinphp540.php から見ていきます。

このファイルでは、POSTで送られてきた検索語をもとに、検索APIを呼び出し、結果を取得しています。API化したことにより、ここでは「検索結果を取得する」という処理だけに集中できており、全体の構成もかなりスッキリしました。

shinphp540.php

$searchWord = '';
if (!empty($_POST['ID_a'])) {
    $searchWord = trim($_POST['ID_a']);
} elseif (!empty($_POST['word'])) {
    $searchWord = trim($_POST['word']);
}
$searchWord = preg_replace('/ /u', ' ', $searchWord);

$lastSearch = $_SESSION['last_search_word'] ?? '';
$shouldUpdateAppear = ($searchWord !== '' && $searchWord !== $lastSearch);

$totalLimit = 400;

// fetchAPIへPOST
$ch = curl_init('https://sae-porns.org/api540-fetch.php');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => http_build_query([
        'searchWord'  => $searchWord,
        'totalLimit'  => $totalLimit,
    ])
]);
$response = curl_exec($ch);
curl_close($ch);

$data = json_decode($response, true);
$memberList = $data['data'] ?? [];

前半部分は特に解説する必要もないコードですね。POSTされた検索語を受け取り、全角スペースを半角に変換し、直前の検索語と同じかどうかをセッションで判定しているだけです。

後半部分が今回のキモになってくるところで、curl を使って api540-fetch.php にPOSTし、検索結果を取得しています。つまり、検索ロジック本体はすでにメインから切り離され、APIに委譲されているというわけです。

この構造によって、フロント側は「データを受け取る」ことに集中でき、検索処理の中身は裏側のAPIに完全に任せる形になっています。

動画データを抽出する方(api540-fetch.php

次に、動画データを抽出する側のコードを見ていきます。

   if ($searchWord !== '') {
        $sql = <<<SQL
SELECT * FROM (
  SELECT
      v.id            AS ID,
      v.title         AS title,
      v.url           AS url,
      v.duration      AS duration,
      v.thumbnail_url AS image,
      v.embedflag     AS embedflag,
      w.rate          AS rate,
      w.selected      AS selected,
      w.appear        AS appear,
      v.created_at    AS created_at,
      v.updated_at    AS last_updated,
      1               AS priority
  FROM videos v
  INNER JOIN words w ON v.id = w.video_id
  WHERE w.word = :exact_word

  UNION ALL

  SELECT
      v.id            AS ID,
      v.title         AS title,
      v.url           AS url,
      v.duration      AS duration,
      v.thumbnail_url AS image,
      v.embedflag     AS embedflag,
      NULL            AS rate,
      0               AS selected,
      0               AS appear,
      v.created_at    AS created_at,
      v.updated_at    AS last_updated,
      2               AS priority
  FROM videos v
  WHERE v.id NOT IN (
      SELECT video_id FROM words WHERE word = :exact_word
  )
) AS combined_results
ORDER BY priority ASC, rate DESC, last_updated DESC
LIMIT :totalLimit
SQL;

        $stmt = $dbh->prepare($sql);
        $stmt->bindValue(':exact_word', $searchWord, PDO::PARAM_STR);
        $stmt->bindValue(':totalLimit', $totalLimit, PDO::PARAM_INT);
        $stmt->execute();
    } 

このSQLには、ただ動画を返すだけでなく、ランキング的な並び替えの要素も含まれています。いわば、Google検索でいうところの「ページランクに近いロジックです。

まず、検索語が words テーブルに登録されている動画は 優先度 1、未登録のものは 優先度 2 として分類されます(priority ASC)。そのうえで、登録済み動画は rate DESC(スコア順)、updated_at DESC(新しさ順)で並べ替えられます。

この「スコア順」というのがミソで、SaePornsではCTR(クリック率)をベースに、検索語との一致度を独自に算出し、rate に反映しています。つまり、単なる文字の一致ではなく、「関連性が高く、実際に選ばれている動画」を上位に表示する仕組みになっているのです。

このあたりのロジックは、Googleのようにユーザーを追跡せず、匿名性を守りながら改善を重ねていく設計を目指しています。

※ちなみに、検索語が空だった場合には、全動画の中から新着順で返すシンプルな構造になっています(コード省略)。

おわりに:責務を分けると見えてくるもの

今回は、検索機能をAPIとして切り出し、メイン処理と表示用処理を分離することで、検索エンジンの構成を軽量化した実例を紹介しました。

コードそのものは大したことをしていないように見えるかもしれませんが、「表示は表示、集計は集計」と処理の責任を明確に分けることで、フロントは軽く・APIは再利用しやすくなります。特に、検索処理のようにトラフィックが集中しやすい部分では、このアイソレーションが効いてきます。

さらに今回は、CTRベースのスコアを使ってランキング的な並び替えも同時に導入しており、単なる一致検索から「関連性の高い順」への進化も進めています。

SaePornsでは、こうした仕組みをユーザーの追跡なしで実現していくことをひとつの方針としています。匿名でも、ある程度の精度を出せる検索エンジン。そのために、処理の分離・構造の整理・軽量化をこれからも地道にやっていきます。

それではみなさん、よい開発ライフを。

本文のコードが実際に動いている、あなたを追跡しないアダルト動画の検索エンジン、SaePornsはこちら。

※18歳未満の方はご利用いただけません。

sae-porns.org