Laravel で X(Twitter)の良ポストを探す便利フォームをつくる

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、最近のマーケティングでは欠かせない存在と言えば、やはり、

SNS(YouTube、Instagram、X など)

ですよね。

というのも、もしSNSにフォロワーさんが多ければ、
良いことばかりだからです。

例えば、

  • 広告費なしで集客できる
  • ファン化しているので成約率が高い
  • 同じ理由から、高単価のものが売れる
  • 同じ理由から、他の商品も買ってもらえる
  • 事前に理念を共有できるので、質のいい採用ができる

なので、企業さんもSNSに力を入れているようです。
(ただし、属人化してしまうのが悩みだそうです…担当者がやめちゃうので😅)

そして、この流れを考えると私も本格的にSNSの勉強をするべき
という結論になり、尊敬する社長さんの有料教材を購入してみました。

📝 参考ページ(PR): Twitterマーケティングマスター講座

やはり有料ということで、これまで全く想像していなかったアプローチ方法
を学ぶことができて、とても満足してます。😊✨

そして、その教材の中ではXで良ポストを探す方法を勉強するのですが、
毎回検索コマンドを入力するのが面倒でした。

じゃあ、使いやすい検索フォームをパパッと作っちゃおう!
というのが今回の趣旨です。

※ なお、Xの検索コマンドは公開されている情報を元にしてつくってます。念のため!

今回はシンプルなので、ぜひLaravelの勉強をしたい方も
真似して作ってもらったらスキルアップになると思います!

ぜひ何かの参考になりましたら嬉しいです。😊✨

「有料の情報って
想像してたのと全然
違ってたなぁ(感心)」

開発環境: Laravel + Inertia + Vue 3(Composition API

環境を整える

Laravel + Inertia + Vue 3が使えるようにするために
一番手っ取り早いのがLaravel breezeをインストールすることです。

Laravel breezeはログイン周りのパッケージですが、今回の環境をすべて
満たしているので先にこれをインストールしておきましょう。

以下のコマンドを実行して下さい。

composer require laravel/breeze --dev

するとパッケージがインストールされるので、
続けて以下のコマンドを実行して下さい。

php artisan breeze:install

すると、どのモードでインストールするか聞かれるので、
今回はVue with Inertiaを選択。

続くオプションは何も選択せずエンターキーを押します。

テストもそのまま(そのうちPestも勉強しなきゃ…😅)

これでインストールが完了しました。

あとは、以下のコマンドを実行すれば完了です。

php artisan migrate
npm install
npm run dev

ちなみに表示URLは.env内のAPP_URLが適用されます。

.env

APP_URL=(ここにあなたの環境のURL。localhostとかl11x.testとかですね)

JS パッケージをインストールする

今回以下2つのパッケージを使うので、先にインストールしておきましょう。

  • dayjs: 日付を管理する
  • Lodash: 便利機能が満載なパッケージ

以下のコマンドを実行して下さい。

npm i lodash --save-dev
npm i dayjs --save-dev

すると、パッケージがインストールされますが
まだこの状態ではVueから呼び出すことができません。

以下に登録をしておきましょう。

resources/js/bootstrap.js

// 省略

// dayjs
import dayjs from "dayjs";
import 'dayjs/locale/ja';
window.dayjs = dayjs;
dayjs.locale('ja');

// Lodash
import _ from 'lodash';
window._ = _;

これで、Vue側からもdayjs_にアクセスができるようになります。

Vue ファイルをつくる

では、今回はシンプルな構造なのでコントローラーは作らず
いきなりVueファイルを作ります。

※ もし実際にサイトをつくる場合はコントローラーを作ったほうがいいです。

resources/js/Pages/UsefulForm.vue

<script setup>
import {computed, onBeforeMount, onMounted, ref} from 'vue';
import { Head } from '@inertiajs/vue3';

// Form
const keyword = ref('');
const params = ref({
    from: '',
    min_faves: 100,
    min_retweets: 100,
    min_replies: 100,
    since: '',
    until: ''
});
const toggle = ref({});
onBeforeMount(() => {

    let toggleValues = {};

    for(let key in params.value) {

        toggleValues[key] = false;

    }

    toggle.value = toggleValues;

});
// Inputs
const onInputChange = (key) => {

    toggle.value[key] = params.value[key] !== '';

};
const onClearKeywordClick = () => {

    keyword.value = '';
    document.getElementById('keyword-input').focus();

};
const setDate = (paramKey, type) => {

    let dt = dayjs();

    if (type === 'year') {

        dt = dt.subtract(1, 'year');

    } else if (type === 'month') {

        dt = dt.subtract(1, 'month');

    } else if (type === 'week') {

        dt = dt.subtract(1, 'week');

    } else if (type === 'yesterday') {

        dt = dt.subtract(1, 'day');

    }

    params.value[paramKey] = dt.format('YYYY-MM-DD');
    toggle.value[paramKey] = true;

};

// Search
const searchUrl = computed(() => {

    let searchParams = [];

    if (keyword.value) {

        searchParams.push(keyword.value);

    }

    for (const [key, value] of Object.entries(params.value)) {

        if (toggle.value[key] && value) {

            searchParams.push(`${key}:${value}`);

        }

    }

    let searchUrl = `https://twitter.com/search?q=${searchParams.join(' ')}`;

    if(currentIncludeItems.value.length > 0) {

        searchUrl += ' filter:';

        for(let i = 0; i < currentIncludeItems.value.length; i++) {

            searchUrl += currentIncludeItems.value[i];

            if(i < currentIncludeItems.value.length - 1) {

                searchUrl += ' ';

            }

        }

    }

    return searchUrl;
});
const currentIncludeItems = ref([]);
const includingItems = ref([
    { value: 'images', label: '画像を含む' },
    { value: 'videos', label: '動画を含む' },
    { value: 'links', label: 'リンクを含む' },
]);

// DataList
const dataList = ref({
    keyword: [],
    from: [],
});
const onSearchButtonClick = () => {

    setDataList('keyword', keyword.value);
    setDataList('from', params.value.from);

    localStorage.setItem('dataList', JSON.stringify(dataList.value));

};
const setDataList = (dataListKey, text) => {

    if(text.length > 0) {

        const targetDataList = dataList.value[dataListKey];
        let hasDataListItem = false;

        for(let i = 0; i < targetDataList.length; i++) {

            let dataListItem = targetDataList[i];

            if(dataListItem.text === text) {

                dataListItem.count++;
                hasDataListItem = true;
                break;

            }

        }

        if(! hasDataListItem) {

            targetDataList.push({
                text: text,
                count: 1
            });

        }

    }

};
const dataListKeywords = computed(() => {

    return _.sortBy(dataList.value.keyword, 'count').reverse(); // 検索が多い順に並び替え

});
const dataListFroms = computed(() => {

    return _.sortBy(dataList.value.from, 'count').reverse(); // 検索が多い順に並び替え

});
onMounted(() => {

    const storage = localStorage.getItem('dataList');

    if (storage) {

        try {

            dataList.value = JSON.parse(storage);

        } catch (e) {}

    }

});

</script>

<template>
    <Head title="X ポスト / リサーチ" />
    <div class="max-w-4xl mx-auto py-12 px-4 sm:px-6 lg:px-8 bg-gray-200">
        <div class="text-center text-2xl mb-6 font-bold">
            X ポスト / リサーチ
        </div>
        <table class="mx-auto table-auto">
            <tbody>
            <tr>
                <td colspan="3" class="pb-1">
                    <input id="keyword-input" list="keyword" v-model="keyword" type="text" class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="キーワード">
                    <datalist id="keyword">
                        <option v-for="keyword in dataListKeywords" :value="keyword.text"></option>
                    </datalist>
                    <div class="text-right px-2 py-1 text-sm">
                        <a href="#" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer" @click.prevent="onClearKeywordClick">クリア</a>
                    </div>
                </td>
            </tr>
            <tr>
                <td class="w-10 align-top">
                    <input type="checkbox" v-model="toggle.from" id="toggleFrom" class="w-6 h-6 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
                </td>
                <td class="w-40 align-top">
                    <label for="toggleFrom" class="text-sm font-medium text-gray-700 text-nowrap">アカウントID:</label>
                </td>
                <td class="w-64">
                    <input v-model="params.from" list="from" type="text" class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" @input="onInputChange('from')">
                    <datalist id="from">
                        <option v-for="from in dataListFroms" :value="from.text"></option>
                    </datalist>
                    <div class="pt-0.5 p-1 text-gray-600">
                        <small>例:elonmusk</small>
                    </div>
                </td>
            </tr>
            <tr>
                <td>
                    <input type="checkbox" v-model="toggle.min_faves" id="toggleMinFavorites" class="w-6 h-6 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
                </td>
                <td>
                    <label for="toggleMinFavorites" class="text-sm font-medium text-gray-700 text-nowrap">最小いいね数:</label>
                </td>
                <td>
                    <input v-model="params.min_faves" type="number" class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="0" @input="onInputChange('min_faves')">
                </td>
            </tr>
            <tr>
                <td>
                    <input type="checkbox" v-model="toggle.min_retweets" id="toggleMinRetweets" class="w-6 h-6 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
                </td>
                <td>
                    <label for="toggleMinRetweets" class="text-sm font-medium text-gray-700 text-nowrap">最小リツイート数:</label>
                </td>
                <td>
                    <input v-model="params.min_retweets" type="number" class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="0" @input="onInputChange('min_retweets')">
                </td>
            </tr>
            <tr>
                <td>
                    <input type="checkbox" v-model="toggle.min_replies" id="toggleMinReplies" class="w-6 h-6 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
                </td>
                <td>
                    <label for="toggleMinReplies" class="text-sm font-medium text-gray-700 text-nowrap">最小リプライ数:</label>
                </td>
                <td>
                    <input v-model="params.min_replies" type="number" class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="0" @input="onInputChange('min_replies')">
                </td>
            </tr>
            <tr>
                <td class="align-top">
                    <input type="checkbox" v-model="toggle.since" id="toggleSince" class="w-6 h-6 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
                </td>
                <td class="align-top">
                    <label for="toggleSince" class="text-sm font-medium text-gray-700 text-nowrap">開始日:</label>
                </td>
                <td>
                    <input v-model="params.since" :max="params.until" type="date" class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" @input="onInputChange('since')">
                </td>
            </tr>
            <tr>
                <td colspan="3">
                    <div class="text-right p-1 text-gray-400">
                        <a href="#" @click="setDate('since', 'year')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">1年前</a> /
                        <a href="#" @click="setDate('since', 'month')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">1ヶ月前</a> /
                        <a href="#" @click="setDate('since', 'week')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">1週間前</a> /
                        <a href="#" @click="setDate('since', 'yesterday')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">昨日</a> /
                        <a href="#" @click="setDate('since', 'today')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">今日</a>
                    </div>
                </td>
            </tr>
            <tr>
                <td class="align-top">
                    <input type="checkbox" v-model="toggle.until" id="toggleUntil" class="w-6 h-6 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500">
                </td>
                <td class="align-top">
                    <label for="toggleUntil" class="text-sm font-medium text-gray-700 text-nowrap">終了日:</label>
                </td>
                <td>
                    <input v-model="params.until" :min="params.since" type="date" class="w-full border border-gray-300 px-3 py-2 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" @input="onInputChange('until')">
                </td>
            </tr>
            <tr>
                <td colspan="3">
                    <div class="text-right p-1 text-gray-400">
                        <a href="#" @click="setDate('until', 'year')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">1年前</a> /
                        <a href="#" @click="setDate('until', 'month')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">1ヶ月前</a> /
                        <a href="#" @click="setDate('until', 'week')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">1週間前</a> /
                        <a href="#" @click="setDate('until', 'yesterday')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">昨日</a> /
                        <a href="#" @click="setDate('until', 'today')" class="text-indigo-600 hover:text-indigo-800 text-sm cursor-pointer">今日</a>
                    </div>
                </td>
            </tr>
            <tr>
                <td colspan="3" class="pt-4">
                    <div class="text-sm font-medium text-gray-700 mb-2">含むアイテム:</div>
                    <div class="flex flex-wrap">
                        <div class="mr-2" v-for="item in includingItems">
                            <label class="text-sm font-medium text-gray-700 ml-1 flex items-center">
                                <input type="checkbox" v-model="currentIncludeItems" :value="item.value" class="w-4 h-4 text-indigo-600 border-gray-300 rounded focus:ring-indigo-500 mr-1">
                                {{ item.label }}
                            </label>
                        </div>
                    </div>
                </td>
            </tr>
            <tr>
                <td colspan="3" class="pt-3 text-right">
                    <a :href="searchUrl" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" target="_blank" @click="onSearchButtonClick">
                        検索
                    </a>
                </td>
            </tr>
            </tbody>
        </table>
    </div>
</template>

ちなみに、今回実装している内容は次のとおりです。

  • 検索したキーワードが LocalStorage に保存されて、次回から入力補完ができる
  • 検索回数が多い順に自動で並べ替える
  • アカウントIDも同じ形で入力補完できる
  • チェックボックスで検索したい内容を選択できる
  • 日付は選択が面倒なのでワンクリックで完了できるようにする
  • その他オプションも選択可能にする

ちなみに、その他のコマンドはこちらがわかりやすいです。

📝 参考ページ: 【Twitterユーザー必見】便利な検索コマンド25選!いいね数や特定ユーザーで検索

そして、LocalStorageは、以下の2パターンのみです。

  • ページ読み込み時に取得
  • 検索ボタンを押したら保存

ルートをつくる

今回ルートはとてもシンプルになっています。

routes/web.php

<?php

use Illuminate\Support\Facades\Route;
use Inertia\Inertia;

Route::get('x_useful_form', fn() => Inertia::render('UsefulForm'));

※ なお、Laravel Breezeをインストールするといくつかルートが追加されていますが、今回は不要なので削除しています。

さぁ、これで作業は完了です。
お疲れ様でした。😊

テストしてみる

では、実際のテストしてみましょう❗

ブラウザで「https://******/x_useful_form」へアクセスします。

すると・・・・・・

はい❗
検索用のフォームが表示されました。

では、キーワードに「カレーうどん」と入力し、
最小いいね数にチェックを入れて「検索」ボタンをクリックしてみます。

どうなるでしょうか・・・・・・

はい!
ページ移動した先の検索ワードが想定通りになっていました。

では、次に入力補完のチェックです。

豚骨ラーメン」で2回チェックして一番上に来ているか
確認しておきましょう。

うまくいくでしょうか・・・・・・

はい❗
豚骨ラーメンは2回検索されたので、一番上に表示されています。

すべて成功です。😊✨

実際に公開しました

今回のコードを実際に体験できるように
ページを公開しました。

ぜひ以下から試してみて下さい。

📝 公開ページ: https://social-digger.capilano-fw.com/

企業様へのご提案

昨今、Xを始めとするSNSの重要性は
より大きくなってきています。

しかし、それらの運用はなかなか時間がかかってしまします。

そのため、今回のように運用を支援するツールを
ご用意になってみてはいかがでしょうか。

貴社やその業界にカスタマイズした専用リサーチツールを
つくっておくと集客の効率化に役に立つことが期待できます。

ぜひそういったツールをご希望でしたら、
いつでもお気軽にご相談下さい。

お待ちしております。😊✨

開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はSNSマーケティングを支援するツールとして
Xの便利な検索フォームをつくってみました。

常に、優秀なものに触れるのはスキルアップにつながると思いますので、
ぜひ皆さんも今回のフォームを活用していただけると嬉しいです。

また、ご自身の業界や状況によって必要な項目も出てくるかと思うので、
その場合は拡張して入力項目を増やしてみてはいかがでしょうか。

私もまだまだ勉強します!

ではでは〜❗

「ついでに、
(PR) ビジネスモデルマスター講座
も買っちゃった 😊👍」

このエントリーをはてなブックマークに追加       follow us in feedly