PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Core / SiteProfiler.php

SiteProfiler.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.0, at includes/Core/SiteProfiler.php

883 lines 25.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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