PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 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 All 200 releases
← All changes | includes/Core/SiteProfiler.php +254 -6 4.6.14.9.2 View file →
@@ -27,23 +27,54 @@
27 27 */
28 28 const CACHE_TTL = HOUR_IN_SECONDS;
29 29
30 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 + /**
31 38 * Build the full site profile.
32 39 *
33 - * @param bool $fresh Skip the cache and recompute.
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.
34 44 * @return array
35 45 */
36 - public function build( $fresh = false ) {
46 + public function build( $fresh = false, $with_content = false ) {
37 47 $cache_key = self::CACHE_KEY . '_' . get_locale();
38 48
49 + $profile = null;
39 50 if ( ! $fresh ) {
40 51 $cached = get_transient( $cache_key );
41 52 if ( is_array( $cached ) ) {
42 - return $cached;
53 + $profile = $cached;
43 54 }
44 55 }
45 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() {
46 77 $site = [
47 78 'title' => sanitize_text_field( get_bloginfo( 'name' ) ),
48 79 'tagline' => sanitize_text_field( get_bloginfo( 'description' ) ),
49 80 'url' => esc_url_raw( home_url() ),
@@ -71,16 +102,232 @@
71 102 * Filter the computed site profile before it is cached/returned.
72 103 *
73 104 * @param array $profile
74 105 */
75 - $profile = apply_filters( 'betterdocs_site_profile', $profile );
106 + return apply_filters( 'betterdocs_site_profile', $profile );
107 + }
76 108
77 - set_transient( $cache_key, $profile, self::CACHE_TTL );
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 + }
78 129
79 - return $profile;
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;
80 166 }
81 167
82 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 + /**
83 330 * Primary site type, keyed off active plugins (highest signal).
84 331 * First match wins.
85 332 *
86 333 * @return string
@@ -613,8 +860,9 @@
613 860 * @return void
614 861 */
615 862 public function flush() {
616 863 delete_transient( self::CACHE_KEY . '_' . get_locale() );
864 + delete_transient( self::CONTENT_CACHE_KEY . '_' . get_locale() );
617 865 }
618 866
619 867 /**
620 868 * Active plugins on this site (network-active included).