| 1 |
<?php |
| 2 |
|
| 3 |
namespace WPDeveloper\BetterDocs\Core; |
| 4 |
|
| 5 |
/** |
| 6 |
* SiteProfiler — Phase 1 of the AI "Generate Sample Docs" feature. |
| 7 |
* |
| 8 |
* Runs entirely inside WordPress (no AI call). Produces a small, deterministic, |
| 9 |
* privacy-safe profile of the site that the hosted proxy turns into tailored |
| 10 |
* docs/FAQs. Detection only targets; the proxy writes. |
| 11 |
* |
| 12 |
* The profile contains only public-facing labels (titles, category names) — no |
| 13 |
* user data, no order data, no PII. |
| 14 |
* |
| 15 |
* @since 4.5.3 |
| 16 |
*/ |
| 17 |
class SiteProfiler { |
| 18 |
/** |
| 19 |
* Transient key for the cached profile (per-locale). |
| 20 |
* @var string |
| 21 |
*/ |
| 22 |
const CACHE_KEY = 'betterdocs_site_profile'; |
| 23 |
|
| 24 |
/** |
| 25 |
* How long to cache the computed profile so re-renders don't recompute. |
| 26 |
* @var int |
| 27 |
*/ |
| 28 |
const CACHE_TTL = HOUR_IN_SECONDS; |
| 29 |
|
| 30 |
/** |
| 31 |
* Transient key for the cached content digest (per-locale). Kept separate from the |
| 32 |
* base profile so the lightweight detect() path never carries the heavier excerpts. |
| 33 |
* @var string |
| 34 |
*/ |
| 35 |
const CONTENT_CACHE_KEY = 'betterdocs_site_content'; |
| 36 |
|
| 37 |
/** |
| 38 |
* Build the full site profile. |
| 39 |
* |
| 40 |
* @param bool $fresh Skip the cache and recompute. |
| 41 |
* @param bool $with_content Also attach the richer `content` digest (real page/post/ |
| 42 |
* product excerpts). Only the AI KB "outline" call needs it; |
| 43 |
* detect() leaves it off to stay light. |
| 44 |
* @return array |
| 45 |
*/ |
| 46 |
public function build( $fresh = false, $with_content = false ) { |
| 47 |
$cache_key = self::CACHE_KEY . '_' . get_locale(); |
| 48 |
|
| 49 |
$profile = null; |
| 50 |
if ( ! $fresh ) { |
| 51 |
$cached = get_transient( $cache_key ); |
| 52 |
if ( is_array( $cached ) ) { |
| 53 |
$profile = $cached; |
| 54 |
} |
| 55 |
} |
| 56 |
|
| 57 |
if ( null === $profile ) { |
| 58 |
$profile = $this->build_base(); |
| 59 |
set_transient( $cache_key, $profile, self::CACHE_TTL ); |
| 60 |
} |
| 61 |
|
| 62 |
if ( $with_content ) { |
| 63 |
// Computed + cached separately so it never bloats the base profile transient. |
| 64 |
$profile['content'] = $this->content_digest( $fresh ); |
| 65 |
} |
| 66 |
|
| 67 |
return $profile; |
| 68 |
} |
| 69 |
|
| 70 |
/** |
| 71 |
* Compute the base (content-free) profile. Split out of build() so the heavier |
| 72 |
* content digest can be attached on demand without polluting the base cache. |
| 73 |
* |
| 74 |
* @return array |
| 75 |
*/ |
| 76 |
private function build_base() { |
| 77 |
$site = [ |
| 78 |
'title' => sanitize_text_field( get_bloginfo( 'name' ) ), |
| 79 |
'tagline' => sanitize_text_field( get_bloginfo( 'description' ) ), |
| 80 |
'url' => esc_url_raw( home_url() ), |
| 81 |
'locale' => get_locale(), |
| 82 |
'theme' => sanitize_text_field( wp_get_theme()->get( 'Name' ) ), |
| 83 |
]; |
| 84 |
|
| 85 |
$type = $this->detect_type(); |
| 86 |
$topics = $this->topic_hints(); |
| 87 |
$niche = $this->detect_niche( $site, $type, $topics ); |
| 88 |
|
| 89 |
$profile = [ |
| 90 |
'site' => $site, |
| 91 |
'type' => $type, |
| 92 |
// Human-friendly niche descriptor for the detection UI and the proxy |
| 93 |
// prompt (e.g. "AI / LLM platform"), inferred from real site content. |
| 94 |
'niche' => $niche['key'], |
| 95 |
'niche_label' => $niche['label'], |
| 96 |
'signals' => $this->collect_signals(), |
| 97 |
'woocommerce' => $this->woo_context(), |
| 98 |
'topics' => $topics, |
| 99 |
]; |
| 100 |
|
| 101 |
/** |
| 102 |
* Filter the computed site profile before it is cached/returned. |
| 103 |
* |
| 104 |
* @param array $profile |
| 105 |
*/ |
| 106 |
return apply_filters( 'betterdocs_site_profile', $profile ); |
| 107 |
} |
| 108 |
|
| 109 |
/** |
| 110 |
* Real, privacy-safe, size-bounded content excerpts that let the AI understand what |
| 111 |
* the site actually does (not just page/nav titles). Only published, public content |
| 112 |
* — already on the public web — is excerpted: no drafts, private, user or order data. |
| 113 |
* |
| 114 |
* Cached separately from the base profile and gated by filters so it can be trimmed |
| 115 |
* or disabled per site. |
| 116 |
* |
| 117 |
* @param bool $fresh Skip the content cache and recompute. |
| 118 |
* @return array |
| 119 |
*/ |
| 120 |
public function content_digest( $fresh = false ) { |
| 121 |
/** |
| 122 |
* Allow a site to opt out of sending real content excerpts to the AI proxy. |
| 123 |
* |
| 124 |
* @param bool $enabled |
| 125 |
*/ |
| 126 |
if ( ! apply_filters( 'betterdocs_site_profile_include_content', true ) ) { |
| 127 |
return []; |
| 128 |
} |
| 129 |
|
| 130 |
$cache_key = self::CONTENT_CACHE_KEY . '_' . get_locale(); |
| 131 |
if ( ! $fresh ) { |
| 132 |
$cached = get_transient( $cache_key ); |
| 133 |
if ( is_array( $cached ) ) { |
| 134 |
return $cached; |
| 135 |
} |
| 136 |
} |
| 137 |
|
| 138 |
$content = [ |
| 139 |
'summary' => $this->home_about_summary(), |
| 140 |
'pages' => $this->page_excerpts(), |
| 141 |
'posts' => $this->post_excerpts(), |
| 142 |
]; |
| 143 |
|
| 144 |
if ( class_exists( 'WooCommerce' ) ) { |
| 145 |
$content['products'] = $this->product_excerpts(); |
| 146 |
} |
| 147 |
|
| 148 |
$content = array_filter( |
| 149 |
$content, |
| 150 |
function ( $value ) { |
| 151 |
return ! ( is_array( $value ) && empty( $value ) ) && '' !== $value; |
| 152 |
} |
| 153 |
); |
| 154 |
|
| 155 |
/** |
| 156 |
* Filter the computed content digest before it is cached/returned. |
| 157 |
* |
| 158 |
* @param array $content |
| 159 |
* @param SiteProfiler $profiler |
| 160 |
*/ |
| 161 |
$content = apply_filters( 'betterdocs_site_profile_content', $content, $this ); |
| 162 |
|
| 163 |
set_transient( $cache_key, $content, self::CACHE_TTL ); |
| 164 |
|
| 165 |
return $content; |
| 166 |
} |
| 167 |
|
| 168 |
/** |
| 169 |
* Short plain-text summary drawn from the front page and an About-style page — the |
| 170 |
* best single signal of "what is this site about". |
| 171 |
* |
| 172 |
* @return string |
| 173 |
*/ |
| 174 |
private function home_about_summary() { |
| 175 |
$parts = []; |
| 176 |
|
| 177 |
$front_id = (int) get_option( 'page_on_front' ); |
| 178 |
if ( 'page' === get_option( 'show_on_front' ) && $front_id > 0 ) { |
| 179 |
$ex = $this->excerpt_of( get_post_field( 'post_content', $front_id ), 300 ); |
| 180 |
if ( '' !== $ex ) { |
| 181 |
$parts[] = $ex; |
| 182 |
} |
| 183 |
} |
| 184 |
|
| 185 |
foreach ( [ 'about', 'about-us', 'company', 'who-we-are' ] as $slug ) { |
| 186 |
$page = get_page_by_path( $slug ); |
| 187 |
if ( $page instanceof \WP_Post && 'publish' === $page->post_status ) { |
| 188 |
$ex = $this->excerpt_of( $page->post_content, 300 ); |
| 189 |
if ( '' !== $ex ) { |
| 190 |
$parts[] = $ex; |
| 191 |
break; |
| 192 |
} |
| 193 |
} |
| 194 |
} |
| 195 |
|
| 196 |
return $this->excerpt_of( implode( ' ', $parts ), 600 ); |
| 197 |
} |
| 198 |
|
| 199 |
/** |
| 200 |
* Title + short excerpt for the first few published pages (About, Pricing, Features…). |
| 201 |
* |
| 202 |
* @return array |
| 203 |
*/ |
| 204 |
private function page_excerpts() { |
| 205 |
// Exclude WooCommerce's utility pages (Shop/Cart/Checkout/My account) and the |
| 206 |
// privacy page: they carry no subject anyone documents or FAQs about, but on a |
| 207 |
// store they have the lowest menu_order and so used to consume the whole (small) |
| 208 |
// budget — starving the real content pages (About, Pricing, Financing, Delivery…). |
| 209 |
$exclude = array_filter( |
| 210 |
[ |
| 211 |
(int) get_option( 'woocommerce_shop_page_id' ), |
| 212 |
(int) get_option( 'woocommerce_cart_page_id' ), |
| 213 |
(int) get_option( 'woocommerce_checkout_page_id' ), |
| 214 |
(int) get_option( 'woocommerce_myaccount_page_id' ), |
| 215 |
(int) get_option( 'wp_page_for_privacy_policy' ), |
| 216 |
] |
| 217 |
); |
| 218 |
|
| 219 |
$pages = get_posts( |
| 220 |
[ |
| 221 |
'post_type' => 'page', |
| 222 |
'posts_per_page' => 15, |
| 223 |
'orderby' => 'menu_order', |
| 224 |
'order' => 'ASC', |
| 225 |
'post_status' => 'publish', |
| 226 |
'post__not_in' => $exclude, |
| 227 |
] |
| 228 |
); |
| 229 |
|
| 230 |
$out = []; |
| 231 |
foreach ( $pages as $page ) { |
| 232 |
$title = sanitize_text_field( get_the_title( $page ) ); |
| 233 |
if ( '' === $title ) { |
| 234 |
continue; |
| 235 |
} |
| 236 |
$out[] = [ |
| 237 |
'title' => $title, |
| 238 |
'excerpt' => $this->excerpt_of( $page->post_content, 150 ), |
| 239 |
]; |
| 240 |
} |
| 241 |
|
| 242 |
return array_values( $out ); |
| 243 |
} |
| 244 |
|
| 245 |
/** |
| 246 |
* Title + short excerpt for recent published blog posts — signals real topics. |
| 247 |
* |
| 248 |
* @return array |
| 249 |
*/ |
| 250 |
private function post_excerpts() { |
| 251 |
$posts = get_posts( |
| 252 |
[ |
| 253 |
'post_type' => 'post', |
| 254 |
'posts_per_page' => 8, |
| 255 |
'orderby' => 'date', |
| 256 |
'order' => 'DESC', |
| 257 |
'post_status' => 'publish', |
| 258 |
] |
| 259 |
); |
| 260 |
|
| 261 |
$out = []; |
| 262 |
foreach ( $posts as $post ) { |
| 263 |
$title = sanitize_text_field( get_the_title( $post ) ); |
| 264 |
if ( '' === $title ) { |
| 265 |
continue; |
| 266 |
} |
| 267 |
$raw = '' !== trim( (string) $post->post_excerpt ) ? $post->post_excerpt : $post->post_content; |
| 268 |
$out[] = [ |
| 269 |
'title' => $title, |
| 270 |
'excerpt' => $this->excerpt_of( $raw, 120 ), |
| 271 |
]; |
| 272 |
} |
| 273 |
|
| 274 |
return array_values( $out ); |
| 275 |
} |
| 276 |
|
| 277 |
/** |
| 278 |
* Title + short description for a few recent WooCommerce products. |
| 279 |
* |
| 280 |
* @return array |
| 281 |
*/ |
| 282 |
private function product_excerpts() { |
| 283 |
$products = get_posts( |
| 284 |
[ |
| 285 |
'post_type' => 'product', |
| 286 |
'posts_per_page' => 8, |
| 287 |
'orderby' => 'date', |
| 288 |
'order' => 'DESC', |
| 289 |
'post_status' => 'publish', |
| 290 |
] |
| 291 |
); |
| 292 |
|
| 293 |
$out = []; |
| 294 |
foreach ( $products as $product ) { |
| 295 |
$title = sanitize_text_field( get_the_title( $product ) ); |
| 296 |
if ( '' === $title ) { |
| 297 |
continue; |
| 298 |
} |
| 299 |
$raw = '' !== trim( (string) $product->post_excerpt ) ? $product->post_excerpt : $product->post_content; |
| 300 |
$out[] = [ |
| 301 |
'title' => $title, |
| 302 |
'excerpt' => $this->excerpt_of( $raw, 120 ), |
| 303 |
]; |
| 304 |
} |
| 305 |
|
| 306 |
return array_values( $out ); |
| 307 |
} |
| 308 |
|
| 309 |
/** |
| 310 |
* Turn raw post content into a clean, bounded, single-line plain-text excerpt. |
| 311 |
* Strips shortcodes + tags (handles page-builder markup) and collapses whitespace. |
| 312 |
* |
| 313 |
* @param string $raw |
| 314 |
* @param int $chars |
| 315 |
* @return string |
| 316 |
*/ |
| 317 |
private function excerpt_of( $raw, $chars ) { |
| 318 |
$text = wp_strip_all_tags( strip_shortcodes( (string) $raw ) ); |
| 319 |
$text = trim( preg_replace( '/\s+/', ' ', $text ) ); |
| 320 |
if ( '' === $text ) { |
| 321 |
return ''; |
| 322 |
} |
| 323 |
if ( mb_strlen( $text ) > $chars ) { |
| 324 |
$text = rtrim( mb_substr( $text, 0, $chars ) ) . '…'; |
| 325 |
} |
| 326 |
return sanitize_text_field( $text ); |
| 327 |
} |
| 328 |
|
| 329 |
/** |
| 330 |
* Primary site type, keyed off active plugins (highest signal). |
| 331 |
* First match wins. |
| 332 |
* |
| 333 |
* @return string |
| 334 |
*/ |
| 335 |
public function detect_type() { |
| 336 |
$active = $this->active_plugins(); |
| 337 |
$has = function ( $needle ) use ( $active ) { |
| 338 |
foreach ( $active as $plugin ) { |
| 339 |
if ( stripos( $plugin, $needle ) !== false ) { |
| 340 |
return true; |
| 341 |
} |
| 342 |
} |
| 343 |
return false; |
| 344 |
}; |
| 345 |
|
| 346 |
if ( $has( 'woocommerce' ) ) { |
| 347 |
return 'ecommerce'; |
| 348 |
} |
| 349 |
if ( $has( 'learndash' ) || $has( 'tutor' ) || $has( 'lifterlms' ) || $has( 'sensei' ) ) { |
| 350 |
return 'course_lms'; |
| 351 |
} |
| 352 |
if ( $has( 'easy-digital-downloads' ) ) { |
| 353 |
return 'digital_downloads'; |
| 354 |
} |
| 355 |
if ( $has( 'paid-memberships-pro' ) || $has( 'memberpress' ) || $has( 'restrict-content' ) ) { |
| 356 |
return 'membership'; |
| 357 |
} |
| 358 |
if ( $has( 'bbpress' ) || $has( 'buddypress' ) ) { |
| 359 |
return 'community'; |
| 360 |
} |
| 361 |
if ( $has( 'elementor' ) || $has( 'beaver' ) || $has( 'divi' ) ) { |
| 362 |
return 'business_site'; |
| 363 |
} |
| 364 |
|
| 365 |
return 'general'; |
| 366 |
} |
| 367 |
|
| 368 |
/** |
| 369 |
* Human-friendly site descriptor for the detection UI ("We detected …") and |
| 370 |
* to ground the proxy prompt. Prefers a strong content-based niche signal |
| 371 |
* (title/tagline/pages/categories/nav/existing docs); falls back to the |
| 372 |
* plugin-detected type, then a generic label. No AI — keyword scoring only. |
| 373 |
* |
| 374 |
* @param array $site |
| 375 |
* @param string $type |
| 376 |
* @param array $topics |
| 377 |
* @return array { key:string, label:string } |
| 378 |
*/ |
| 379 |
public function detect_niche( array $site, $type, array $topics ) { |
| 380 |
$parts = array_merge( |
| 381 |
[ isset( $site['title'] ) ? $site['title'] : '', isset( $site['tagline'] ) ? $site['tagline'] : '' ], |
| 382 |
isset( $topics['page_titles'] ) ? (array) $topics['page_titles'] : [], |
| 383 |
isset( $topics['post_categories'] ) ? (array) $topics['post_categories'] : [], |
| 384 |
isset( $topics['nav_items'] ) ? (array) $topics['nav_items'] : [], |
| 385 |
isset( $topics['doc_categories'] ) ? (array) $topics['doc_categories'] : [], |
| 386 |
isset( $topics['doc_titles'] ) ? (array) $topics['doc_titles'] : [] |
| 387 |
); |
| 388 |
|
| 389 |
// Pad + collapse whitespace so " ai " etc. match as whole words anywhere. |
| 390 |
$text = ' ' . preg_replace( '/\s+/', ' ', strtolower( implode( ' ', $parts ) ) ) . ' '; |
| 391 |
|
| 392 |
// Most-specific first. Leading spaces on short tokens avoid false positives |
| 393 |
// (e.g. " api" never matches "therapist"). |
| 394 |
$niches = [ |
| 395 |
'ai_llm' => [ |
| 396 |
'label' => 'AI / LLM platform', |
| 397 |
'keywords' => [ ' llm', ' gpt', ' ai ', 'a.i.', 'artificial intelligence', 'machine learning', 'language model', 'large language', 'prompt', 'openai', 'anthropic', 'claude', 'chatbot', 'embedding', 'fine-tun', 'inference', 'generative', 'neural' ], |
| 398 |
], |
| 399 |
'developer' => [ |
| 400 |
'label' => 'developer platform', |
| 401 |
'keywords' => [ ' api', ' sdk', 'developer', 'endpoint', 'webhook', ' cli ', 'integration', 'documentation', 'rate limit', 'oauth', 'deploy' ], |
| 402 |
], |
| 403 |
'ecommerce' => [ |
| 404 |
'label' => 'online store', |
| 405 |
'keywords' => [ ' shop', ' store', ' cart', 'checkout', 'product', 'shipping', 'returns', ' order', 'fashion', 'clothing', 'apparel' ], |
| 406 |
], |
| 407 |
'education' => [ |
| 408 |
'label' => 'online learning site', |
| 409 |
'keywords' => [ ' course', 'lesson', ' learn', 'student', 'curriculum', 'enroll', 'academy', 'tutorial', ' class', 'teacher' ], |
| 410 |
], |
| 411 |
'agency' => [ |
| 412 |
'label' => 'agency or services site', |
| 413 |
'keywords' => [ 'agency', 'clients', 'marketing', ' seo', 'branding', 'portfolio', 'services' ], |
| 414 |
], |
| 415 |
'health' => [ |
| 416 |
'label' => 'health & wellness site', |
| 417 |
'keywords' => [ 'health', 'clinic', 'medical', 'wellness', 'therapy', 'patient', 'fitness', 'nutrition' ], |
| 418 |
], |
| 419 |
'finance' => [ |
| 420 |
'label' => 'finance site', |
| 421 |
'keywords' => [ 'finance', ' bank', 'invest', 'crypto', 'trading', ' loan', 'insurance' ], |
| 422 |
], |
| 423 |
'food' => [ |
| 424 |
'label' => 'food & restaurant site', |
| 425 |
'keywords' => [ 'restaurant', ' menu', 'recipe', ' food', ' cafe', 'coffee', 'cuisine', ' dish' ], |
| 426 |
], |
| 427 |
]; |
| 428 |
|
| 429 |
$scores = []; |
| 430 |
foreach ( $niches as $key => $def ) { |
| 431 |
$score = 0; |
| 432 |
foreach ( $def['keywords'] as $kw ) { |
| 433 |
if ( strpos( $text, $kw ) !== false ) { |
| 434 |
$score++; |
| 435 |
} |
| 436 |
} |
| 437 |
$scores[ $key ] = $score; |
| 438 |
} |
| 439 |
|
| 440 |
// AI/LLM is the most notable/specific niche — let it win whenever there are |
| 441 |
// a couple of independent AI signals, even if "developer" also scores high. |
| 442 |
if ( $scores['ai_llm'] >= 2 ) { |
| 443 |
return [ 'key' => 'ai_llm', 'label' => $niches['ai_llm']['label'] ]; |
| 444 |
} |
| 445 |
|
| 446 |
arsort( $scores ); |
| 447 |
$best = key( $scores ); |
| 448 |
$top = current( $scores ); |
| 449 |
if ( $best && $top >= 2 ) { |
| 450 |
return [ 'key' => $best, 'label' => $niches[ $best ]['label'] ]; |
| 451 |
} |
| 452 |
|
| 453 |
// Fall back to the plugin-detected type, then a generic label. |
| 454 |
$type_labels = [ |
| 455 |
'ecommerce' => 'online store', |
| 456 |
'course_lms' => 'course / LMS site', |
| 457 |
'digital_downloads' => 'digital downloads store', |
| 458 |
'membership' => 'membership site', |
| 459 |
'community' => 'community site', |
| 460 |
'business_site' => 'business site', |
| 461 |
]; |
| 462 |
if ( isset( $type_labels[ $type ] ) ) { |
| 463 |
return [ 'key' => $type, 'label' => $type_labels[ $type ] ]; |
| 464 |
} |
| 465 |
|
| 466 |
return [ 'key' => 'general', 'label' => 'website' ]; |
| 467 |
} |
| 468 |
|
| 469 |
/** |
| 470 |
* Raw signals — give the AI evidence, not just a label. |
| 471 |
* |
| 472 |
* @return array |
| 473 |
*/ |
| 474 |
public function collect_signals() { |
| 475 |
$active = $this->active_plugins(); |
| 476 |
$known = [ |
| 477 |
'woocommerce' => 'WooCommerce', |
| 478 |
'learndash' => 'LearnDash', |
| 479 |
'tutor' => 'Tutor LMS', |
| 480 |
'lifterlms' => 'LifterLMS', |
| 481 |
'easy-digital' => 'Easy Digital Downloads', |
| 482 |
'memberpress' => 'MemberPress', |
| 483 |
'bbpress' => 'bbPress', |
| 484 |
'buddypress' => 'BuddyPress', |
| 485 |
'elementor' => 'Elementor', |
| 486 |
'wpforms' => 'WPForms', |
| 487 |
'yoast' => 'Yoast SEO', |
| 488 |
]; |
| 489 |
|
| 490 |
$detected = []; |
| 491 |
foreach ( $known as $needle => $label ) { |
| 492 |
foreach ( $active as $plugin ) { |
| 493 |
if ( stripos( $plugin, $needle ) !== false ) { |
| 494 |
$detected[] = $label; |
| 495 |
break; |
| 496 |
} |
| 497 |
} |
| 498 |
} |
| 499 |
|
| 500 |
return [ |
| 501 |
'active_plugins' => array_values( array_unique( $detected ) ), |
| 502 |
'theme' => sanitize_text_field( wp_get_theme()->get( 'Name' ) ), |
| 503 |
]; |
| 504 |
} |
| 505 |
|
| 506 |
/** |
| 507 |
* WooCommerce context — only if Woo is active. |
| 508 |
* |
| 509 |
* @return array|null |
| 510 |
*/ |
| 511 |
public function woo_context() { |
| 512 |
if ( ! class_exists( 'WooCommerce' ) ) { |
| 513 |
return null; |
| 514 |
} |
| 515 |
|
| 516 |
$cats = get_terms( |
| 517 |
[ |
| 518 |
'taxonomy' => 'product_cat', |
| 519 |
'orderby' => 'count', |
| 520 |
'order' => 'DESC', |
| 521 |
'number' => 8, |
| 522 |
'hide_empty' => true, |
| 523 |
] |
| 524 |
); |
| 525 |
$cat_names = is_wp_error( $cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $cats, 'name' ) ); |
| 526 |
|
| 527 |
$products = get_posts( |
| 528 |
[ |
| 529 |
'post_type' => 'product', |
| 530 |
'posts_per_page' => 8, |
| 531 |
'orderby' => 'date', |
| 532 |
'order' => 'DESC', |
| 533 |
'fields' => 'ids', |
| 534 |
] |
| 535 |
); |
| 536 |
$product_names = array_map( |
| 537 |
function ( $id ) { |
| 538 |
return sanitize_text_field( get_the_title( $id ) ); |
| 539 |
}, |
| 540 |
$products |
| 541 |
); |
| 542 |
|
| 543 |
$context = [ |
| 544 |
'product_categories' => array_values( $cat_names ), |
| 545 |
'sample_products' => array_values( array_filter( $product_names ) ), |
| 546 |
'currency' => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '', |
| 547 |
'currency_symbol' => function_exists( 'get_woocommerce_currency_symbol' ) ? html_entity_decode( get_woocommerce_currency_symbol() ) : '', |
| 548 |
]; |
| 549 |
|
| 550 |
// Store config used to ground store/product FAQs in real settings. Each |
| 551 |
// value is read defensively — anything missing simply drops out so the |
| 552 |
// FAQ copy can fall back to a generic-but-accurate answer. No PII. |
| 553 |
$context['store_location'] = $this->store_location(); |
| 554 |
$context['payment_methods'] = $this->payment_methods(); |
| 555 |
$context['shipping_regions'] = $this->shipping_regions(); |
| 556 |
$context['tax_enabled'] = function_exists( 'wc_tax_enabled' ) ? (bool) wc_tax_enabled() : false; |
| 557 |
$context['prices_include_tax'] = 'yes' === get_option( 'woocommerce_prices_include_tax' ); |
| 558 |
|
| 559 |
$terms_page = (int) get_option( 'woocommerce_terms_and_conditions_page_id', 0 ); |
| 560 |
if ( $terms_page > 0 ) { |
| 561 |
$context['terms_page_url'] = esc_url_raw( (string) get_permalink( $terms_page ) ); |
| 562 |
} |
| 563 |
|
| 564 |
$privacy_page = (int) get_option( 'wp_page_for_privacy_policy', 0 ); |
| 565 |
if ( $privacy_page > 0 ) { |
| 566 |
$context['privacy_page_url'] = esc_url_raw( (string) get_permalink( $privacy_page ) ); |
| 567 |
} |
| 568 |
|
| 569 |
// Return window only exists when a returns extension stores it; omit otherwise. |
| 570 |
$return_window = get_option( 'woocommerce_return_requests_window', '' ); |
| 571 |
if ( '' !== $return_window && null !== $return_window ) { |
| 572 |
$context['return_window'] = (int) $return_window; |
| 573 |
} |
| 574 |
|
| 575 |
// Key WooCommerce pages — so store FAQ answers can link the real account / |
| 576 |
// checkout flow instead of describing it generically. |
| 577 |
$this->add_wc_page( $context, 'account_page_url', 'myaccount' ); |
| 578 |
$this->add_wc_page( $context, 'checkout_page_url', 'checkout' ); |
| 579 |
$this->add_wc_page( $context, 'shop_page_url', 'shop' ); |
| 580 |
|
| 581 |
// Refund/returns policy page (WooCommerce has no canonical option — resolved |
| 582 |
// heuristically), used to link the real policy in returns/refunds answers. |
| 583 |
$refund = $this->refund_policy(); |
| 584 |
if ( ! empty( $refund['url'] ) ) { |
| 585 |
$context['refund_policy_url'] = $refund['url']; |
| 586 |
if ( ! empty( $refund['excerpt'] ) ) { |
| 587 |
$context['refund_policy_excerpt'] = $refund['excerpt']; |
| 588 |
} |
| 589 |
} |
| 590 |
|
| 591 |
// Free-shipping threshold, when a Free Shipping method defines a minimum. |
| 592 |
$free_min = $this->free_shipping_min(); |
| 593 |
if ( null !== $free_min ) { |
| 594 |
$context['free_shipping_min'] = $free_min; |
| 595 |
} |
| 596 |
|
| 597 |
return array_filter( |
| 598 |
$context, |
| 599 |
function ( $value ) { |
| 600 |
return ! ( is_array( $value ) && empty( $value ) ) && '' !== $value; |
| 601 |
} |
| 602 |
); |
| 603 |
} |
| 604 |
|
| 605 |
/** |
| 606 |
* Store base location label (city/region, country) for shipping/payment FAQs. |
| 607 |
* |
| 608 |
* @return string |
| 609 |
*/ |
| 610 |
private function store_location() { |
| 611 |
if ( ! function_exists( 'wc_get_base_location' ) ) { |
| 612 |
return ''; |
| 613 |
} |
| 614 |
|
| 615 |
$base = wc_get_base_location(); |
| 616 |
$country = isset( $base['country'] ) ? (string) $base['country'] : ''; |
| 617 |
$city = (string) get_option( 'woocommerce_store_city', '' ); |
| 618 |
|
| 619 |
if ( function_exists( 'WC' ) && WC()->countries && $country ) { |
| 620 |
$countries = WC()->countries->get_countries(); |
| 621 |
$country = isset( $countries[ $country ] ) ? $countries[ $country ] : $country; |
| 622 |
} |
| 623 |
|
| 624 |
$parts = array_filter( [ sanitize_text_field( $city ), sanitize_text_field( $country ) ] ); |
| 625 |
|
| 626 |
return implode( ', ', $parts ); |
| 627 |
} |
| 628 |
|
| 629 |
/** |
| 630 |
* Titles of the enabled payment gateways (e.g. "PayPal", "Credit/Debit Card"). |
| 631 |
* |
| 632 |
* @return array |
| 633 |
*/ |
| 634 |
private function payment_methods() { |
| 635 |
if ( ! function_exists( 'WC' ) || ! WC()->payment_gateways ) { |
| 636 |
return []; |
| 637 |
} |
| 638 |
|
| 639 |
$gateways = WC()->payment_gateways->get_available_payment_gateways(); |
| 640 |
$titles = []; |
| 641 |
foreach ( (array) $gateways as $gateway ) { |
| 642 |
$title = isset( $gateway->title ) ? wp_strip_all_tags( (string) $gateway->title ) : ''; |
| 643 |
if ( '' !== trim( $title ) ) { |
| 644 |
$titles[] = sanitize_text_field( $title ); |
| 645 |
} |
| 646 |
} |
| 647 |
|
| 648 |
return array_values( array_unique( $titles ) ); |
| 649 |
} |
| 650 |
|
| 651 |
/** |
| 652 |
* Shipping zone names (region labels the store ships to). |
| 653 |
* |
| 654 |
* @return array |
| 655 |
*/ |
| 656 |
private function shipping_regions() { |
| 657 |
if ( ! class_exists( '\WC_Shipping_Zones' ) ) { |
| 658 |
return []; |
| 659 |
} |
| 660 |
|
| 661 |
$zones = \WC_Shipping_Zones::get_zones(); |
| 662 |
$labels = []; |
| 663 |
foreach ( (array) $zones as $zone ) { |
| 664 |
$name = isset( $zone['zone_name'] ) ? sanitize_text_field( $zone['zone_name'] ) : ''; |
| 665 |
if ( '' !== trim( $name ) ) { |
| 666 |
$labels[] = $name; |
| 667 |
} |
| 668 |
} |
| 669 |
|
| 670 |
return array_values( array_unique( $labels ) ); |
| 671 |
} |
| 672 |
|
| 673 |
/** |
| 674 |
* Add a WooCommerce page's permalink to the context when the page exists. |
| 675 |
* |
| 676 |
* @param array $context Context array (by reference). |
| 677 |
* @param string $key Context key to set. |
| 678 |
* @param string $wc_page WooCommerce page id key (myaccount|checkout|shop|cart). |
| 679 |
* @return void |
| 680 |
*/ |
| 681 |
private function add_wc_page( array &$context, $key, $wc_page ) { |
| 682 |
if ( ! function_exists( 'wc_get_page_id' ) ) { |
| 683 |
return; |
| 684 |
} |
| 685 |
$page_id = (int) wc_get_page_id( $wc_page ); |
| 686 |
if ( $page_id > 0 ) { |
| 687 |
$url = get_permalink( $page_id ); |
| 688 |
if ( $url ) { |
| 689 |
$context[ $key ] = esc_url_raw( (string) $url ); |
| 690 |
} |
| 691 |
} |
| 692 |
} |
| 693 |
|
| 694 |
/** |
| 695 |
* Resolve the store's Refund/Returns policy page. WooCommerce has no canonical |
| 696 |
* option for it, so match common slugs; returns the URL + a short excerpt, or an |
| 697 |
* empty array when none is found (callers fall back to the terms page). |
| 698 |
* |
| 699 |
* @return array { url?: string, excerpt?: string } |
| 700 |
*/ |
| 701 |
private function refund_policy() { |
| 702 |
$slugs = [ 'refund_returns', 'refund-and-returns-policy', 'refund-policy', 'returns', 'return-policy', 'returns-policy' ]; |
| 703 |
$page = null; |
| 704 |
foreach ( $slugs as $slug ) { |
| 705 |
$found = get_page_by_path( $slug ); |
| 706 |
if ( $found instanceof \WP_Post && 'publish' === $found->post_status ) { |
| 707 |
$page = $found; |
| 708 |
break; |
| 709 |
} |
| 710 |
} |
| 711 |
|
| 712 |
if ( ! $page instanceof \WP_Post ) { |
| 713 |
return []; |
| 714 |
} |
| 715 |
|
| 716 |
return [ |
| 717 |
'url' => esc_url_raw( (string) get_permalink( $page->ID ) ), |
| 718 |
'excerpt' => sanitize_text_field( wp_trim_words( wp_strip_all_tags( (string) $page->post_content ), 40, '…' ) ), |
| 719 |
]; |
| 720 |
} |
| 721 |
|
| 722 |
/** |
| 723 |
* The lowest Free Shipping minimum-order amount configured across shipping zones, |
| 724 |
* or null when no Free Shipping method defines a positive threshold. |
| 725 |
* |
| 726 |
* @return float|null |
| 727 |
*/ |
| 728 |
private function free_shipping_min() { |
| 729 |
if ( ! class_exists( '\WC_Shipping_Zones' ) ) { |
| 730 |
return null; |
| 731 |
} |
| 732 |
|
| 733 |
// All real zones plus the catch-all "Rest of the World" zone (id 0). |
| 734 |
$zone_ids = [ 0 ]; |
| 735 |
foreach ( (array) \WC_Shipping_Zones::get_zones() as $zone ) { |
| 736 |
if ( isset( $zone['id'] ) ) { |
| 737 |
$zone_ids[] = (int) $zone['id']; |
| 738 |
} |
| 739 |
} |
| 740 |
|
| 741 |
$min = null; |
| 742 |
foreach ( array_unique( $zone_ids ) as $zone_id ) { |
| 743 |
$zone = \WC_Shipping_Zones::get_zone( $zone_id ); |
| 744 |
if ( ! $zone ) { |
| 745 |
continue; |
| 746 |
} |
| 747 |
foreach ( (array) $zone->get_shipping_methods( true ) as $method ) { |
| 748 |
if ( ! isset( $method->id ) || 'free_shipping' !== $method->id ) { |
| 749 |
continue; |
| 750 |
} |
| 751 |
$amount = method_exists( $method, 'get_option' ) ? $method->get_option( 'min_amount' ) : ( isset( $method->min_amount ) ? $method->min_amount : '' ); |
| 752 |
$amount = is_numeric( $amount ) ? (float) $amount : 0; |
| 753 |
if ( $amount > 0 && ( null === $min || $amount < $min ) ) { |
| 754 |
$min = $amount; |
| 755 |
} |
| 756 |
} |
| 757 |
} |
| 758 |
|
| 759 |
return $min; |
| 760 |
} |
| 761 |
|
| 762 |
/** |
| 763 |
* Topic hints — primary nav menu + key pages + top post categories. |
| 764 |
* |
| 765 |
* @return array |
| 766 |
*/ |
| 767 |
public function topic_hints() { |
| 768 |
// Primary nav menu item labels. |
| 769 |
$nav = []; |
| 770 |
$locations = get_nav_menu_locations(); |
| 771 |
if ( ! empty( $locations ) ) { |
| 772 |
$menu_id = reset( $locations ); |
| 773 |
$items = wp_get_nav_menu_items( $menu_id ); |
| 774 |
if ( $items ) { |
| 775 |
$nav = array_slice( |
| 776 |
array_map( |
| 777 |
function ( $item ) { |
| 778 |
return sanitize_text_field( $item->title ); |
| 779 |
}, |
| 780 |
$items |
| 781 |
), |
| 782 |
0, |
| 783 |
12 |
| 784 |
); |
| 785 |
} |
| 786 |
} |
| 787 |
|
| 788 |
// Key page titles (About, Pricing, Services, Contact, FAQ…). |
| 789 |
$pages = get_posts( |
| 790 |
[ |
| 791 |
'post_type' => 'page', |
| 792 |
'posts_per_page' => 10, |
| 793 |
'orderby' => 'menu_order', |
| 794 |
'order' => 'ASC', |
| 795 |
'fields' => 'ids', |
| 796 |
] |
| 797 |
); |
| 798 |
$page_titles = array_map( |
| 799 |
function ( $id ) { |
| 800 |
return sanitize_text_field( get_the_title( $id ) ); |
| 801 |
}, |
| 802 |
$pages |
| 803 |
); |
| 804 |
|
| 805 |
// Top post categories for blog topic hints. |
| 806 |
$post_cats = get_terms( |
| 807 |
[ |
| 808 |
'taxonomy' => 'category', |
| 809 |
'orderby' => 'count', |
| 810 |
'order' => 'DESC', |
| 811 |
'number' => 6, |
| 812 |
'hide_empty' => true, |
| 813 |
] |
| 814 |
); |
| 815 |
$post_cat_names = is_wp_error( $post_cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $post_cats, 'name' ) ); |
| 816 |
|
| 817 |
// Existing documentation — lets the proxy build content that extends what |
| 818 |
// the site already covers. In particular, FAQ generation derives questions |
| 819 |
// from these doc topics ("detect FAQs from the docs too"). |
| 820 |
$docs = get_posts( |
| 821 |
[ |
| 822 |
'post_type' => 'docs', |
| 823 |
'posts_per_page' => 15, |
| 824 |
'orderby' => 'date', |
| 825 |
'order' => 'DESC', |
| 826 |
'post_status' => 'publish', |
| 827 |
'fields' => 'ids', |
| 828 |
] |
| 829 |
); |
| 830 |
$doc_titles = array_map( |
| 831 |
function ( $id ) { |
| 832 |
return sanitize_text_field( get_the_title( $id ) ); |
| 833 |
}, |
| 834 |
$docs |
| 835 |
); |
| 836 |
|
| 837 |
$doc_cats = get_terms( |
| 838 |
[ |
| 839 |
'taxonomy' => 'doc_category', |
| 840 |
'orderby' => 'count', |
| 841 |
'order' => 'DESC', |
| 842 |
'number' => 6, |
| 843 |
'hide_empty' => true, |
| 844 |
] |
| 845 |
); |
| 846 |
$doc_cat_names = is_wp_error( $doc_cats ) ? [] : array_map( 'sanitize_text_field', wp_list_pluck( $doc_cats, 'name' ) ); |
| 847 |
|
| 848 |
return [ |
| 849 |
'nav_items' => array_values( array_filter( $nav ) ), |
| 850 |
'page_titles' => array_values( array_filter( $page_titles ) ), |
| 851 |
'post_categories' => array_values( $post_cat_names ), |
| 852 |
'doc_titles' => array_values( array_filter( $doc_titles ) ), |
| 853 |
'doc_categories' => array_values( $doc_cat_names ), |
| 854 |
]; |
| 855 |
} |
| 856 |
|
| 857 |
/** |
| 858 |
* Clear the cached profile (call after big content/plugin changes). |
| 859 |
* |
| 860 |
* @return void |
| 861 |
*/ |
| 862 |
public function flush() { |
| 863 |
delete_transient( self::CACHE_KEY . '_' . get_locale() ); |
| 864 |
delete_transient( self::CONTENT_CACHE_KEY . '_' . get_locale() ); |
| 865 |
} |
| 866 |
|
| 867 |
/** |
| 868 |
* Active plugins on this site (network-active included). |
| 869 |
* |
| 870 |
* @return array |
| 871 |
*/ |
| 872 |
private function active_plugins() { |
| 873 |
$active = (array) get_option( 'active_plugins', [] ); |
| 874 |
|
| 875 |
if ( is_multisite() ) { |
| 876 |
$network = (array) get_site_option( 'active_sitewide_plugins', [] ); |
| 877 |
$active = array_merge( $active, array_keys( $network ) ); |
| 878 |
} |
| 879 |
|
| 880 |
return $active; |
| 881 |
} |
| 882 |
} |
| 883 |
|