PHP 初心者が WordPress の プラグイン 作成 ! part76 ( ココナラブログプラグイン完成!?)

この記事では PHP 初心者 が WordPress プラグイン を 作成 します。 part76 ではココナラブログプラグインの現行の実装を公開します。

前回、ついにココナラブログヘルパーを使用して投稿を果たしました。

PHP 初心者が WordPress の プラグイン 作成 ! part75 ( ココナラブログの要素ビルダーの変換失敗調査)

時間と都合で相当機能や条件を絞ったのですがひとまず完成しました。

実装を紹介したいと思います。

plugin.php

このファイルはWordPressのトリガーを定義するための器という役目にしておきました。

それ以外の実装はすべてココナラヘルパーに丸投げしています。

<?php
/*
Plugin Name: WordPress to Coconara
*/
require_once 'class.coconara-helper.php';

// クラスをまとめてインポート(PHP7.0.0から)
use com\ik_genety\plugin\coconara\{Coconara_Helper};

add_action( 'transition_post_status', 'publishedCoconara', 10, 3 );

function publishedCoconara( $new_status, $old_status, $post ) { 
    if ( $old_status != 'publish' && $new_status == 'publish' ) { 
        // 投稿ステータスが公開以外から公開へ変化するとき実行する処理を記載 
        // $postからいろいろ取得できるので、記事IDが欲しい場合などは以下のようにする 
        //$ID = $post->ID; 

        $coconara = new Coconara_Helper(true);

        $title = $post->post_title;
        $contents = $post->post_content;

        if($coconara->post("{$title}", "{$contents}",true)) {
            //echo '投稿成功!';
        } else {
            //echo '投稿失敗!';
        }

    } 
} 
?>

class.coconara-helper.php

このクラスが本丸です。このクラスを作るのに2か月以上を要するしたとか・・・

<?php
namespace com\ik_genety\plugin\coconara;

// autoloadを読み込む
require 'vendor/autoload.php';
require_once 'class.coconara-builder.php';

use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Page;

use HeadlessChromium\Exception\OperationTimedOut;
use HeadlessChromium\Exception\NavigationExpired;

class Coconara_Helper {
    // ヘッドレスブラウザ
    private $browser;
    private $page;

    // デバッグモード
    private $debug;

    // ココナラアカウント
    private const USER_NAME = 'hoge@mail.com';
    private const USER_PASSWORD = 'fuga';

    /**
     * コンストラクタ
     * @param bool $debug デバッグフラグ
     */
    function __construct(bool $debug = false) {
        // デバッグモードをセット
        $this->debug = $debug;

        // whereis chromiumでパスを探した
        $browserFactory = new BrowserFactory('/usr/bin/chromium');

        // for Docker option
        $options = [
            'headless' => true,
            'noSandbox' => true ,
            'sendSyncDefaultTimeout' => 100000,
            //'windowSize'      => [1920, 1000],
            'userAgent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.212 Safari/537.36',
            'enableImages'    => false,
        ];

        // ヘッドレスブラウザスタート
        $this->browser = $browserFactory->createBrowser($options);

        // 到達できない場合エラー
        if(!$this->reachable()) {
            throw new Exception('サイトに到達できませんでした。');
        }
    }

    // 到達確認
    private function reachable(): bool
    {
        $oya = get_parent_class($this);
        // トップページを取得する
        $tmpPage = $this->getFirstPage();

        // ページタイトル取得
        $pageTitle = $tmpPage->evaluate('document.title')->getReturnValue();
        
        //echo $pageTitle;

        // ブラウザからページを破棄
        $tmpPage->close();

        // タイトル確認
        return $pageTitle === 'ココナラ - みんなの得意を売り買い スキルマーケット';
    }

    // トップページを取得
    private function getFirstPage(): Page
    {
        // 新しいタブを作成しサイトにアクセス
        $tmpPage = $this->browser->createPage();
        $tmpPage->navigate('https://coconala.com/')->waitForNavigation();

        return $tmpPage;
    }
    

    // ログインを行う
    private function login() : bool
    {
        // トップページを取得する
        $this->page = $this->getFirstPage();

        // ココナラのログイン画面に遷移する(30秒待つ)
        $this->page->evaluate('document.querySelector("li.c-mainNavBeforeLogin_item-pc a:first-child").click()')->waitForPageReload(Page::LOAD, 30000);

        // アカウント入力
        $this->page->evaluate(
        '(() => {
            document.querySelector("#UserLoginEmail").value = "'.self::USER_NAME.'";
            document.querySelector("#UserLoginPassword").value = "'.self::USER_PASSWORD.'";
            document.querySelector("form#UserLoginForm button[type=\"submit\"]").removeAttribute("disabled");
        })()');
        
        // ログインを実施(エラーは全て無視)
        try{
            $evaluation = $this->page->evaluate('document.querySelector("form#UserLoginForm button[type=\"submit\"]").click()')->waitForPageReload(Page::LOAD, 30000);
        } catch(OperationTimedOut $e) {
        } catch(NavigationExpired $e) {
        }

        // ダッシュボードのリンク確認を行ってログイン状態か確認する
        $isLogin = $this->page->evaluate('document.querySelector(".c-mainNavProviderLeft_item") != null ? true : false;')->getReturnValue();
        return $isLogin;

    }

    /**
     * 画面遷移をする
     * エラーを無視しない場合、Exceptionが発生した場合は失敗となる。
     * @param string $query セレクタ
     * @param int $waitTime 待ち時間
     * @param ignoreError エラーを無視する
     * @return true 成功, false 失敗
     */
    private function moveAndWait(string $query, int $waitTime = 30000, bool $ignoreError = true) : bool {
        $result = false;

        // 遷移
        try{
            $this->page->evaluate($query)->waitForPageReload(Page::LOAD, $waitTime);
            $result = true;
        } catch(\Throwable  $e) {
            if($ignoreError) {
                $result = true;
            } else {
                //echo $ex->getMessage();
                $result = false;
            }
        } 
        if($this->debug) {
            // 年月日_時分秒(YYYYMMDD_hhmmss)
            $t = date('Ymd_His');
            // screenshot(10秒待機)
            $this->page->screenshot()->saveToFile("/var/www/html/moveAndWait_{$t}.png", 10000);
        }

        return $result;
    }

    /**
     * 処理を実行をする
     * @param string $query セレクタ
     */
    private function execute(string $query) : void {
        
        // 実行
        $this->page->evaluate($query);
        
        if($this->debug) {
            // 年月日_時分秒(YYYYMMDD_hhmmss)
            $t = date('Ymd_His');
            // screenshot(10秒待機)
            $this->page->screenshot()->saveToFile("/var/www/html/execute_{$t}.png", 10000);
        }
    }


    /**
     * 投稿する
     * @param string $title タイトル
     * @param string $contents コンテンツ
     * @return bool 投稿成功・失敗
     */
    public function post(string $title, string $contents) : bool {
        $result = false;

        // ログイン
        if($this->login()) {
            // ブログ画面に遷移(ブログを投稿する→記事の種類選択で編集画面に遷移)
            if($this->moveAndWait('document.querySelector("a.c-subLink_item-blog").click()')
                && $this->moveAndWait('document.querySelector("button.button.is-primary").click()', waitTime:10000)) {


                    // ブログを編集する

                    // 本文を分解する
                    $fixedRetuenCodeContents = str_replace(array("\r\n", "\r", "\n"), "\n", $contents);
                    $contentArray = explode("\n", $fixedRetuenCodeContents);
                    
                    // 値の生成
                    $contentForBody = "";
                    $contentForBodyText = "";
                    foreach ($contentArray as $item) {

                        // ビルダー生成
                        $builder = new Coconara_Builder($item, true);

                        if(!$builder->isEmpty()) {
                            // body用の値
                            $contentForBody .= $builder->getBody();
                            
                            // bodyText用の値
                            $contentForBodyText .= $builder->getBodyText();
                        }
                    }
                     
                    // ヘッドレスブラウザに渡すクエリの作成
                    $titleAndContentsQuery = 
                    '(() => {
                        obj = document.querySelector("[data-v-46ae5ace]");
                        obj.__vue__.title = "'.$title.'";
                        obj.__vue__.body = "'.$contentForBody.'";
                        obj.__vue__.bodyText = "'.$contentForBodyText.'";
                        obj.__vue__.blogEmpty = false;
                    })()';

                    // 処理実行
                    $this->execute($titleAndContentsQuery);


                    // 公開画面を編集し投稿する

                    // 画面表示
                    if($this->moveAndWait('document.querySelector("button.button.c-blogPost_triggerPublish").click();', waitTime:10000)) {

                        // カテゴリとハッシュタグを指定
                        $categoryAndHashTagQuery = 
                        '(() => {
                            // カテゴリの選択
                            document.querySelector("div.c-blogPublishing_category select option[value=\"9\"").selected = true;
                            document.querySelector("div.c-blogPublishing_category select").dispatchEvent(new Event("change"));
                      
                            // ハッシュタグの指定
                            obj = document.querySelector("[data-v-46ae5ace]");
                            obj.__vue__.blogTagNames.push(" IT");
                            obj.__vue__.blogTagNames.push(" WordPress");
                    
                        })()';

                        // 処理実行
                        $this->execute($categoryAndHashTagQuery);


                        // 投稿!
                        if($this->moveAndWait('document.querySelector("button[data-v-6c22d71e].button.is-primary").click();', waitTime:10000)) {
                            $result = true;
                        } else {
                            $result = false;
                        }
                    } else {
                        $result = false;
                    }
            } else {
                $result = false;
            }
        } else {
            $result = false;
        }

        if($this->debug) {
            // screenshot(10秒待機)
            $this->page->screenshot()->saveToFile('/var/www/html/post.png', 10000);
        }
        return $result;
    }

    
    
    /**
     * デストラクタ
     */
    function __destruct() {
        // ヘッドレスブラウザを閉じる
        $this->browser->close();
    }
}

class.coconara-builder.php

ココナラブログは本文が独特な要素を使っています。

WordPressのタグをそのまま持って行ってもうまく表現できません。

この要素ビルダーで害のない文字列に変換します。

といってもすべてのタグを取り除く程度の実装しかしていませんが😅

<?php
namespace com\ik_genety\plugin\coconara;

class Coconara_Builder {

    // 文字列
    private string $lineText;

    // デバッグモード
    private bool $debug;

    // 編集用の変数
    private string  $fixedText;

    /**
     * コンストラクタ
     * @param string $lineText 1行分のWordPress文字列
     * @param bool $debug デバッグフラグ
     */
    function __construct(string $lineText, bool $debug = false) {
        // デバッグモードをセット
        $this->debug = $debug;
        
        // オリジナルテキストをセット
        $this->lineText = $lineText;

        // タグを全て除去
        $fixedText = preg_replace('/<("[^"]*"|\'[^\']*\'|[^\'">])*>/u', "", $this->lineText);

        // リンクを無効化
        $fixedText = str_replace("http", "ttp", $fixedText);

        // HTML文字エスケープ
        $fixedText = htmlspecialchars($fixedText, ENT_QUOTES);

        $this->fixedText = $fixedText;
    }

    /**
     * ココナラブログのbodyプロパティ用の文字列を取得する
     * @return bodyプロパティ文字列
     */
    function getBody() : string{
        
        return "<div data-v-22617325=\\\"\\\" class=\\\"c-blogBody_text\\\">{$this->fixedText}</div>";
    }

    /**
     * ココナラブログのbodyTextプロパティ用の文字列を取得する
     * @return bodyTextプロパティ文字列
     */
    function getBodyText() : string {
        return "{$this->fixedText}\\n";
    }

    /**
     * 文字列が空かどうか確認する
     */
    function isEmpty() : bool {
        return empty($this->fixedText);
    }

}

実運用

実運用での使用は・・・今のところまだ無理があるかと思います。

理由はやはり処理時間です。なんでこんなに時間がかかるんでしょう・・。

まとめ

ココナラブログプラグインの実装を公開しました。

コード量はあまり多くありません。

時間がかかった要因は以下のとおりです。

  • PHP言語
  • ココナラブログの仕様
  • 細切れの時間での実装

このシリーズはいったんここで終わりにしようと思います。100回記念とかやりたくないので😅

このプラグインは細々と改良して実用レベルまでもっていきたいと思います。いつの日か・・・。

また新しいことを何か始めます。できるだけ単発にしたい!

今日はここまで!