PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.0
2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 All 50 releases
thinkrank / includes / admin / importers / class-yoast-exporter.php

class-yoast-exporter.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.9.0, at includes/admin/importers/class-yoast-exporter.php

998 lines 42.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Yoast SEO Exporter
5 *
6 * Reads Yoast SEO data from postmeta/termmeta/options and normalizes
7 * into the canonical snapshot format.
8 *
9 * @package ThinkRank\Admin\Importers
10 * @since 2.0.0
11 */
12
13 declare(strict_types=1);
14
15 namespace ThinkRank\Admin\Importers;
16
17 if (!defined('ABSPATH')) {
18 exit;
19 }
20
21 /**
22 * Yoast Exporter Class
23 *
24 * @since 2.0.0
25 */
26 class Yoast_Exporter extends Abstract_Plugin_Exporter {
27
28 /**
29 * Constructor
30 */
31 public function __construct() {
32 $this->plugin_slug = 'yoast';
33 $this->plugin_name = 'Yoast SEO';
34 $this->plugin_file = 'wordpress-seo/wp-seo.php';
35 $this->meta_key_prefix = '_yoast_wpseo_';
36 $this->option_keys = ['wpseo', 'wpseo_titles', 'wpseo_social'];
37 }
38
39 /**
40 * {@inheritDoc}
41 */
42 public function detect(): bool {
43 global $wpdb;
44
45 $count = (int) $wpdb->get_var(
46 $wpdb->prepare(
47 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s LIMIT 1",
48 $wpdb->esc_like($this->meta_key_prefix) . '%'
49 )
50 );
51
52 return $count > 0;
53 }
54
55 /**
56 * {@inheritDoc}
57 */
58 public function get_available_types(): array {
59 global $wpdb;
60
61 $types = [];
62
63 $post_count = (int) $wpdb->get_var(
64 $wpdb->prepare(
65 "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
66 $wpdb->esc_like($this->meta_key_prefix) . '%'
67 )
68 );
69 if ($post_count > 0) {
70 $types['postmeta'] = $post_count;
71 }
72
73 // Yoast stores term SEO in the wpseo_taxonomy_meta option, not termmeta.
74 $term_count = count($this->get_flattened_taxonomy_meta());
75 if ($term_count > 0) {
76 $types['termmeta'] = $term_count;
77 }
78
79 // Check for user meta
80 $user_count = (int) $wpdb->get_var(
81 $wpdb->prepare(
82 "SELECT COUNT(DISTINCT user_id) FROM {$wpdb->usermeta} WHERE meta_key LIKE %s",
83 $wpdb->esc_like('wpseo_') . '%'
84 )
85 );
86 if ($user_count > 0) {
87 $types['usermeta'] = $user_count;
88 }
89
90 // Settings always available if options exist
91 foreach ($this->option_keys as $key) {
92 if (get_option($key, null) !== null) {
93 $types['settings'] = 1;
94 break;
95 }
96 }
97
98 return $types;
99 }
100
101 /**
102 * {@inheritDoc}
103 */
104 protected function export_postmeta_page(int $page): array {
105 $post_ids = $this->get_post_ids_with_meta($page);
106
107 if (empty($post_ids)) {
108 return [];
109 }
110
111 $records = [];
112 foreach ($post_ids as $post_id) {
113 $post_id = (int) $post_id;
114 $meta = $this->get_all_plugin_meta($post_id);
115
116 if (empty($meta)) {
117 continue;
118 }
119
120 $robots = $this->map_yoast_post_robots(
121 $meta['_yoast_wpseo_meta-robots-noindex'] ?? '',
122 $meta['_yoast_wpseo_meta-robots-nofollow'] ?? ''
123 );
124
125 $advanced_robots = $this->parse_yoast_advanced_robots($meta['_yoast_wpseo_meta-robots-adv'] ?? '');
126
127 $records[] = [
128 'object_id' => $post_id,
129 'object_type' => 'post',
130 'source_plugin' => $this->plugin_slug,
131 'data' => [
132 'seo_title' => $this->convert_template_variables($meta['_yoast_wpseo_title'] ?? '', $post_id),
133 'meta_description' => $this->convert_template_variables($meta['_yoast_wpseo_metadesc'] ?? '', $post_id),
134 'focus_keyword' => $meta['_yoast_wpseo_focuskw'] ?? '',
135 // Full keyword list (primary + Yoast Premium's additional
136 // keyphrases) for ThinkRank's keyword array — without this
137 // the migrator falls back to the single focus_keyword and
138 // Premium keyphrases never reach the post.
139 'focus_keywords' => array_values(array_filter(
140 array_merge(
141 [(string) ($meta['_yoast_wpseo_focuskw'] ?? '')],
142 $this->parse_yoast_additional_keywords($meta['_yoast_wpseo_focuskeywords'] ?? '')
143 ),
144 static fn($keyword) => trim((string) $keyword) !== ''
145 )),
146 'canonical_url' => $meta['_yoast_wpseo_canonical'] ?? '',
147 'noindex' => $robots['noindex'],
148 'nofollow' => $robots['nofollow'],
149 'noarchive' => $advanced_robots['noarchive'],
150 'noimageindex' => $advanced_robots['noimageindex'],
151 'nosnippet' => $advanced_robots['nosnippet'],
152 'og_title' => $this->convert_template_variables($meta['_yoast_wpseo_opengraph-title'] ?? '', $post_id),
153 'og_description' => $this->convert_template_variables($meta['_yoast_wpseo_opengraph-description'] ?? '', $post_id),
154 'og_image' => $meta['_yoast_wpseo_opengraph-image'] ?? '',
155 'twitter_title' => $this->convert_template_variables($meta['_yoast_wpseo_twitter-title'] ?? '', $post_id),
156 'twitter_description' => $this->convert_template_variables($meta['_yoast_wpseo_twitter-description'] ?? '', $post_id),
157 'twitter_image' => $meta['_yoast_wpseo_twitter-image'] ?? '',
158 'primary_category' => (int) ($meta['_yoast_wpseo_primary_category'] ?? 0),
159 'schema_type' => $meta['_yoast_wpseo_schema_page_type'] ?? '',
160 // Yoast "cornerstone content" maps to ThinkRank pillar content.
161 'pillar_content' => (int) ($meta['_yoast_wpseo_is_cornerstone'] ?? 0),
162 ],
163 'extended' => [
164 'is_cornerstone' => (bool) ($meta['_yoast_wpseo_is_cornerstone'] ?? false),
165 'focus_keywords_additional' => $this->parse_yoast_additional_keywords($meta['_yoast_wpseo_focuskeywords'] ?? ''),
166 'breadcrumb_title' => $meta['_yoast_wpseo_bctitle'] ?? '',
167 'advanced_robots' => $meta['_yoast_wpseo_meta-robots-adv'] ?? '',
168 'schema_article_type' => $meta['_yoast_wpseo_schema_article_type'] ?? '',
169 'og_image_id' => $meta['_yoast_wpseo_opengraph-image-id'] ?? '',
170 'twitter_image_id' => $meta['_yoast_wpseo_twitter-image-id'] ?? '',
171 'estimated_reading_time' => (int) ($meta['_yoast_wpseo_estimated-reading-time-minutes'] ?? 0),
172 'wordproof_timestamp' => $meta['_yoast_wpseo_wordproof_timestamp'] ?? '',
173 'inclusive_language_score' => $meta['_yoast_wpseo_inclusive-language-score'] ?? '',
174 'linkdex' => $meta['_yoast_wpseo_linkdex'] ?? '',
175 'content_score' => $meta['_yoast_wpseo_content_score'] ?? '',
176 ],
177 ];
178 }
179
180 return $records;
181 }
182
183 /**
184 * {@inheritDoc}
185 */
186 protected function export_termmeta_page(int $page): array {
187 // Yoast keeps term SEO in the wpseo_taxonomy_meta option
188 // ($option[$taxonomy][$term_id] = [...]), NOT the termmeta table.
189 // Keys are wpseo_*-prefixed; the term description is wpseo_desc (not
190 // wpseo_metadesc) and wpseo_noindex is a string (default/index/noindex).
191 $flat = $this->get_flattened_taxonomy_meta();
192 if (empty($flat)) {
193 return [];
194 }
195
196 $offset = ($page - 1) * $this->chunk_size;
197 $slice = array_slice($flat, $offset, $this->chunk_size, true);
198 if (empty($slice)) {
199 return [];
200 }
201
202 $records = [];
203 foreach ($slice as $term_id => $meta) {
204 $term_id = (int) $term_id;
205 $noindex = (($meta['wpseo_noindex'] ?? 'default') === 'noindex') ? 1 : 0;
206
207 $records[] = [
208 'object_id' => $term_id,
209 'object_type' => 'term',
210 'source_plugin' => $this->plugin_slug,
211 'data' => [
212 'seo_title' => $this->convert_template_variables($meta['wpseo_title'] ?? ''),
213 'meta_description' => $this->convert_template_variables($meta['wpseo_desc'] ?? ''),
214 'focus_keyword' => $meta['wpseo_focuskw'] ?? '',
215 'canonical_url' => $meta['wpseo_canonical'] ?? '',
216 'noindex' => $noindex,
217 'nofollow' => 0,
218 'og_title' => $this->convert_template_variables($meta['wpseo_opengraph-title'] ?? ''),
219 'og_description' => $this->convert_template_variables($meta['wpseo_opengraph-description'] ?? ''),
220 ],
221 'extended' => [
222 'breadcrumb_title' => $meta['wpseo_bctitle'] ?? '',
223 'og_image' => $meta['wpseo_opengraph-image'] ?? '',
224 'twitter_title' => $meta['wpseo_twitter-title'] ?? '',
225 'twitter_description' => $meta['wpseo_twitter-description'] ?? '',
226 ],
227 ];
228 }
229
230 return $records;
231 }
232
233 /**
234 * {@inheritDoc}
235 */
236 protected function export_usermeta_page(int $page): array {
237 global $wpdb;
238
239 $offset = ($page - 1) * $this->chunk_size;
240
241 $user_ids = $wpdb->get_col(
242 $wpdb->prepare(
243 "SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key LIKE %s ORDER BY user_id ASC LIMIT %d OFFSET %d",
244 $wpdb->esc_like('wpseo_') . '%',
245 $this->chunk_size,
246 $offset
247 )
248 );
249
250 if (empty($user_ids)) {
251 return [];
252 }
253
254 $records = [];
255 foreach ($user_ids as $user_id) {
256 $user_id = (int) $user_id;
257
258 // Author title/description go in canonical `data` (seo_title /
259 // meta_description) so the plugin-agnostic migrator writes them to
260 // the `_thinkrank_seo_title` / `_thinkrank_meta_description` USER meta
261 // that Author_Archives_Manager reads. ThinkRank consumes those values
262 // verbatim, so resolve Yoast template tags to literals here — seeding
263 // %%name%% with the author display name (the generic converter only
264 // knows post context).
265 $display_name = (string) get_the_author_meta('display_name', $user_id);
266 $author_title = (string) (get_user_meta($user_id, 'wpseo_title', true) ?: '');
267 $author_desc = (string) (get_user_meta($user_id, 'wpseo_metadesc', true) ?: '');
268 $author_title = $this->convert_template_variables(str_replace('%%name%%', $display_name, $author_title));
269 $author_desc = $this->convert_template_variables(str_replace('%%name%%', $display_name, $author_desc));
270
271 $records[] = [
272 'object_id' => $user_id,
273 'object_type' => 'user',
274 'source_plugin' => $this->plugin_slug,
275 'data' => [
276 'seo_title' => $author_title,
277 'meta_description' => $author_desc,
278 ],
279 'extended' => [
280 'author_noindex' => (bool) get_user_meta($user_id, 'wpseo_noindex_author', true),
281 'social_profiles' => [
282 'facebook' => get_user_meta($user_id, 'facebook', true) ?: '',
283 'twitter' => get_user_meta($user_id, 'twitter', true) ?: '',
284 'linkedin' => get_user_meta($user_id, 'linkedin', true) ?: '',
285 'instagram' => get_user_meta($user_id, 'instagram', true) ?: '',
286 'youtube' => get_user_meta($user_id, 'youtube_url', true) ?: '',
287 'pinterest' => get_user_meta($user_id, 'pinterest_url', true) ?: '',
288 'wikipedia' => get_user_meta($user_id, 'wikipedia_url', true) ?: '',
289 ],
290 ],
291 ];
292 }
293
294 return $records;
295 }
296
297 /**
298 * {@inheritDoc}
299 */
300 protected function export_settings(): array {
301 // get_option()'s [] default only applies to a missing row; a row that
302 // exists but holds a scalar/false (reset, legacy, corrupted) would flow
303 // into the array-typed helpers below and throw a TypeError. Normalize.
304 $wpseo = get_option('wpseo', []);
305 $wpseo = is_array($wpseo) ? $wpseo : [];
306 $titles = get_option('wpseo_titles', []);
307 $titles = is_array($titles) ? $titles : [];
308 $social = get_option('wpseo_social', []);
309 $social = is_array($social) ? $social : [];
310
311 return [
312 [
313 'type' => 'settings',
314 'source_plugin' => $this->plugin_slug,
315 'data' => [
316 'separator' => $this->map_yoast_separator($titles['separator'] ?? 'sc-dash'),
317 'homepage_title' => $this->convert_template_variables($titles['title-home-wpseo'] ?? ''),
318 'homepage_description' => $this->convert_template_variables($titles['metadesc-home-wpseo'] ?? ''),
319 'organization_name' => $this->yoast_identity($titles, $wpseo, 'company_name'),
320 'organization_logo' => $this->yoast_identity($titles, $wpseo, 'company_logo'),
321 // Schema `alternateName` for the site.
322 'alternate_name' => $this->yoast_identity($titles, $wpseo, 'alternate_website_name'),
323 // Knowledge Graph entity: Yoast's company_or_person, with the
324 // name taken from whichever side it points at.
325 'knowledge_graph' => $this->extract_yoast_knowledge_graph($titles, $wpseo),
326 // Yoast's IndexNow key is a public verification token served
327 // at /{key}.txt, not an account secret — carrying it over
328 // avoids re-verifying the site with IndexNow.
329 'instant_indexing' => [
330 'api_key' => (string) ($wpseo['index_now_key'] ?? ''),
331 ],
332 'social_profiles' => [
333 'facebook' => $social['facebook_site'] ?? '',
334 'twitter' => $social['twitter_site'] ?? '',
335 'instagram' => $social['instagram_url'] ?? '',
336 'linkedin' => $social['linkedin_url'] ?? '',
337 'youtube' => $social['youtube_url'] ?? '',
338 'pinterest' => $social['pinterest_url'] ?? '',
339 'wikipedia' => $social['wikipedia_url'] ?? '',
340 ],
341 'noindex_archives' => [
342 'date' => !empty($titles['noindex-archive-wpseo']),
343 'author' => !empty($titles['noindex-author-wpseo']),
344 ],
345 // Default Twitter card size for ThinkRank's Social Meta
346 // settings.
347 'twitter_card_type' => (string) ($social['twitter_card_type'] ?? ''),
348 // Site-wide social defaults with direct ThinkRank homes.
349 'social_defaults' => [
350 'og_default_image' => (string) ($social['og_default_image'] ?? ''),
351 ],
352 ],
353 'extended' => [
354 'breadcrumb_settings' => [
355 'enabled' => !empty($titles['breadcrumbs-enable']),
356 'home_label' => $titles['breadcrumbs-home'] ?? 'Home',
357 'separator' => $titles['breadcrumbs-sep'] ?? '»',
358 'prefix' => (string) ($titles['breadcrumbs-prefix'] ?? ''),
359 ],
360 // Webmaster-tools verification codes. Pinterest is applied
361 // (the one ThinkRank renders); the rest is preserved and
362 // gates /import/cleanup.
363 'webmaster_tools' => array_filter([
364 'google' => (string) ($wpseo['googleverify'] ?? ''),
365 'bing' => (string) ($wpseo['msverify'] ?? ''),
366 'yandex' => (string) ($wpseo['yandexverify'] ?? ''),
367 'baidu' => (string) ($wpseo['baiduverify'] ?? ''),
368 'pinterest' => (string) ($social['pinterestverify'] ?? ''),
369 ]),
370 'post_type_settings' => $this->extract_post_type_settings($titles),
371 'taxonomy_settings' => $this->extract_taxonomy_settings($titles),
372 // Per-context title formats in ThinkRank's SITE IDENTITY token
373 // vocabulary, which is a different dialect from the Global SEO
374 // one used by post_type_settings above.
375 'title_formats' => $this->extract_yoast_title_formats($titles),
376 // Author archive behaviour (Author Archives feature).
377 'author_archives' => $this->extract_yoast_author_archives($titles),
378 // Role Manager assignments (live on the roles, not in wpseo_*).
379 'role_capabilities' => $this->extract_role_capabilities('wpseo_'),
380 // News/Video sitemap post types, when the Yoast News SEO /
381 // Video SEO add-ons are installed.
382 'publisher_sitemaps' => $this->extract_yoast_publisher_sitemaps(),
383 'og_frontpage_title' => $social['og_frontpage_title'] ?? '',
384 'og_frontpage_desc' => $social['og_frontpage_desc'] ?? '',
385 'og_frontpage_image' => $social['og_frontpage_image'] ?? '',
386 // Yoast's XML sitemap is otherwise filter-driven: per-type
387 // inclusion follows the noindex settings (already captured in
388 // post_type_settings / taxonomy_settings) and it exposes no
389 // links/images/ping options. The one real toggle is the global
390 // enable flag; only flag has_data when it is actually present so
391 // the migrator does not disable ThinkRank's sitemap by default.
392 'sitemap_settings' => [
393 'enabled' => !empty($wpseo['enable_xml_sitemap']),
394 'has_data' => array_key_exists('enable_xml_sitemap', $wpseo),
395 ],
396 ],
397 ],
398 ];
399 }
400
401 /**
402 * {@inheritDoc}
403 */
404 protected function export_redirections_page(int $page): array {
405 // Yoast Premium stores redirections in a custom table
406 global $wpdb;
407
408 $table_name = $wpdb->prefix . 'yoast_seo_redirects';
409 $table_exists = $wpdb->get_var(
410 $wpdb->prepare("SHOW TABLES LIKE %s", $table_name)
411 );
412
413 if (!$table_exists) {
414 return [];
415 }
416
417 $offset = ($page - 1) * $this->chunk_size;
418
419 $rows = $wpdb->get_results(
420 // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is $wpdb->prefix plus a literal, and every value is passed as a placeholder replacement.
421 $wpdb->prepare(
422 "SELECT * FROM {$table_name} ORDER BY id ASC LIMIT %d OFFSET %d",
423 $this->chunk_size,
424 $offset
425 ),
426 ARRAY_A
427 );
428 // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
429
430 if (empty($rows)) {
431 return [];
432 }
433
434 $records = [];
435 foreach ($rows as $row) {
436 $records[] = [
437 'object_type' => 'redirection',
438 'source_plugin' => $this->plugin_slug,
439 'data' => [],
440 'extended' => [
441 'source_url' => $row['origin'] ?? '',
442 'target_url' => $row['url'] ?? '',
443 'http_code' => (int) ($row['type'] ?? 301),
444 'is_regex' => !empty($row['format'] ?? '') && $row['format'] === 'regex',
445 'enabled' => true,
446 ],
447 ];
448 }
449
450 return $records;
451 }
452
453 /**
454 * {@inheritDoc}
455 */
456 protected function convert_template_variables($value, ?int $post_id = null): string {
457 // Foreign data first: booleans/arrays in the source plugin's options
458 // must degrade to '' here, not fatal the migration (see abstract).
459 $value = $this->stringify_template_value($value);
460
461 if (empty($value) || strpos($value, '%%') === false) {
462 return $value;
463 }
464
465 $replacements = [
466 '%%sitename%%' => get_bloginfo('name'),
467 '%%sitedesc%%' => get_bloginfo('description'),
468 '%%sep%%' => '-',
469 '%%page%%' => '',
470 '%%pagenumber%%' => '',
471 '%%pagetotal%%' => '',
472 '%%currentyear%%' => gmdate('Y'),
473 '%%currentdate%%' => gmdate('Y-m-d'),
474 '%%currentmonth%%' => gmdate('F'),
475 '%%currentday%%' => gmdate('j'),
476 ];
477
478 // Add post-specific replacements
479 if ($post_id) {
480 $post = get_post($post_id);
481 if ($post) {
482 $replacements['%%title%%'] = $post->post_title;
483 $replacements['%%excerpt%%'] = \ThinkRank\Core\Seo_Text::trim_words(
484 $post->post_excerpt ?: \ThinkRank\Core\Seo_Text::trim_words(wp_strip_all_tags($post->post_content), 55),
485 55
486 );
487 $replacements['%%date%%'] = get_the_date('', $post);
488 $replacements['%%modified%%'] = get_the_modified_date('', $post);
489 $replacements['%%id%%'] = (string) $post_id;
490 $replacements['%%name%%'] = get_the_author_meta('display_name', (int) $post->post_author);
491
492 // Post type
493 $post_type_obj = get_post_type_object($post->post_type);
494 $replacements['%%pt_single%%'] = $post_type_obj ? $post_type_obj->labels->singular_name : '';
495 $replacements['%%pt_plural%%'] = $post_type_obj ? $post_type_obj->labels->name : '';
496
497 // Category
498 $categories = get_the_category($post_id);
499 $replacements['%%category%%'] = !empty($categories) ? $categories[0]->name : '';
500 $replacements['%%primary_category%%'] = !empty($categories) ? $categories[0]->name : '';
501
502 // Tags
503 $tags = get_the_tags($post_id);
504 $replacements['%%tag%%'] = !empty($tags) ? $tags[0]->name : '';
505 }
506 }
507
508 $value = str_replace(array_keys($replacements), array_values($replacements), $value);
509
510 // Strip any remaining unknown variables
511 $value = preg_replace('/%%[a-z0-9_-]+%%/i', '', $value);
512
513 return trim($value);
514 }
515
516 /**
517 * Translate a Yoast title/description *template* into ThinkRank pattern syntax.
518 *
519 * Unlike convert_template_variables() — which resolves a per-post value to a
520 * literal string — post-type and taxonomy templates must stay templates so the
521 * frontend can resolve them per request. Yoast uses double-percent tokens
522 * (`%%title%%`); ThinkRank's Pattern_Resolver uses single-percent tokens
523 * (`%title%`) with a smaller vocabulary. Map the tokens ThinkRank can resolve
524 * and strip any Yoast token it has no equivalent for, so an imported template
525 * never renders stray `%` characters.
526 *
527 * @param mixed $template Raw Yoast template (e.g. "%%title%% %%sep%% %%sitename%%").
528 * @return string ThinkRank pattern (e.g. "%title% %sep% %sitename%").
529 */
530 private function convert_template_pattern($template): string {
531 // Foreign data first: booleans/arrays in the source plugin's options
532 // must degrade to '' here, not fatal the migration (see abstract).
533 $template = $this->stringify_template_value($template);
534
535 if ($template === '' || strpos($template, '%%') === false) {
536 return $template;
537 }
538
539 // Yoast token => ThinkRank Pattern_Resolver token. Only tokens the
540 // resolver understands (see Pattern_Resolver::placeholders_for()) are
541 // mapped; everything else is stripped below.
542 $map = [
543 '%%title%%' => '%title%',
544 '%%sitename%%' => '%sitename%',
545 '%%sep%%' => '%sep%',
546 '%%excerpt%%' => '%excerpt%',
547 '%%excerpt_only%%' => '%excerpt%',
548 '%%date%%' => '%date%',
549 '%%modified%%' => '%modified%',
550 '%%name%%' => '%author%',
551 '%%category%%' => '%category%',
552 '%%primary_category%%' => '%category%',
553 ];
554
555 $template = str_replace(array_keys($map), array_values($map), $template);
556
557 // Drop any remaining Yoast tokens ThinkRank cannot resolve
558 // (e.g. %%page%%, %%sitedesc%%, %%pt_single%%, %%tag%%).
559 $template = preg_replace('/%%[a-z0-9_-]+%%/i', '', $template);
560
561 // Tidy whitespace left by removed tokens. Pattern_Resolver also collapses
562 // whitespace/separators at render time, so this is mostly cosmetic.
563 $template = preg_replace('/\s+/', ' ', $template);
564
565 return trim($template);
566 }
567
568 /**
569 * Parse Yoast's `_yoast_wpseo_meta-robots-adv` value.
570 *
571 * Stored as a comma-separated list of advanced directives
572 * (e.g. "noimageindex,nosnippet"), or "-" / "none" when no
573 * extras are selected.
574 *
575 * @param mixed $value Raw meta value from Yoast
576 * @return array{noarchive:int,noimageindex:int,nosnippet:int}
577 */
578 private function parse_yoast_advanced_robots($value): array {
579 $result = ['noarchive' => 0, 'noimageindex' => 0, 'nosnippet' => 0];
580
581 // Foreign data first: non-string meta degrades to '' → no extras (see abstract).
582 $value = $this->stringify_template_value($value);
583
584 if ($value === '' || $value === '-' || $value === 'none') {
585 return $result;
586 }
587
588 $tokens = array_map('trim', explode(',', $value));
589 foreach ($tokens as $token) {
590 if (isset($result[$token])) {
591 $result[$token] = 1;
592 }
593 }
594
595 return $result;
596 }
597
598 /**
599 * Map Yoast's tri-state post robots values to canonical 0/1 flags.
600 *
601 * Yoast stores `_yoast_wpseo_meta-robots-noindex` as 0 (post-type default),
602 * 1 (noindex) or 2 (index) — so ONLY an explicit 1 means noindex; 2 (index)
603 * and 0 (default) must not. `_yoast_wpseo_meta-robots-nofollow` is a plain
604 * 0/1 boolean (1 = nofollow).
605 *
606 * @param mixed $noindex_raw Raw meta-robots-noindex value
607 * @param mixed $nofollow_raw Raw meta-robots-nofollow value
608 * @return array{noindex:int,nofollow:int}
609 */
610 private function map_yoast_post_robots($noindex_raw, $nofollow_raw): array {
611 return [
612 'noindex' => ((int) $noindex_raw === 1) ? 1 : 0,
613 'nofollow' => ((int) $nofollow_raw === 1) ? 1 : 0,
614 ];
615 }
616
617 /**
618 * Flatten the wpseo_taxonomy_meta option to a term_id => meta map.
619 *
620 * The option is nested as $option[$taxonomy][$term_id] = [...]. term_id is
621 * globally unique in WordPress, so keying by it is safe and gives a stable,
622 * paginatable order.
623 *
624 * @return array<int,array> Map of term_id => Yoast term meta array
625 */
626 private function get_flattened_taxonomy_meta(): array {
627 $tax_meta = get_option('wpseo_taxonomy_meta', []);
628 $tax_meta = is_array($tax_meta) ? $tax_meta : [];
629 if (!is_array($tax_meta)) {
630 return [];
631 }
632
633 $flat = [];
634 foreach ($tax_meta as $terms) {
635 if (!is_array($terms)) {
636 continue;
637 }
638 foreach ($terms as $term_id => $meta) {
639 if (is_array($meta) && (int) $term_id > 0) {
640 $flat[(int) $term_id] = $meta;
641 }
642 }
643 }
644
645 ksort($flat);
646
647 return $flat;
648 }
649
650 /**
651 * Convert a Yoast separator slug (e.g. "sc-dash") to its literal character.
652 *
653 * Yoast stores the title separator as a slug, not the glyph. An unknown or
654 * already-literal value is returned unchanged.
655 *
656 * @param string $separator Raw separator value from wpseo_titles
657 * @return string Literal separator character
658 */
659 /**
660 * Read a Yoast site-identity value, tolerating where the version stores it.
661 *
662 * Yoast moved company_name / company_logo / company_or_person / person_name /
663 * website_name / alternate_website_name from the `wpseo` option into
664 * `wpseo_titles` (around Yoast 14). Reading only `wpseo` — as this exporter
665 * originally did — silently returned nothing on every modern install, so the
666 * organization name and logo never migrated. Check the modern location
667 * first, then fall back for older installs.
668 *
669 * @param array $titles `wpseo_titles` option
670 * @param array $wpseo `wpseo` option
671 * @param string $key Setting key
672 * @return string Value, or '' when absent from both
673 */
674 private function yoast_identity(array $titles, array $wpseo, string $key): string {
675 foreach ([$titles, $wpseo] as $source) {
676 if (!empty($source[$key]) && is_scalar($source[$key])) {
677 return (string) $source[$key];
678 }
679 }
680
681 return '';
682 }
683
684 /**
685 * Extract Yoast's Knowledge Graph entity into ThinkRank's vocabulary.
686 *
687 * Yoast stores `company_or_person` ('company' | 'person') alongside separate
688 * name fields; ThinkRank models the same split as organization/person.
689 *
690 * @param array $titles `wpseo_titles` option
691 * @param array $wpseo `wpseo` option
692 * @return array{type:string,name:string}
693 */
694 private function extract_yoast_knowledge_graph(array $titles, array $wpseo): array {
695 $raw = strtolower(trim($this->yoast_identity($titles, $wpseo, 'company_or_person')));
696
697 if ($raw === 'person') {
698 return [
699 'type' => 'person',
700 'name' => $this->yoast_identity($titles, $wpseo, 'person_name'),
701 ];
702 }
703
704 if ($raw === 'company') {
705 return [
706 'type' => 'organization',
707 'name' => $this->yoast_identity($titles, $wpseo, 'company_name'),
708 ];
709 }
710
711 // Yoast also allows "neither" (empty), which the migrator skips.
712 return ['type' => '', 'name' => ''];
713 }
714
715 /**
716 * Map Yoast's per-context title formats onto ThinkRank's Site Identity keys.
717 *
718 * A DIFFERENT token dialect from `post_type_settings`: the Site Identity
719 * renderer resolves %site_title%/%post_title%/%category_title%/… while the
720 * Global SEO renderer resolves %title%/%sitename%/%excerpt%. Converting with
721 * the wrong one renders the token literally on the front end.
722 *
723 * @param array $titles `wpseo_titles` option
724 * @return array Map of ThinkRank title-format key => converted template
725 */
726 private function extract_yoast_title_formats(array $titles): array {
727 // ThinkRank key => [Yoast key, token Yoast's %%title%%/%%term_title%%
728 // stands for in that context].
729 $map = [
730 'homepage_title' => ['title-home-wpseo', ''],
731 'post_title' => ['title-post', '%post_title%'],
732 'page_title' => ['title-page', '%page_title%'],
733 'category_title' => ['title-tax-category', '%category_title%'],
734 'tag_title' => ['title-tax-post_tag', '%tag_title%'],
735 'search_title' => ['title-search-wpseo', '%search_term%'],
736 'archive_title' => ['title-archive-wpseo', '%archive_title%'],
737 'author_title' => ['title-author-wpseo', '%author_name%'],
738 ];
739
740 $formats = [];
741 foreach ($map as $tr_key => [$yoast_key, $context_token]) {
742 $raw = (string) ($titles[$yoast_key] ?? '');
743 if ($raw === '') {
744 continue;
745 }
746
747 $converted = $this->convert_identity_pattern($raw, $context_token);
748 if ($converted !== '') {
749 $formats[$tr_key] = $converted;
750 }
751 }
752
753 return $formats;
754 }
755
756 /**
757 * Convert a Yoast title template into ThinkRank's Site Identity token
758 * vocabulary, preserving structure.
759 *
760 * Tokens ThinkRank cannot resolve (%%page%%, %%pt_single%%, %%currentyear%%)
761 * are stripped so they never render literally, and a separator left dangling
762 * by that strip is dropped rather than rendering as a leading/trailing dash.
763 *
764 * @param string $template Raw Yoast template
765 * @param string $context_token Token %%title%%/%%term_title%% stands for (may be '')
766 * @return string ThinkRank Site Identity template
767 */
768 private function convert_identity_pattern(string $template, string $context_token): string {
769 if ($template === '' || strpos($template, '%%') === false) {
770 return trim($template);
771 }
772
773 $map = [
774 '%%sitename%%' => '%site_title%',
775 '%%sitedesc%%' => '%site_description%',
776 '%%sep%%' => '%sep%',
777 '%%date%%' => '%date%',
778 '%%searchphrase%%' => '%search_term%',
779 '%%name%%' => '%author_name%',
780 // Yoast's own archive/term variables, which ThinkRank resolves under
781 // the same names.
782 '%%archive_title%%' => '%archive_title%',
783 '%%category%%' => '%category_title%',
784 '%%tag%%' => '%tag_title%',
785 // Rank Math leftovers. Yoast's own Rank Math importer rewrites title
786 // settings with a blind str_replace('%', '%%') and NO token
787 // translation, so a site that came RankMath -> Yoast carries Rank
788 // Math tokens wrapped in Yoast's double-percent syntax — tokens Yoast
789 // itself cannot resolve either. Map them rather than stripping them,
790 // or the format silently loses its search term / term name.
791 '%%search_query%%' => '%search_term%',
792 ];
793 if ($context_token !== '') {
794 $map['%%title%%'] = $context_token;
795 $map['%%term_title%%'] = $context_token;
796 $map['%%term%%'] = $context_token; // Rank Math leftover (see above)
797 }
798 $template = str_replace(array_keys($map), array_values($map), $template);
799
800 // Drop any remaining Yoast token ThinkRank cannot resolve.
801 $template = preg_replace('/%%[a-z0-9_-]+%%/i', '', $template);
802
803 $template = preg_replace('/\s{2,}/', ' ', (string) $template);
804 $template = trim((string) $template);
805 $template = preg_replace('/^(?:%sep%)\s*/', '', $template);
806 $template = preg_replace('/\s*(?:%sep%)$/', '', (string) $template);
807
808 return trim((string) $template);
809 }
810
811 /**
812 * Extract Yoast's author-archive behaviour for ThinkRank's Author Archives
813 * feature (author_archives_enabled / _title / _meta_desc).
814 *
815 * The archive noindex flag travels separately in `data.noindex_archives`.
816 *
817 * @param array $titles `wpseo_titles` option
818 * @return array Author archive settings
819 */
820 private function extract_yoast_author_archives(array $titles): array {
821 return [
822 // Yoast stores the NEGATIVE `disable-author`; ThinkRank stores the
823 // positive `enabled`, so invert. Cast loosely — Yoast has stored this
824 // as both a real boolean and the string 'on'.
825 'enabled' => !filter_var($titles['disable-author'] ?? false, FILTER_VALIDATE_BOOLEAN),
826 'title' => $this->convert_identity_pattern((string) ($titles['title-author-wpseo'] ?? ''), '%author_name%'),
827 'description' => $this->convert_identity_pattern((string) ($titles['metadesc-author-wpseo'] ?? ''), '%author_name%'),
828 ];
829 }
830
831 /**
832 * Extract News/Video sitemap post types from the Yoast News SEO and Video
833 * SEO add-ons, when installed. Both are separate paid plugins, so their
834 * options are usually absent — an empty result means "nothing to migrate".
835 *
836 * @return array News/Video post type lists (absent keys omitted)
837 */
838 private function extract_yoast_publisher_sitemaps(): array {
839 $out = [];
840
841 $news = get_option('wpseo_news', []);
842 $news = is_array($news) ? $news : [];
843 if (is_array($news) && !empty($news['news_sitemap_include_post_types'])) {
844 // Yoast News stores this as [post_type => 'on'] rather than a list.
845 $types = $news['news_sitemap_include_post_types'];
846 $out['news_post_types'] = is_array($types) && $this->is_assoc_toggle_map($types)
847 ? array_keys(array_filter($types))
848 : array_values(array_map('strval', (array) $types));
849 }
850
851 $video = get_option('wpseo_video', []);
852 $video = is_array($video) ? $video : [];
853 if (is_array($video) && !empty($video['videositemap_posttypes'])) {
854 $types = $video['videositemap_posttypes'];
855 $out['video_post_types'] = is_array($types) && $this->is_assoc_toggle_map($types)
856 ? array_keys(array_filter($types))
857 : array_values(array_map('strval', (array) $types));
858 }
859
860 return $out;
861 }
862
863 /**
864 * Whether an array is a [slug => truthy] toggle map rather than a plain list
865 * of slugs. Yoast's add-ons have used both shapes across versions.
866 *
867 * @param array $value Candidate array
868 * @return bool
869 */
870 private function is_assoc_toggle_map(array $value): bool {
871 return array_keys($value) !== range(0, count($value) - 1);
872 }
873
874 private function map_yoast_separator($separator): string {
875 // Foreign data first: a non-string separator setting degrades to '' so
876 // the default fallback applies instead of fatalling (see abstract).
877 $separator = $this->stringify_template_value($separator);
878
879 $map = [
880 'sc-dash' => '-',
881 'sc-ndash' => '–',
882 'sc-mdash' => '—',
883 'sc-colon' => ':',
884 'sc-middot' => '·',
885 'sc-bull' => '•',
886 'sc-star' => '*',
887 'sc-smstar' => '⋆',
888 'sc-pipe' => '|',
889 'sc-tilde' => '~',
890 'sc-laquo' => '«',
891 'sc-raquo' => '»',
892 'sc-lt' => '>',
893 'sc-gt' => '<',
894 ];
895
896 return $map[$separator] ?? ($separator !== '' ? $separator : '-');
897 }
898
899 /**
900 * Parse Yoast's additional focus keywords JSON
901 *
902 * @param mixed $json JSON string from _yoast_wpseo_focuskeywords
903 * @return array Array of additional keyword strings
904 */
905 private function parse_yoast_additional_keywords($json): array {
906 // Foreign data first: non-string meta degrades to '' → no keywords (see abstract).
907 $json = $this->stringify_template_value($json);
908
909 if (empty($json)) {
910 return [];
911 }
912
913 $decoded = json_decode($json, true);
914 if (!is_array($decoded)) {
915 return [];
916 }
917
918 $keywords = [];
919 foreach ($decoded as $item) {
920 if (isset($item['keyword']) && !empty($item['keyword'])) {
921 $keywords[] = $item['keyword'];
922 }
923 }
924
925 return $keywords;
926 }
927
928 /**
929 * Extract post type SEO settings from Yoast titles option
930 *
931 * @param array $titles Yoast wpseo_titles option
932 * @return array Post type settings
933 */
934 private function extract_post_type_settings(array $titles): array {
935 $settings = [];
936 $post_types = get_post_types(['public' => true], 'names');
937
938 // Yoast's link suggestions is a single GLOBAL toggle, not per-post-type
939 // like Rank Math's and ThinkRank's. Only a deliberate "off" carries
940 // information (both plugins default it on), so an off is fanned out to
941 // every public type and an on is left alone.
942 $wpseo = get_option('wpseo', []);
943 $link_suggestions_off = is_array($wpseo)
944 && array_key_exists('enable_link_suggestions', $wpseo)
945 && !$wpseo['enable_link_suggestions'];
946
947 foreach ($post_types as $pt) {
948 $pt_settings = [];
949 if ($link_suggestions_off) {
950 $pt_settings['link_suggestions'] = false;
951 }
952 if (isset($titles["title-{$pt}"])) {
953 $pt_settings['title_template'] = $this->convert_template_pattern($titles["title-{$pt}"]);
954 }
955 if (isset($titles["metadesc-{$pt}"])) {
956 $pt_settings['description_template'] = $this->convert_template_pattern($titles["metadesc-{$pt}"]);
957 }
958 if (isset($titles["noindex-{$pt}"])) {
959 $pt_settings['noindex'] = (bool) $titles["noindex-{$pt}"];
960 }
961 if (!empty($pt_settings)) {
962 $settings[$pt] = $pt_settings;
963 }
964 }
965
966 return $settings;
967 }
968
969 /**
970 * Extract taxonomy SEO settings from Yoast titles option
971 *
972 * @param array $titles Yoast wpseo_titles option
973 * @return array Taxonomy settings
974 */
975 private function extract_taxonomy_settings(array $titles): array {
976 $settings = [];
977 $taxonomies = get_taxonomies(['public' => true], 'names');
978
979 foreach ($taxonomies as $tax) {
980 $tax_settings = [];
981 if (isset($titles["title-tax-{$tax}"])) {
982 $tax_settings['title_template'] = $this->convert_template_pattern($titles["title-tax-{$tax}"]);
983 }
984 if (isset($titles["metadesc-tax-{$tax}"])) {
985 $tax_settings['description_template'] = $this->convert_template_pattern($titles["metadesc-tax-{$tax}"]);
986 }
987 if (isset($titles["noindex-tax-{$tax}"])) {
988 $tax_settings['noindex'] = (bool) $titles["noindex-tax-{$tax}"];
989 }
990 if (!empty($tax_settings)) {
991 $settings[$tax] = $tax_settings;
992 }
993 }
994
995 return $settings;
996 }
997 }
998