【開発編Part1】半自動で運営される個人開発集サイト作ってみた

プログラミング

こんにちは!

今回も「半自動で運営される個人開発集サイト作ってみた」シリーズを書いていきたいと思います。

前回は環境構築編を書きました。

【環境構築編】半自動で運営される個人開発集サイト作ってみた

今回はこの続きとして、開発編Part1を紹介していきます。

それでは、早速見ていきましょう!

データベース作成

まずは、Laravelのマイグレーションという機能を使ってDBを作成していきます。

最初にarticlesテーブルのマイグレーションファイルを作成します。

php artisan make:migration create_articles_table

articlesテーブルのマイグレーションファイルを編集します。

public function up()
{
    Schema::create('articles', function (Blueprint $table) {
        $table->id();
        $table->string('title')->comment('タイトル');
        $table->string('url')->comment('URL');
        $table->string('comment')->comment('コメント')->nullable();
        $table->string('source')->comment('データ元')->nullable();
        $table->string('author_name')->comment('著者名')->nullable();
        $table->string('author_profile_image_url')->comment('著者プロフィール画像URL')->nullable();
        $table->dateTime('article_created_at')->comment('公開日時')->nullable();
        $table->datetime('article_updated_at')->comment('更新日時')->nullable();
        $table->timestamps();
        $table->softDeletes();
    });
}

/**
 * Reverse the migrations.
 *
 * @return void
 */
public function down()
{
    Schema::dropIfExists('articles');
}

同様に、admin_usersテーブルも作成していきます。

php artisan make:migration admin_users_table

admin_usersテーブルのマイグレーションファイルを編集していきます。

/**
* Run the migrations.
*
* @return void
*/
public function up()
{
  Schema::create('admin_users', function (Blueprint $table) {
      $table->id();
      $table->string('name');
      $table->string('email')->unique();
      $table->timestamp('email_verified_at')->nullable();
      $table->string('password');
      $table->rememberToken();
      $table->timestamps();
      $table->softDeletes();
  });
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
  Schema::dropIfExists('admin_users');
}

さて、これでマイグレーションファイルが完成しました。

最後に以下コマンドでテーブルを追加しましょう。

php artisan migrate

以下のURLからphpMyAdminでテーブルを確認できたら成功です。

無効なURLです

ホーム画面作成

まずはホーム画面を作成していきます。

Viewファイル作成

Viewファイルを作成します。

cd app/resources/views
touch home.blade.php

CSSファイルは特に分けてはおらず、そのままheadタグの中に記載しています。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>個人開発集</title>

        <!-- Fonts -->
        <link href="<https://fonts.bunny.net/css2?family=Nunito:wght@400;600;700&display=swap>" rel="stylesheet">

        <!-- Styles -->
        <style>
            /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}a{background-color:transparent}[hidden]{display:none}html{font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;line-height:1.5}*,:after,:before{box-sizing:border-box;border:0 solid #e2e8f0}a{color:inherit;text-decoration:inherit}svg,video{display:block;vertical-align:middle}video{max-width:100%;height:auto}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity))}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity))}.border-t{border-top-width:1px}.flex{display:flex}.grid{display:grid}.hidden{display:none}.items-center{align-items:center}.justify-center{justify-content:center}.font-semibold{font-weight:600}.h-5{height:1.25rem}.h-8{height:2rem}.h-16{height:4rem}.text-sm{font-size:.875rem}.text-lg{font-size:1.125rem}.leading-7{line-height:1.75rem}.mx-auto{margin-left:auto;margin-right:auto}.ml-1{margin-left:.25rem}.mt-2{margin-top:.5rem}.mr-2{margin-right:.5rem}.ml-2{margin-left:.5rem}.mt-4{margin-top:1rem}.ml-4{margin-left:1rem}.mt-8{margin-top:2rem}.ml-12{margin-left:3rem}.-mt-px{margin-top:-1px}.max-w-6xl{max-width:72rem}.min-h-screen{min-height:100vh}.overflow-hidden{overflow:hidden}.p-6{padding:1.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.pt-8{padding-top:2rem}.fixed{position:fixed}.relative{position:relative}.top-0{top:0}.right-0{right:0}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.text-center{text-align:center}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity))}.underline{text-decoration:underline}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.w-5{width:1.25rem}.w-8{width:2rem}.w-auto{width:auto}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}@media (min-width:640px){.sm\\:rounded-lg{border-radius:.5rem}.sm\\:block{display:block}.sm\\:items-center{align-items:center}.sm\\:justify-start{justify-content:flex-start}.sm\\:justify-between{justify-content:space-between}.sm\\:h-20{height:5rem}.sm\\:ml-0{margin-left:0}.sm\\:px-6{padding-left:1.5rem;padding-right:1.5rem}.sm\\:pt-0{padding-top:0}.sm\\:text-left{text-align:left}.sm\\:text-right{text-align:right}}@media (min-width:768px){.md\\:border-t-0{border-top-width:0}.md\\:border-l{border-left-width:1px}.md\\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width:1024px){.lg\\:px-8{padding-left:2rem;padding-right:2rem}}@media (prefers-color-scheme:dark){.dark\\:bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity))}.dark\\:bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity))}.dark\\:border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity))}.dark\\:text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity))}.dark\\:text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity))}.dark\\:text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity))}}
        </style>

        <style>
            body {
                font-family: 'Nunito', sans-serif;
                background-color: whitesmoke;
                padding-top: 3rem;
                padding-bottom: 5rem;
            }
            .main {
                width: 80%;
                margin: 0 auto;
            }
            .fv {
                display: flex;
                flex-direction: column;
                align-items: center;
            }
            .card {
                width: 80%;
                background-color: white;
                border-radius: 10px;
                /* box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); */
                padding: 1rem 2rem;
            }
            .card img {
                border-radius: 50%;
                width: 3rem;
                height: 3rem;
            }
            .card h2.title {
                font-size: 1.2rem;
                margin-bottom: 0;
            }
            .contents {
                margin-top: 50px;
                display: flex;
                flex-direction: column;
                align-items: center;
                gap: 1.2rem;
            }
            .author {
                display: flex;
                align-items: center;
                gap: 0.7rem;
            }
            .sub {
                display: flex;
                align-items: center;
                gap: 20px;
            }
            .source {
                font-size: 0.8rem;
                color: gray;
            }
            .published {
                font-size: 0.8rem;
                color: gray;
            }
            .strong {
                color: #5271FF;
            }
            .description-sp {
                display: none;
            }
            .description-pc {
                line-height: 1.8rem;
            }
            .pagination {
                display: flex;
                gap: 10px;
            }
            .pagination a {
                display: flex;
                justify-content: center;
                align-items: center;
                border: 1px solid gray;
                border-radius: 5px;
                background-color: white;
                width: 40px;
                height: 40px;
                font-size: 1.2rem;
            }
            .keyword-form {
                width: 300px;
                padding: 10px;
                border: 1px solid #ccc;
                border-radius: 5px;
            }
            .search-btn {
                padding: 5px 10px;
                border: 1px solid #ccc;
                border-radius: 5px;
                background-color: #fff;
                cursor: pointer;
            }
            .search-title {
                text-align: center;
                margin: 0;
            }
            .form-group-pc {
                display: flex;
                align-items: center;
                gap: 5px;
            }
            .form-group-sp {
                display: none;
            }
            @media screen and (max-width: 768px) {
                .main {
                    width: 90%;
                }
                .description-pc {
                    display: none;
                }
                .description-sp {
                    display: block;
                    font-size: 0.8rem;
                }
                .form-group-pc {
                    display: none;
                }
                .form-group-sp {
                    display: flex;
                    align-items: center;
                    gap: 5px;
                }
                .form-group-sp form {
                    display: flex;
                    flex-direction: column;
                    gap: 10px;
                    margin-bottom: 30px;
                }
                .card {
                    width: 100%;
                }
                .card h2.title {
                    font-size: 1rem;
                }
            }
        </style>
    </head>
    <body>
        <div class="main">
            <div class="fv">
                <h1>個人開発集</h1>
                <p class="description-pc">
                    このサイトはさまざまなサイトから<span class="strong">個人開発</span>に関する記事を集めた、個人開発のポータルサイトです。<br />
                    コンテンツは毎日定期的に自動収集され、当サイトは<span class="strong">半自動</span>で運営されております。<br />
                    データ収集元はQiita, Zenn, note, 個人ブログなど多岐に渡ります。
                </p>
                <p class="description-sp">
                    このサイトはさまざまなサイトから<span class="strong">個人開発</span>に関する記事を集めた、個人開発のポータルサイトです。<br />
                    データ収集元はQiita, Zenn, note, 個人ブログなど多岐に渡ります。
                </p>
            </div>
            <div class="contents">
                <p class="search-title">タイトル検索</p>
                <div class="form-group-pc">
                    <form action="{{ route('home') }}">
												@csrf
                        <input type="text" name="keyword" class="keyword-form" value="{{ $keyword }}" placeholder="検索ワードを入力">
                        <input type="submit" class="search-btn" value="検索" />
                    </form>
                    <form action="{{ route('home') }}">
                        <input type="submit" class="search-btn" value="クリア" />
                    </form>
                </div>
                <div class="form-group-sp">
                    <form action="{{ route('home') }}">
                        <input type="text" name="keyword" class="keyword-form" value="{{ $keyword }}" placeholder="検索ワードを入力">
                    </form>
                </div>
                @foreach ($articles as $article)
                <a href="{{ $article['url'] }}" class="card" target="blank">
                    <div class="author">
                        <img src="{{ $article['author_profile_image_url'] }}" alt="サンプル">
                        <p class="author-name">{{ $article['author_name'] }}</p>
                    </div>
                    <h2 class="title">{{ $article['title'] }}</h2>
                    <div class="sub">
                        <p class="published">公開日: {{ explode(" ", $article['article_updated_at'])[0] }}</p>
                        <p class="source">収集元: {{ $article['source'] }}</p>
                    </div>
                </a>
                @endforeach
                <div class="pagination">
                    <a class="prev-btn" href="{{ $articles->previousPageUrl() }}"><</a>
                    @if ($articles->currentPage() - 1 > 0)
                    <a class="page" href="{{ $articles->url($articles->currentPage() - 1) }}" data-page="{{ $articles->currentPage() - 1 }}">{{ $articles->currentPage() - 1 }}</a>
                    @endif
                    <a class="page" href="{{ $articles->url($articles->currentPage()) }}" data-page="{{ $articles->currentPage() }}">{{ $articles->currentPage() }}</a>
                    @if ($articles->currentPage() + 1 <= $articles->lastPage())
                    <a class="page" href="{{ $articles->url($articles->currentPage() + 1) }}" data-page="{{ $articles->currentPage() + 1 }}">{{ $articles->currentPage() + 1 }}</a>
                    @endif
                    <a class="next-btn" href="{{ $articles->nextPageUrl() }}">></a>
                </div>
            </div>
        </div>
        <script>
            const pageElements = document.querySelectorAll('.page');
            pageElements.forEach((elem) => {
                if (elem.dataset.page == {{ $articles->currentPage() }}) {
                    elem.style.border = '1px solid #5271FF';
                    elem.style.backgroundColor = '#5271FF';
                    elem.style.color = 'white';
                }
            })
        </script>
    </body>
</html>

タイトル検索

特に難しい点はないかと思いますが、一応記事のタイトル検索ができるようにしています。

<form action="{{ route('home') }}">
		@csrf
    <input type="text" name="keyword" class="keyword-form" value="{{ $keyword }}" placeholder="検索ワードを入力">
    <input type="submit" class="search-btn" value="検索" />
</form>

homeという名前付きルートにリクエストを投げています。

記事一覧表示

具体的な記事の一覧は以下でループを回して表示しています。

@foreach ($articles as $article)
<a href="{{ $article['url'] }}" class="card" target="blank">
    <div class="author">
        <img src="{{ $article['author_profile_image_url'] }}" alt="サンプル">
        <p class="author-name">{{ $article['author_name'] }}</p>
    </div>
    <h2 class="title">{{ $article['title'] }}</h2>
    <div class="sub">
        <p class="published">公開日: {{ explode(" ", $article['article_updated_at'])[0] }}</p>
        <p class="source">収集元: {{ $article['source'] }}</p>
    </div>
</a>
@endforeach

ページネーション

もっと良い実装方法があるかもしれませんが、ページネーションは以下のように書いています。

<div class="pagination">
    <a class="prev-btn" href="{{ $articles->previousPageUrl() }}"><</a>
    @if ($articles->currentPage() - 1 > 0)
    <a class="page" href="{{ $articles->url($articles->currentPage() - 1) }}" data-page="{{ $articles->currentPage() - 1 }}">{{ $articles->currentPage() - 1 }}</a>
    @endif
    <a class="page" href="{{ $articles->url($articles->currentPage()) }}" data-page="{{ $articles->currentPage() }}">{{ $articles->currentPage() }}</a>
    @if ($articles->currentPage() + 1 <= $articles->lastPage())
    <a class="page" href="{{ $articles->url($articles->currentPage() + 1) }}" data-page="{{ $articles->currentPage() + 1 }}">{{ $articles->currentPage() + 1 }}</a>
    @endif
    <a class="next-btn" href="{{ $articles->nextPageUrl() }}">></a>
</div>
<script>
    const pageElements = document.querySelectorAll('.page');
    pageElements.forEach((elem) => {
        if (elem.dataset.page == {{ $articles->currentPage() }}) {
            elem.style.border = '1px solid #5271FF';
            elem.style.backgroundColor = '#5271FF';
            elem.style.color = 'white';
        }
    })
</script>

ページ番号がない場合は表示しないようにしているのと、現在のページ番号がわかるようにリンクの背景色をJavaScriptで変更しています。

ルーティング追加

次に、ホーム画面のルーティングを設定していきます。

app/routes/web.phpを開きます。

Route::get('/', 'App\\Http\\Controllers\\ArticleController@index')->name('home');

後々説明しますが、今回はユーザ側と管理側のルーティングファイルを分けています。

上記のファイルには、ユーザ側のルーティングのみを記述します。

コントローラ作成

次に記事一覧表示するためのArticleControllerを作成していきます。

php artisan make:controller ArticleController

コントローラの中にindexアクションを作成します。

<?php

namespace App\\Http\\Controllers;

use Illuminate\\Http\\Request;
use App\\Models\\Article;

class ArticleController extends Controller
{
    const PAGINATE = 20;
    public function index(Request $request)
    {
        $query = Article::orderBy('article_updated_at', 'desc');
        if ($request->keyword) {
            $query->where('title', 'like', '%' . $request->keyword . '%');
        }
        $articles = $query->paginate(self::PAGINATE);
        return view('home', [
            'articles' => $articles,
            'keyword' => $request->keyword
        ]);
    }
}

こちらは記事の更新日付の降順でデータを取り出し、リクエストされたキーワードがあれば検索するようにしています。

$query = Article::orderBy('article_updated_at', 'desc');
if ($request->keyword) {
    $query->where('title', 'like', '%' . $request->keyword . '%');
}

最後にページネーションで1ページ20記事となるようにしています。

$articles = $query->paginate(self::PAGINATE);
return view('home', [
    'articles' => $articles,
    'keyword' => $request->keyword
]);

モデル作成

次に、Articleモデルを作成していきます。

php artisan make:model Article

Articleモデルに必要なコードを追加しましょう。

<?php

namespace App\\Models;

use Illuminate\\Database\\Eloquent\\Factories\\HasFactory;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\SoftDeletes;

class Article extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'title',
        'url',
        'source',
        'author_name',
        'author_profile_image_url',
        'article_created_at',
        'article_updated_at',
    ];
}

今回はソフトデリートといって、データを物理的に削除するのではなく、論理削除するようにしています。

そのため、SoftDeletesトレイトを追加しておきましょう。

use HasFactory, SoftDeletes;

また、更新があるカラムは保護しておきます。

protected $fillable = [
    'title',
    'url',
    'source',
    'author_name',
    'author_profile_image_url',
    'article_created_at',
    'article_updated_at',
];

記事データ取得プログラム作成

ホーム画面の作成は完了しました。

あとは記事データを入れていくプログラムを組めば、実際に表示することができます。

記事データはAPIやスクレイピングを行うことで、データベースに保存していきます。

具体的な実装を見てみましょう。

Qiita

まずはQiitaというエンジニア御用達のコミュニティサイトから個人開発に関する記事だけを抜き出し、データベースに保存する処理を作っていきます。

こちらは後々Laravelのスケジューラという機能を使って定期的に実行したいので、コマンドとして処理を作っていきます。

php artisan make:command getQiitaPage

上記コマンドを叩くと、app/Console/Commands/getQiitaPage.phpが作成されます。

この中に処理を書きます。

<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;
use App\\Models\\Article;
use App\\Models\\Author;
use App\\Http\\Controllers\\CommonController;

class getQiitaPage extends Command
{
    const PAGE = 1;
    const QUERY = 'title:個人開発';

    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'command:getQiitaPage';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'QiitaAPIから記事を取得し、DBに保存する';

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        try {
            DB::beginTransaction();
            // ヘッダーを設定
            $headers = [
                'Content-Type: application/json',
                'Authoriztion: Bearer ' . config('external_api.qiita.api_key'),
            ];
            $url = '<https://qiita.com/api/v2/items?page=>'. self::PAGE .'&query='. self::QUERY;

            $data = CommonController::execCurl($headers, $url);
            foreach ($data as $item) {
                $article_with_trashed = Article::withTrashed()
                    ->where('url', $item['url'])
                    ->first();

                $article = $article_with_trashed ? $article_with_trashed : new Article();
                $article->title = $item['title'];
                $article->url = $item['url'];
                $article->source = 'Qiita';
                $article->author_name = $item['user']['name'];
                $article->author_profile_image_url = $item['user']['profile_image_url'];
                $article->article_created_at = $item['created_at'];
                $article->article_updated_at = $item['updated_at'];
                $article->save();
            }
            DB::commit();
        } catch (\\Exception $e) {
            logger()->critical('file: ' . $e->getFile() . ' line: ' . $e->getLine() . ' message: ' . $e->getMessage());
            DB::rollback();
        }
    }
}

handle()メソッドの中に処理を書き、$signatureにはコマンド名を、$descriptionにはコマンドの説明を書きます。

QiitaAPIから記事データを取得し、データベースに保存する処理は以下のような流れになります。

  1. QiitaAPIを実行する
  2. 個人開発に関する記事一覧を取得
  3. 同じURLの記事が既にデータベースに登録されているか確認し、登録されていれば更新、登録されていなければ登録する
  4. 各記事のデータを取り出し、データベースに保存

実際にAPIを実行する処理は共通処理として別ファイルにまとめているため、このファイル上では行なっていません。

Note

Qiitaの場合と同様に、Noteでも同じことを行います。

NoteAPIを使って記事を取得し、データベースに保存します。

<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;
use App\\Http\\Controllers\\CommonController;
use App\\Models\\Article;

class getNotePage extends Command
{
    const PAGE_SIZE = 20;
    const START = 0;

    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'command:getNotePage';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'noteAPIから記事を取得し、DBに保存する';

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        // 参考) <https://note.com/ego_station/n/n85fcb635c0a9>
        try {
            DB::beginTransaction();
            $headers = ['Content-Type: application/json'];
            $index_url = "<https://note.com/api/v3/searches?q=個人開発&size=>". self::PAGE_SIZE ."&start=". self::START;
            $pages = CommonController::execCurl($headers, $index_url);
            foreach ($pages['data']['notes']['contents'] as $page) {
                $show_url = "<https://note.com/api/v3/notes/>" . $page['key'];
                $show = CommonController::execCurl($headers, $show_url);
                $show_data = $show['data'];

                $article_with_trashed = Article::withTrashed()
                    ->where('url', $show_data['note_url'])
                    ->first();
                $article = $article_with_trashed ? $article_with_trashed : new Article();
                $article->title = $show_data['name'];
                $article->url = $show_data['note_url'];
                $article->source = 'note';
                $article->author_name = $show_data['user']['nickname'];
                $article->author_profile_image_url = $show_data['user']['user_profile_image_path'];
                $article->article_created_at = $show_data['created_at'];
                $article->article_updated_at = $show_data['created_at'];
                $article->save();
            }
            DB::commit();
        } catch (\\Exception $e) {
            logger()->critical('file: ' . $e->getFile() . ' line: ' . $e->getLine() . ' message: ' . $e->getMessage());
            DB::rollback();
        }
    }
}

Zenn

最後にZennについてですが、Zennに関しては筆者調べではAPIが用意されていませんでした。

そこで、Zennはスクレイピングによって記事データを取得することにしました。

スクレイピング事前調査

スクレイピングを行う場合は、スクレイピングの許可があるか事前に調べておく必要があります。

これは、robots.txtというファイルに書かれており、「ドメイン/robots.txt」とアドレスバーに入力すれば中身を見れます。

Zennの場合は以下となっていました。

User-agent: Yahoo Pipes 1.0
Disallow: /

User-agent: 008
Disallow: /

User-agent: voltron
Disallow: /

User-agent: Bytespider
Disallow: /

User-agent: Livelapbot
Disallow: /

User-agent: Megalodon
Disallow: /

User-agent: ia_archiver
Disallow: /

Sitemap: <https://zenn.dev/sitemaps/_index.xml>

User-agentを見る限りスクレイピングを行なっても大丈夫そうです。

次に、利用規約を確認します。

第4条(禁止事項)
1. 利用者は、本サービスの利用にあたり、以下の行為をしてはなりません。
	1. 法令または公序良俗に違反する行為
	2. 犯罪行為に関連する行為
	3. 運営者のサーバーまたはネットワークの機能を破壊したり、妨害したりする行為
	4. 本サービスの運営を妨害する行為、または妨害するおそれのある行為
	5. 他者の個人情報等を収集または蓄積する行為
	6. 他者に成りすます行為
	7. 反社会的勢力に対して直接的または間接的に利益を供与する行為
	8. 本サービスの利用者および運営者、第三者の知的財産権、肖像権、プライバシー、名誉その他の権利または利益を侵害する行為
	9. 何らかの手段により、本サービス上の有料コンテンツに支払いなくアクセスする行為
	10. 他の利用者および第三者を欺く虚偽の内容を記載する行為
	11. スパムとみなされる行為(機械により自動生成された文章の投稿や同一内容の文章を繰り返し投稿する行為など)
	12. 過度に暴力的な表現、露骨な性的表現、人種、国籍、信条、性別、社会的身分、門地等による差別につながる表現、自殺、自傷行為、薬物乱用を誘引または助長する表現、他人に不快感を与える表現等、不適切な内容を投稿する行為
	13. 性行為やわいせつな行為を目的とする行為、面識のない異性との出会いや交際を目的とする行為、他者に対する嫌がらせや誹謗中傷
	14. 宗教活動または宗教団体への勧誘行為
	15. その他、運営者が不適切と判断する行為
	16. 前項のいずれかの行為が発覚した場合、当該コンテンツの削除、あるいはその利用者のアカウントを停止・削除する場合があります。

利用規約を見る限り、サーバーやネットワークの邪魔にならない範囲であれば大丈夫そうです。

スクレイピング実装

今回はdomcrawlerというライブラリを使用しました。

理由としては、実務で扱ったことがあり、PHP/Laravelで使えるパーサー系のライブラリの中では最も融通が効き、優秀という認識があったためです。

GitHub - symfony/dom-crawler: Eases DOM navigation for HTML and XML documents
Eases DOM navigation for HTML and XML documents. Contribute to symfony/dom-crawler development by creating an account on...
<?php

namespace App\\Console\\Commands;

use Illuminate\\Console\\Command;
use Illuminate\\Support\\Facades\\DB;
use Goutte\\Client;
use Symfony\\Component\\HttpClient\\HttpClient;
use App\\Models\\Article;

class getZennPage extends Command
{
    const PAGE = 1;

    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'command:getZennPage';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Zennから記事をスクレイピングし、DBに保存する';

    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        try {
            DB::beginTransaction();
            $client = new Client(HttpClient::create(['timeout' => 60]));
            $crawler = $client->request('GET', '<https://zenn.dev/topics/%E5%80%8B%E4%BA%BA%E9%96%8B%E7%99%BA?page=>'. self::PAGE);
            $crawler->filter('.ArticleList_itemContainer__UNI2Y')->each(function ($node) use ($crawler, $client) {
                $title = $node->filter('.ArticleList_title__mmSkv')->text(); // タイトル
                $relative_path = $node->filter('.ArticleList_link__4Igs4')->attr('href');
                $link = '<https://zenn.dev>'.$relative_path; // リンク
                $author_name = $node->filter('.ArticleList_userName__MlDD5 a')->text(); // 著者名
                $title_link = $crawler->selectLink($title)->link();
                $crawler = $client->click($title_link);
                $author_profile_image_url = $crawler->filter('.AvatarImage_plain__Fgp4R')->attr('src'); // 著者プロフィール画像URL
                $article_created_at = $crawler->filter('.ArticleHeader_num__7Zpz0')->text(); // 公開日時
                // dump($article_created_at);

                // DBに保存
                $article_with_trashed = Article::withTrashed()
                    ->where('url', $link)
                    ->first();
                if (!$article_with_trashed) {
                    // データを標準出力
                    dump('新規記事');
                    $line = 'タイトル: '.$title.",".'リンク: '.$link.",".'著者名: '.$author_name.",".'著者プロフィール画像URL: '.$author_profile_image_url;
                    dump($line);
                }
                $article = $article_with_trashed ? $article_with_trashed : new Article();
                $article->title = $title;
                $article->url = $link;
                $article->source = 'Zenn';
                $article->author_name = $author_name;
                $article->author_profile_image_url = $author_profile_image_url;
                $article->article_created_at = $article_created_at;
                $article->article_updated_at = $article_created_at;
                $article->save();
            });
            DB::commit();
        } catch (\\Exception $e) {
            logger()->critical('file: ' . $e->getFile() . ' line: ' . $e->getLine() . ' message: ' . $e->getMessage());
            DB::rollback();
        }
    }
}

細かな実装の説明は割愛しますが、dom要素のクラス名などから記事タイトルやリンクや著者名などを取得し、データベースに保存しています。

API実行共通処理

最後に、APIを実行するための共通処理を作っていきます。

CommonControllerを作成します。

php artisan make:controller CommonController

共通処理はexecCurlメソッドに書きます。

<?php

namespace App\\Http\\Controllers;

use Illuminate\\Http\\Request;

class CommonController extends Controller
{
    public static function execCurl($headers, $url)
    {
        // curlのセッションを初期化
        $ch = curl_init();

        // オプションを設定
        $options = [
            CURLOPT_URL => $url,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $headers,
        ];
        curl_setopt_array($ch, $options);

        // 実行
        $response = curl_exec($ch);
        $json = json_decode($response, true); // 文字列からjsonへ変換

        curl_close($ch);

        return $json;
    }
}

はい、これで完成です。

動作確認

最後に、各コマンドを実行して実際に記事データがデータベースに保存されるか確認してみましょう。

php artisan getQiitaPage
php artisan getNotePage
php artisan getZennPage

これらのコマンドが全て通り、データベースにデータが保存されたら成功です。

今回はここまでにします。

次回は、管理側からも記事を手動で登録することができるように管理画面を作成していきます。

お楽しみに😊

コメント

タイトルとURLをコピーしました