| 1 |
<?php |
| 2 |
|
| 3 |
// IndexNow: instantly notifies Bing, Yandex, Naver, Seznam (and every IndexNow-compatible |
| 4 |
// engine) when content is published or updated, so new pages don't sit waiting for a crawl. |
| 5 |
// Google does not consume IndexNow; this complements the sitemap, it does not replace it. |
| 6 |
// Spec: https://www.indexnow.org/documentation |
| 7 |
class Meow_MWSEO_Modules_IndexNow |
| 8 |
{ |
| 9 |
private $core = null; |
| 10 |
|
| 11 |
public function __construct( $core ) { |
| 12 |
$this->core = $core; |
| 13 |
$this->init(); |
| 14 |
} |
| 15 |
|
| 16 |
public function init() { |
| 17 |
// Rides the Technical SEO module: one switch, fully automatic, on by default. |
| 18 |
if ( !$this->core->get_option( 'technical_seo', true ) || !$this->core->get_option( 'indexnow', true ) ) { |
| 19 |
return; |
| 20 |
} |
| 21 |
add_action( 'init', array( $this, 'serve_key_file' ), 0 ); |
| 22 |
add_action( 'transition_post_status', array( $this, 'on_transition' ), 10, 3 ); |
| 23 |
} |
| 24 |
|
| 25 |
// The key is self-generated once; engines verify ownership by fetching /{key}.txt. |
| 26 |
public function get_key() { |
| 27 |
$key = $this->core->get_option( 'indexnow_key', '' ); |
| 28 |
if ( empty( $key ) ) { |
| 29 |
$key = strtolower( wp_generate_password( 32, false, false ) ); |
| 30 |
$this->core->update_option( 'indexnow_key', $key ); |
| 31 |
} |
| 32 |
return $key; |
| 33 |
} |
| 34 |
|
| 35 |
// Serve the verification file at the site root without touching rewrite rules |
| 36 |
// (works on every host and on multi-domain installs, where each domain serves it). |
| 37 |
public function serve_key_file() { |
| 38 |
if ( empty( $_SERVER['REQUEST_URI'] ) ) return; |
| 39 |
$path = trim( (string) parse_url( $_SERVER['REQUEST_URI'], PHP_URL_PATH ), '/' ); |
| 40 |
if ( substr( $path, -4 ) !== '.txt' || strlen( $path ) !== 36 ) return; |
| 41 |
$key = $this->get_key(); |
| 42 |
if ( $path !== $key . '.txt' ) return; |
| 43 |
header( 'Content-Type: text/plain; charset=utf-8' ); |
| 44 |
echo $key; |
| 45 |
exit; |
| 46 |
} |
| 47 |
|
| 48 |
public function on_transition( $new_status, $old_status, $post ) { |
| 49 |
// Fires for first publish AND for updates of already-published posts. |
| 50 |
if ( $new_status !== 'publish' || !$post || wp_is_post_revision( $post ) ) return; |
| 51 |
|
| 52 |
$public_types = get_post_types( array( 'public' => true ) ); |
| 53 |
unset( $public_types['attachment'] ); |
| 54 |
if ( !in_array( $post->post_type, $public_types, true ) ) return; |
| 55 |
|
| 56 |
// No-index posts are excluded on purpose; don't ask engines to fetch them. |
| 57 |
$excluded = array_map( 'intval', (array) $this->core->get_option( 'sitemap_excluded_post_ids', [] ) ); |
| 58 |
if ( in_array( (int) $post->ID, $excluded, true ) ) return; |
| 59 |
|
| 60 |
$url = get_permalink( $post ); |
| 61 |
if ( !$url ) return; |
| 62 |
|
| 63 |
// Throttle: a burst of saves on the same post pings once every 10 minutes. |
| 64 |
$lock = 'mwseo_indexnow_' . $post->ID; |
| 65 |
if ( get_transient( $lock ) ) return; |
| 66 |
set_transient( $lock, 1, 10 * MINUTE_IN_SECONDS ); |
| 67 |
|
| 68 |
$this->ping( array( $url ) ); |
| 69 |
} |
| 70 |
|
| 71 |
// Submit URLs to api.indexnow.org (shared endpoint: one ping reaches every engine). |
| 72 |
// Multi-domain installs (Polylang) group by host, since key location must match the host. |
| 73 |
public function ping( $urls ) { |
| 74 |
$urls = array_values( array_filter( array_map( 'esc_url_raw', (array) $urls ) ) ); |
| 75 |
if ( empty( $urls ) ) return false; |
| 76 |
|
| 77 |
$key = $this->get_key(); |
| 78 |
$by_host = array(); |
| 79 |
foreach ( $urls as $url ) { |
| 80 |
$host = parse_url( $url, PHP_URL_HOST ); |
| 81 |
if ( !$host ) continue; |
| 82 |
$by_host[ $host ][] = $url; |
| 83 |
} |
| 84 |
|
| 85 |
foreach ( $by_host as $host => $host_urls ) { |
| 86 |
$scheme = parse_url( $host_urls[0], PHP_URL_SCHEME ) ?: 'https'; |
| 87 |
$body = array( |
| 88 |
'host' => $host, |
| 89 |
'key' => $key, |
| 90 |
'keyLocation' => $scheme . '://' . $host . '/' . $key . '.txt', |
| 91 |
'urlList' => array_slice( $host_urls, 0, 10000 ), |
| 92 |
); |
| 93 |
// Non-blocking: publishing must never wait on a third-party endpoint. |
| 94 |
wp_remote_post( 'https://api.indexnow.org/indexnow', array( |
| 95 |
'blocking' => false, |
| 96 |
'timeout' => 3, |
| 97 |
'headers' => array( 'Content-Type' => 'application/json; charset=utf-8' ), |
| 98 |
'body' => wp_json_encode( $body ), |
| 99 |
) ); |
| 100 |
$this->core->log( '📣 IndexNow pinged ' . count( $host_urls ) . ' URL(s) on ' . $host ); |
| 101 |
} |
| 102 |
return true; |
| 103 |
} |
| 104 |
} |
| 105 |
|