data = new Data(); add_action( 'admin_init', [ $this, 'scheduleScan' ], 3003 ); add_action( $this->actionName, [ $this, 'checkLinkStatuses' ], 11, 1 ); } /** * Schedules the link status scan as a recurring action. * * @since 1.0.0 * @version 1.2.9 Switch to recurring action with cache-based idle state. * * @return void */ public function scheduleScan() { if ( ! aioseoBrokenLinkChecker()->license->isActive() ) { return; } // If we're in idle/backoff mode, unschedule and don't reschedule yet. if ( aioseoBrokenLinkChecker()->core->cache->get( 'as_blc_link_status_idle' ) ) { aioseoBrokenLinkChecker()->actionScheduler->unschedule( $this->actionName ); return; } if ( aioseoBrokenLinkChecker()->actionScheduler->isScheduled( $this->actionName ) ) { return; } aioseoBrokenLinkChecker()->actionScheduler->scheduleRecurrent( $this->actionName, 10, MINUTE_IN_SECONDS ); } /** * Sends links to the server to check their status. * * @since 1.0.0 * @version 1.2.9 Use recurring action with runtime lock and idle state. * * @return void */ public function checkLinkStatuses() { // Runtime lock: Prevent concurrent execution of this action. $lockKey = 'as_blc_link_status_running'; if ( aioseoBrokenLinkChecker()->core->cache->get( $lockKey ) ) { return; } // Set lock with a safety timeout in case the action fails mid-execution. aioseoBrokenLinkChecker()->core->cache->update( $lockKey, true, 2 * MINUTE_IN_SECONDS ); if ( ! aioseoBrokenLinkChecker()->license->isActive() ) { aioseoBrokenLinkChecker()->core->cache->delete( $lockKey ); return; } $scanId = aioseoBrokenLinkChecker()->internalOptions->internal->scanId; if ( ! empty( $scanId ) ) { // If we have a scan ID, check if the results are ready. $this->checkForScanResults(); aioseoBrokenLinkChecker()->core->cache->delete( $lockKey ); return; } // If we don't have a scan ID, first check if there are links that need to be checked. $linksToCheck = $this->data->getlinksToCheck(); if ( empty( $linksToCheck ) ) { // No links to check - set idle cache. The schedule method on the next init will unschedule. aioseoBrokenLinkChecker()->core->cache->update( 'as_blc_link_status_idle', true, HOUR_IN_SECONDS ); aioseoBrokenLinkChecker()->core->cache->delete( $lockKey ); return; } // If there are links to check, start a new scan. $this->startScan(); aioseoBrokenLinkChecker()->core->cache->delete( $lockKey ); } /** * Start a scan and store the scan ID. * * @since 1.0.0 * @version 1.2.9 Remove self-scheduling; recurring action handles next tick. Unschedule + idle on API errors. * * @return void */ private function startScan() { $requestBody = array_merge( $this->data->getBaseData(), [ 'links' => $this->data->getlinksToCheck() ] ); $response = $this->doPostRequest( 'scan/start/', $requestBody ); $responseCode = (int) wp_remote_retrieve_response_code( $response ); if ( 401 === $responseCode ) { // Set idle cache. The schedule method on the next init will unschedule. aioseoBrokenLinkChecker()->core->cache->update( 'as_blc_link_status_idle', true, DAY_IN_SECONDS + wp_rand( 60, 600 ) ); return; } if ( 418 === $responseCode ) { aioseoBrokenLinkChecker()->core->cache->update( 'as_blc_link_status_idle', true, HOUR_IN_SECONDS + wp_rand( 60, 600 ) ); return; } $responseBody = json_decode( wp_remote_retrieve_body( $response ) ); if ( is_wp_error( $response ) || 200 !== $responseCode || empty( $responseBody->success ) || empty( $responseBody->scanId ) || ! isset( $responseBody->quotaRemaining ) ) { if ( ! empty( $responseBody->error ) && 'out-of-quota' === strtolower( $responseBody->error ) ) { // If the scan failed because the user is out of quota, check again in 24h to see if the quota has been replenished. aioseoBrokenLinkChecker()->core->cache->update( 'as_blc_link_status_idle', true, DAY_IN_SECONDS + wp_rand( 60, 600 ) ); return; } // Generic error: just return and let the next recurring tick retry. return; } aioseoBrokenLinkChecker()->internalOptions->internal->scanId = $responseBody->scanId; aioseoBrokenLinkChecker()->internalOptions->internal->license->quotaRemaining = $responseBody->quotaRemaining; if ( aioseoBrokenLinkChecker()->internalOptions->internal->license->quota !== $responseBody->quota ) { // If the quota changed, reactivate the license to pull in the latest date from the marketing site. aioseoBrokenLinkChecker()->internalOptions->internal->license->quota = $responseBody->quota; aioseoBrokenLinkChecker()->license->activateProgrammatic(); } } /** * Checks if the scan has been completed. If so, parses and stores the results. * * @since 1.0.0 * @version 1.2.9 Remove self-scheduling; recurring action handles next tick. Idle on API errors. * * @return void */ private function checkForScanResults() { $scanId = aioseoBrokenLinkChecker()->internalOptions->internal->scanId; if ( empty( $scanId ) ) { return; } $response = $this->doPostRequest( "scan/{$scanId}/" ); $responseCode = (int) wp_remote_retrieve_response_code( $response ); if ( 401 === $responseCode ) { aioseoBrokenLinkChecker()->core->cache->update( 'as_blc_link_status_idle', true, DAY_IN_SECONDS + wp_rand( 60, 600 ) ); return; } if ( 418 === $responseCode ) { aioseoBrokenLinkChecker()->core->cache->update( 'as_blc_link_status_idle', true, HOUR_IN_SECONDS + wp_rand( 60, 600 ) ); return; } $responseBody = json_decode( wp_remote_retrieve_body( $response ) ); if ( is_wp_error( $response ) && 200 !== $responseCode || empty( $responseBody->success ) ) { // If the scan data cannot be found on the server, wipe the scan ID so the scan restarts. if ( ! empty( $responseBody->error ) && 'missing-scan-data' === strtolower( $responseBody->error ) ) { aioseoBrokenLinkChecker()->internalOptions->internal->scanId = ''; } // Generic error: just return and let the next recurring tick retry. return; } $this->parseResults( $responseBody ); aioseoBrokenLinkChecker()->internalOptions->internal->license->quotaRemaining = $responseBody->quotaRemaining; if ( aioseoBrokenLinkChecker()->internalOptions->internal->license->quota !== $responseBody->quota ) { // If the quota changed, reactivate the license to pull in the latest date from the marketing site. aioseoBrokenLinkChecker()->internalOptions->internal->license->quota = $responseBody->quota; aioseoBrokenLinkChecker()->license->activateProgrammatic(); } // Once the request is successful, we know the scan has been completed and we can go ahead and reset it. $this->doDeleteRequest( "scan/{$scanId}/" ); aioseoBrokenLinkChecker()->internalOptions->internal->scanId = ''; } /** * Parse the results that came back from the server. * * @since 1.0.0 * * @param Object $responseBody The response body object. * @return void */ private function parseResults( $responseBody ) { $scanData = json_decode( $responseBody->scanData ); if ( empty( $scanData ) || empty( $scanData->urls ) ) { return; } foreach ( $scanData->urls as $url ) { $this->parseResultsHelper( $url ); } } /** * Helper function for parseResults(). * * @since 1.0.0 * * @param Object $url The URL object. * @return void */ public function parseResultsHelper( $url ) { $linkStatus = Models\LinkStatus::getByUrl( $url->url ); if ( ! $linkStatus->exists() || empty( $url->data ) ) { return; } if ( empty( $url->data->status ) ) { $linkStatus->scanning = false; $linkStatus->broken = true; $linkStatus->http_status_code = null; $linkStatus->request_duration = 0; $linkStatus->final_url = ''; $linkStatus->scan_count = $linkStatus->scan_count + 1; $linkStatus->last_scan_date = aioseoBrokenLinkChecker()->helpers->timeToMysql( time() ); $linkStatus->log = [ 'error' => ! empty( $url->data->error ) ? $url->data->error : '', 'headers' => ! empty( $url->data->headers ) ? $url->data->headers : '' ]; if ( ! $linkStatus->first_failure ) { $linkStatus->first_failure = aioseoBrokenLinkChecker()->helpers->timeToMysql( time() ); } $linkStatus->save(); return; } $success = (int) $url->data->status < 400; $redirectCount = count( $url->data->redirects ); $finalUrl = $redirectCount ? $url->data->redirects[ $redirectCount - 1 ] : ''; $linkStatus->scanning = false; $linkStatus->broken = ! $success; $linkStatus->http_status_code = (int) $url->data->status; $linkStatus->redirect_count = $redirectCount; $linkStatus->final_url = $finalUrl; $linkStatus->request_duration = ! empty( $url->data->stats->loadTime ) ? abs( $url->data->stats->loadTime ) : 0; $linkStatus->scan_count = $linkStatus->scan_count + 1; $linkStatus->last_scan_date = aioseoBrokenLinkChecker()->helpers->timeToMysql( time() ); $linkStatus->log = [ 'error' => ! empty( $url->data->error ) ? $url->data->error : '', 'headers' => ! empty( $url->data->headers ) ? $url->data->headers : '' ]; if ( $success ) { $linkStatus->last_success = aioseoBrokenLinkChecker()->helpers->timeToMysql( time() ); $linkStatus->first_failure = null; } elseif ( ! $linkStatus->first_failure ) { $linkStatus->first_failure = aioseoBrokenLinkChecker()->helpers->timeToMysql( time() ); } $linkStatus->save(); } /** * Returns the URL for the Broken Link Checker server. * * @since 1.0.0 * * @return string The URL. */ public function getUrl() { if ( defined( 'AIOSEO_BROKEN_LINK_CHECKER_SCAN_URL' ) ) { return AIOSEO_BROKEN_LINK_CHECKER_SCAN_URL; } return $this->baseUrl; } /** * Sends a POST request to the server. * * @since 1.0.0 * * @param string $path The path. * @param array $requestBody The request body. * @return array|\WP_Error The response or WP_Error on failure. */ public function doPostRequest( $path, $requestBody = [] ) { $requestData = [ 'timeout' => 60 ]; if ( ! empty( $requestBody ) ) { $requestData['body'] = wp_json_encode( $requestBody ); } return aioseoBrokenLinkChecker()->helpers->wpRemotePost( $this->getUrl() . $path, $requestData ); } /** * Sends a DELETE request to the server. * * @since 1.0.0 * * @param string $path The path. * @return array|\WP_Error The response or WP_Error on failure. */ public function doDeleteRequest( $path ) { return aioseoBrokenLinkChecker()->helpers->wpRemoteDelete( $this->getUrl() . $path, [ 'timeout' => 60 ] ); } }