PluginProbe
BotWriter – AI Writer & SEO Content Generator / trunk
BotWriter – AI Writer & SEO Content Generator vtrunk
3.4.10 3.4.9 3.4.8 3.4.7 3.4.6 3.4.4 3.4.2 3.4.1 3.4.0 3.3.9 3.3.8 3.3.7 3.3.6 3.3.5 3.3.4 3.3.3 3.3.2 3.3.1 3.3.0 3.2.8 trunk 1.3.0 1.3.1 1.3.2 1.3.3 All 51 releases
botwriter / includes / rewriter.php

rewriter.php in BotWriter – AI Writer & SEO Content Generator trunk, at includes/rewriter.php

490 lines 20.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit;
4 }
5
6 /**
7 * Content Rewriter Page Handler
8 * Renders the Content Rewriter wizard UI.
9 */
10 function botwriter_rewriter_page_handler() {
11 if (!current_user_can('manage_options')) {
12 return;
13 }
14
15 $dir_images = plugin_dir_url(dirname(__FILE__)) . '/assets/images/';
16 ?>
17
18 <div class="wrap">
19 <h1><?php esc_html_e('Content Rewriter', 'botwriter'); ?></h1>
20 <p><?php esc_html_e('Paste URLs of articles to fetch, extract and rewrite them with AI.', 'botwriter'); ?></p>
21
22 <!-- Step 1: URL Input -->
23 <div id="rewriter_step1" class="super-ia bw-flex-col">
24 <div class="bw-flex-row-center bw-mb-15">
25 <div class="super-ia-image bw-img-shrink">
26 <img src="<?php echo esc_url($dir_images . 'ai_cerebro.png'); ?>" alt="<?php echo esc_attr__('AI', 'botwriter'); ?>" class="bw-hue-rotate-200 bw-img-80">
27 </div>
28 <div>
29 <h2 class="super-title"><?php esc_html_e('Step 1: Paste URLs', 'botwriter'); ?></h2>
30 <p class="super-text"><?php esc_html_e('Enter the URLs of the articles you want to rewrite (one per line). The system will fetch and extract the content.', 'botwriter'); ?></p>
31 </div>
32 </div>
33
34 <div class="col-md-6">
35 <label for="rewriter_urls"><?php esc_html_e('Article URLs (one per line):', 'botwriter'); ?></label>
36 <textarea id="rewriter_urls" name="rewriter_urls" rows="8" cols="60" placeholder="https://example.com/article-1&#10;https://example.com/article-2&#10;https://example.com/article-3"></textarea>
37 </div>
38 <br>
39 <div>
40 <button id="rewriter_fetch_btn" class="button-primary"><?php esc_html_e('Fetch Content', 'botwriter'); ?></button>
41 </div>
42 <div id="rewriter_fetch_status" class="bw-mt-10"></div>
43 </div>
44
45 <!-- Step 2: Review Extracted Content -->
46 <div id="rewriter_step2" style="display: none;" class="bw-mt-20">
47 <div class="super-ia bw-flex-col">
48 <h2 class="super-title"><?php esc_html_e('Step 2: Review Extracted Content', 'botwriter'); ?></h2>
49 <p class="super-text"><?php esc_html_e('Review the extracted content from each URL. You can edit, remove or manually paste content for failed extractions.', 'botwriter'); ?></p>
50 <div id="rewriter_articles_list"></div>
51 <br>
52 <div>
53 <button id="rewriter_add_manual_btn" class="button" type="button"><?php esc_html_e('+ Add Article Manually', 'botwriter'); ?></button>
54 </div>
55 </div>
56 </div>
57
58 <!-- Step 3: Rewrite Options + Create Task -->
59 <div id="rewriter_step3" style="display: none;" class="bw-mt-20">
60 <div class="super-ia bw-flex-col">
61 <h2 class="super-title"><?php esc_html_e('Step 3: Rewrite Settings', 'botwriter'); ?></h2>
62 <p class="super-text"><?php esc_html_e('Configure how the AI should rewrite the articles.', 'botwriter'); ?></p>
63
64 <div class="col-md-6">
65 <label for="rewriter_prompt"><?php esc_html_e('Rewrite Instructions:', 'botwriter'); ?></label>
66 <textarea id="rewriter_prompt" name="rewriter_prompt" rows="4" cols="60" placeholder="<?php esc_attr_e('e.g. Rewrite in a friendly conversational tone. Add practical examples. Make it 30% longer with more detail.', 'botwriter'); ?>"></textarea>
67 <p class="form-text"><?php esc_html_e('Global instructions for rewriting all articles. This will be sent to the AI along with each original article.', 'botwriter'); ?></p>
68 </div>
69 <br>
70
71 <!-- Task Properties Grid -->
72 <div class="bw-rewriter-props-extra">
73 <div class="col-md-6">
74 <label for="rewriter_category" class="form-label"><?php esc_html_e('Category:', 'botwriter'); ?></label>
75 <select id="rewriter_category" name="rewriter_category" class="form-select">
76 <option value="0"><?php esc_html_e('— Default —', 'botwriter'); ?></option>
77 <?php
78 $categories = get_categories(array(
79 'orderby' => 'name',
80 'order' => 'ASC',
81 'hide_empty' => false
82 ));
83 foreach ($categories as $category) {
84 echo '<option value="' . esc_attr($category->term_id) . '">' . esc_html($category->name) . '</option>';
85 }
86 ?>
87 </select>
88 <p class="form-text"><?php esc_html_e('Select the category where the rewritten posts will be published.', 'botwriter'); ?></p>
89 </div>
90 </div>
91 <br>
92
93 <!-- Reuse Super Task Properties form -->
94 <?php
95 $rewriter_default_item = array(
96 /* translators: %s: current date and time */
97 'task_name' => sprintf(__('Rewriter Task %s', 'botwriter'), wp_date('M j, Y H:i')),
98 'writer' => 'ai_cerebro',
99 'narration' => 'Descriptive',
100 'custom_style' => '',
101 'post_language' => substr(get_locale(), 0, 2),
102 'post_length' => '800',
103 'custom_post_length' => '',
104 'post_status' => 'draft',
105 'days' => 'Monday,Tuesday,Wednesday,Thursday,Friday,Saturday,Sunday',
106 'times_per_day' => 1,
107 'author_selection' => strval(get_current_user_id()),
108 'disable_ai_images' => 0,
109 'template_id' => null,
110 );
111 botwriter_super_form_meta_box_handler($rewriter_default_item);
112 ?>
113 <!-- End Super Task Properties -->
114 <br>
115
116 <div>
117 <button id="rewriter_create_btn" class="button-primary"><?php esc_html_e('Create Rewrite Task', 'botwriter'); ?></button>
118 </div>
119 <div id="rewriter_create_status" class="bw-mt-10"></div>
120 </div>
121 </div>
122
123 </div>
124
125 <?php
126 }
127
128
129 /**
130 * Extract article content from a URL using DOMDocument.
131 *
132 * @param string $url The URL to extract content from.
133 * @return array|WP_Error Array with 'title', 'content', 'excerpt', 'url' or WP_Error.
134 */
135 function botwriter_rewriter_extract_content($url, $prefetched_html = null) {
136 if ($prefetched_html !== null) {
137 // Use pre-fetched HTML (e.g. from Site Rewriter crawl)
138 return botwriter_rewriter_extract_content_from_html($prefetched_html, $url);
139 }
140
141 // Build a Referer from the target domain (sites often block requests without one)
142 $parsed = wp_parse_url($url);
143 $referer = (isset($parsed['scheme']) ? $parsed['scheme'] : 'https') . '://' . ($parsed['host'] ?? '');
144
145 // Fetch the page with realistic browser headers to avoid 403 blocks
146 $ssl_verify = get_option('botwriter_sslverify', 'yes') === 'yes';
147 $response = wp_remote_get($url, array(
148 'timeout' => 30,
149 'user-agent' => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
150 'sslverify' => $ssl_verify,
151 'redirection' => 5,
152 'headers' => array(
153 'Accept' => 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
154 'Accept-Language' => 'en-US,en;q=0.9,es;q=0.8',
155 'Accept-Encoding' => 'identity',
156 'Cache-Control' => 'no-cache',
157 'Referer' => $referer,
158 'Sec-Fetch-Dest' => 'document',
159 'Sec-Fetch-Mode' => 'navigate',
160 'Sec-Fetch-Site' => 'same-origin',
161 'Sec-Fetch-User' => '?1',
162 'Upgrade-Insecure-Requests' => '1',
163 'Sec-Ch-Ua' => '"Chromium";v="131", "Not_A Brand";v="24"',
164 'Sec-Ch-Ua-Mobile' => '?0',
165 'Sec-Ch-Ua-Platform' => '"Windows"',
166 ),
167 ));
168
169 if (is_wp_error($response)) {
170 return $response;
171 }
172
173 $status_code = wp_remote_retrieve_response_code($response);
174 if ($status_code === 403) {
175 return new WP_Error('http_403', 'Access denied (403). The website is blocking automated requests from your server. Try using "Add Article Manually" to paste the content instead.');
176 }
177 if ($status_code !== 200) {
178 return new WP_Error('http_error', sprintf('HTTP %d error fetching URL.', $status_code));
179 }
180
181 $html = wp_remote_retrieve_body($response);
182 if (empty($html)) {
183 return new WP_Error('empty_response', 'Empty response from URL.');
184 }
185
186 return botwriter_rewriter_extract_content_from_html($html, $url);
187 }
188
189 /**
190 * Extract article content from pre-fetched HTML.
191 * Shared logic used by both botwriter_rewriter_extract_content() and
192 * botwriter_siterewriter_crawl_page() to avoid re-downloading pages.
193 *
194 * @param string $html Raw HTML.
195 * @param string $url Original URL (used for output only).
196 * @return array|WP_Error
197 */
198 function botwriter_rewriter_extract_content_from_html($html, $url = '') {
199 // Suppress DOM warnings for malformed HTML
200 libxml_use_internal_errors(true);
201
202 $doc = new DOMDocument();
203 $doc->loadHTML('<?xml encoding="UTF-8">' . $html, LIBXML_NOWARNING | LIBXML_NOERROR);
204
205 libxml_clear_errors();
206
207 $xpath = new DOMXPath($doc);
208
209 // --- Extract title ---
210 $title = '';
211
212 // Try og:title first
213 $og_title_nodes = $xpath->query('//meta[@property="og:title"]/@content');
214 if ($og_title_nodes && $og_title_nodes->length > 0) {
215 $title = trim($og_title_nodes->item(0)->nodeValue);
216 }
217
218 // Try <h1> if og:title empty
219 if (empty($title)) {
220 $h1_nodes = $xpath->query('//h1');
221 if ($h1_nodes && $h1_nodes->length > 0) {
222 $title = trim($h1_nodes->item(0)->textContent);
223 }
224 }
225
226 // Fallback to <title>
227 if (empty($title)) {
228 $title_nodes = $xpath->query('//title');
229 if ($title_nodes && $title_nodes->length > 0) {
230 $title = trim($title_nodes->item(0)->textContent);
231 }
232 }
233
234 // --- Remove unwanted tags (always safe to remove) ---
235 $remove_tags = array('script', 'style', 'nav', 'header', 'footer', 'aside', 'iframe', 'noscript', 'form', 'svg', 'figcaption');
236 foreach ($remove_tags as $tag) {
237 $elements = $xpath->query('//' . $tag);
238 if ($elements) {
239 // Collect into array first to avoid live NodeList issues
240 $to_remove = array();
241 foreach ($elements as $element) {
242 $to_remove[] = $element;
243 }
244 foreach ($to_remove as $element) {
245 if ($element->parentNode) {
246 $element->parentNode->removeChild($element);
247 }
248 }
249 }
250 }
251
252 // Remove noise elements by class/id patterns.
253 // Only remove elements that do NOT contain substantial paragraph text
254 // (prevents stripping parent containers on sites that put "social" or "share" classes on wrappers).
255 $noise_patterns = array(
256 '//*[contains(@class, "comment")]',
257 '//*[contains(@class, "sidebar")]',
258 '//*[contains(@class, "widget")]',
259 '//*[contains(@class, "share")]',
260 '//*[contains(@class, "social")]',
261 '//*[contains(@class, "related")]',
262 '//*[contains(@class, "advertisement")]',
263 '//*[contains(@class, "ad-")]',
264 '//*[contains(@class, "cookie")]',
265 '//*[contains(@class, "newsletter")]',
266 '//*[contains(@class, "popup")]',
267 '//*[contains(@class, "author-bio")]',
268 '//*[contains(@class, "post-tags")]',
269 '//*[contains(@class, "breadcrumb")]',
270 '//*[contains(@class, "navigation")]',
271 '//*[contains(@class, "paginat")]',
272 '//*[contains(@id, "comment")]',
273 '//*[contains(@id, "sidebar")]',
274 '//*[contains(@id, "footer")]',
275 '//*[contains(@id, "header")]',
276 '//*[contains(@id, "newsletter")]',
277 );
278 foreach ($noise_patterns as $pattern) {
279 $elements = $xpath->query($pattern);
280 if ($elements) {
281 $to_remove = array();
282 foreach ($elements as $element) {
283 // Safety check: only remove if element has few real paragraphs
284 // (avoids stripping parent containers that wrap the article)
285 $p_count = 0;
286 $paras = $xpath->query('.//p', $element);
287 if ($paras) {
288 foreach ($paras as $p) {
289 if (strlen(trim($p->textContent)) > 40) {
290 $p_count++;
291 }
292 }
293 }
294 if ($p_count < 3) {
295 $to_remove[] = $element;
296 }
297 }
298 foreach ($to_remove as $element) {
299 if ($element->parentNode) {
300 $element->parentNode->removeChild($element);
301 }
302 }
303 }
304 }
305
306 // --- Extract main content ---
307 $content = '';
308
309 // Strategy 1: Schema.org articleBody (most reliable when present)
310 $schema_nodes = $xpath->query('//*[@itemprop="articleBody"]');
311 if ($schema_nodes && $schema_nodes->length > 0) {
312 $content = botwriter_rewriter_get_text_content($schema_nodes->item(0));
313 }
314
315 // Strategy 2: Common content container classes (including popular WP themes)
316 if (empty(trim($content)) || strlen(trim($content)) < 200) {
317 $content_selectors = array(
318 // Standard WordPress
319 '//*[contains(@class, "entry-content")]',
320 '//*[contains(@class, "post-content")]',
321 '//*[contains(@class, "article-content")]',
322 '//*[contains(@class, "article-body")]',
323 '//*[contains(@class, "post-body")]',
324 '//*[contains(@class, "single-content")]',
325 // TagDiv themes (Flavor, Flavor HD, Flavor Pro, Flavor Magazine)
326 '//*[contains(@class, "td-post-content")]',
327 '//*[contains(@class, "tdb-block-inner")]',
328 // Flavor / flavored themes
329 '//*[contains(@class, "flavor-content")]',
330 // flavor theme "the_content" wrapper
331 '//*[contains(@class, "the_content_wrapper")]',
332 // flavor "single-post-content"
333 '//*[contains(@class, "single-post-content")]',
334 // flavor article text
335 '//*[contains(@class, "flavor-text")]',
336 // flavor article body
337 '//*[contains(@class, "flavor-article")]',
338 // flavor tdb
339 '//*[contains(@class, "tdb_single_content")]',
340 // flavor td_module_wrap
341 '//*[contains(@class, "td-module-content")]',
342 // flavor td
343 '//*[contains(@class, "td-ss-main-content")]',
344 // flavor tdi
345 '//*[contains(@class, "wpb_text_column")]',
346 // GeneratePress
347 '//*[contains(@class, "inside-article")]',
348 // Astra
349 '//*[contains(@class, "ast-post-format-")]',
350 // OceanWP
351 '//*[contains(@class, "entry")]',
352 // Flavor / flavored themes
353 '//*[contains(@class, "flavor-article")]',
354 // Generic / other frameworks
355 '//*[contains(@class, "article__body")]',
356 '//*[contains(@class, "story-body")]',
357 '//*[contains(@class, "c-article-body")]',
358 '//*[contains(@class, "content-area")]',
359 '//*[@id="content"]',
360 '//*[@role="main"]',
361 '//main',
362 );
363
364 foreach ($content_selectors as $selector) {
365 $nodes = $xpath->query($selector);
366 if ($nodes && $nodes->length > 0) {
367 $candidate = botwriter_rewriter_get_text_content($nodes->item(0));
368 if (strlen(trim($candidate)) > strlen(trim($content))) {
369 $content = $candidate;
370 }
371 }
372 }
373 }
374
375 // Strategy 3: Look for <article> tag (after class-based, because article
376 // tags often include metadata/author/share buttons besides the text)
377 if (empty(trim($content)) || strlen(trim($content)) < 200) {
378 $article_nodes = $xpath->query('//article');
379 if ($article_nodes && $article_nodes->length > 0) {
380 $candidate = botwriter_rewriter_get_text_content($article_nodes->item(0));
381 if (strlen(trim($candidate)) > strlen(trim($content))) {
382 $content = $candidate;
383 }
384 }
385 }
386
387 // Strategy 4: Find the div with the most <p> text (simplified readability heuristic)
388 if (empty(trim($content)) || strlen(trim($content)) < 200) {
389 $divs = $xpath->query('//div');
390 $best_div = null;
391 $best_score = 0;
392
393 if ($divs) {
394 foreach ($divs as $div) {
395 $paragraphs = $xpath->query('.//p', $div);
396 if (!$paragraphs) continue;
397
398 $score = 0;
399 $text_len = 0;
400 foreach ($paragraphs as $p) {
401 $p_text = trim($p->textContent);
402 if (strlen($p_text) > 25) {
403 $score++;
404 $text_len += strlen($p_text);
405 }
406 }
407 // Prefer containers with more paragraphs + text, but penalize
408 // very large containers (likely body/wrapper) to find the tightest match
409 $child_divs = $xpath->query('./div', $div);
410 $nesting_penalty = $child_divs ? $child_divs->length * 10 : 0;
411 $total_score = $score * 100 + $text_len - $nesting_penalty;
412 if ($total_score > $best_score && $score >= 2) {
413 $best_score = $total_score;
414 $best_div = $div;
415 }
416 }
417 }
418
419 if ($best_div) {
420 $candidate = botwriter_rewriter_get_text_content($best_div);
421 if (strlen(trim($candidate)) > strlen(trim($content))) {
422 $content = $candidate;
423 }
424 }
425 }
426
427 // Clean up content
428 $content = trim($content);
429
430 // Truncate if extremely long (e.g. over 15000 chars)
431 if (strlen($content) > 15000) {
432 $content = substr($content, 0, 15000) . "\n\n[Content truncated...]";
433 }
434
435 if (empty($title) && empty($content)) {
436 return new WP_Error('extraction_failed', 'Could not extract any content from the URL.');
437 }
438
439 // Flag partial extractions so the frontend can warn the user
440 $content_warning = '';
441 if (empty($content)) {
442 $content_warning = 'no_content';
443 } elseif (strlen($content) < 200) {
444 $content_warning = 'short_content';
445 }
446
447 // Create excerpt from first 300 chars
448 $excerpt = '';
449 if (!empty($content)) {
450 $excerpt = wp_trim_words(wp_strip_all_tags($content), 40, '...');
451 }
452
453 return array(
454 'title' => sanitize_text_field($title),
455 'content' => $content,
456 'excerpt' => $excerpt,
457 'url' => esc_url($url),
458 'content_warning' => $content_warning,
459 );
460 }
461
462
463 /**
464 * Helper: Extract cleaned text content from a DOMNode.
465 * Preserves paragraph structure but removes tags.
466 *
467 * @param DOMNode $node
468 * @return string
469 */
470 function botwriter_rewriter_get_text_content($node) {
471 $text = '';
472 foreach ($node->childNodes as $child) {
473 if ($child->nodeType === XML_TEXT_NODE) {
474 $text .= trim($child->textContent) . ' ';
475 } elseif ($child->nodeType === XML_ELEMENT_NODE) {
476 $tag = strtolower($child->nodeName);
477 // Add line breaks for block elements
478 if (in_array($tag, array('p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'br', 'blockquote', 'pre', 'tr'))) {
479 $inner = botwriter_rewriter_get_text_content($child);
480 if (!empty(trim($inner))) {
481 $text .= "\n\n" . trim($inner);
482 }
483 } else {
484 $text .= botwriter_rewriter_get_text_content($child);
485 }
486 }
487 }
488 return $text;
489 }
490