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 / dedup.php

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

295 lines 10.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * BotWriter — Cross-task duplicate detection for RSS / WordPress sources.
4 *
5 * Public API:
6 * - botwriter_dedup_is_duplicate( array $candidate ): array
7 *
8 * Settings (whitelisted in includes/settings.php):
9 * - botwriter_dedup_enabled (UI) "1" / "0" default "1"
10 * - botwriter_dedup_title_threshold (UI) int 0-100 default 70
11 * - botwriter_dedup_window_days (hidden) int 1-30 default 7
12 * - botwriter_dedup_url_normalize (hidden) "1" / "0" default "1"
13 * - botwriter_dedup_content_threshold (hidden) int 0-100 default 80
14 * - botwriter_dedup_max_history (hidden) int 50-1000 default 200
15 * - botwriter_dedup_scope (hidden) "task" / "site" default "site"
16 * - botwriter_dedup_action (hidden) "skip"|"log_only" default "skip"
17 */
18
19 if (!defined('ABSPATH')) {
20 exit;
21 }
22
23 /**
24 * Return all dedup options merged with their defaults.
25 *
26 * @return array
27 */
28 function botwriter_dedup_get_options() {
29 return array(
30 'enabled' => get_option('botwriter_dedup_enabled', '1') === '1',
31 'title_threshold' => max(0, min(100, intval(get_option('botwriter_dedup_title_threshold', 70)))),
32 'content_threshold' => max(0, min(100, intval(get_option('botwriter_dedup_content_threshold', 80)))),
33 'window_days' => max(1, min(30, intval(get_option('botwriter_dedup_window_days', 7)))),
34 'max_history' => max(50, min(1000, intval(get_option('botwriter_dedup_max_history', 200)))),
35 'url_normalize' => get_option('botwriter_dedup_url_normalize', '1') === '1',
36 'scope' => get_option('botwriter_dedup_scope', 'site') === 'task' ? 'task' : 'site',
37 'action' => get_option('botwriter_dedup_action', 'skip') === 'log_only' ? 'log_only' : 'skip',
38 );
39 }
40
41 /**
42 * Normalize a URL so equivalent links compare equal.
43 *
44 * - Lowercase scheme + host
45 * - Strip "www."
46 * - Drop fragment
47 * - Drop common tracking query params (utm_*, fbclid, gclid, intcmp, ref, source, ...)
48 * - Trim trailing slash from path
49 *
50 * @param string $url
51 * @return string Normalized URL (or original on parse failure).
52 */
53 function botwriter_dedup_normalize_url($url) {
54 if (!is_string($url) || $url === '') {
55 return '';
56 }
57
58 $url = trim($url);
59 $parts = wp_parse_url($url);
60 if (!is_array($parts) || empty($parts['host'])) {
61 return strtolower($url);
62 }
63
64 $scheme = isset($parts['scheme']) ? strtolower($parts['scheme']) : 'https';
65 $host = strtolower($parts['host']);
66 if (strpos($host, 'www.') === 0) {
67 $host = substr($host, 4);
68 }
69
70 $path = isset($parts['path']) ? $parts['path'] : '/';
71 if ($path !== '/' && substr($path, -1) === '/') {
72 $path = rtrim($path, '/');
73 }
74
75 $query = '';
76 if (!empty($parts['query'])) {
77 $pairs = array();
78 parse_str($parts['query'], $pairs);
79
80 // Drop tracking params
81 $drop_exact = array('fbclid', 'gclid', 'intcmp', 'ref', 'source', 'mc_cid', 'mc_eid', '_hsenc', '_hsmi');
82 foreach ($pairs as $k => $v) {
83 $kl = strtolower($k);
84 if (strpos($kl, 'utm_') === 0 || in_array($kl, $drop_exact, true)) {
85 unset($pairs[$k]);
86 }
87 }
88
89 if (!empty($pairs)) {
90 ksort($pairs);
91 $query = '?' . http_build_query($pairs);
92 }
93 }
94
95 return $scheme . '://' . $host . $path . $query;
96 }
97
98 /**
99 * Reduce a string to a comparable form: lowercase, strip tags, collapse whitespace.
100 *
101 * @param string $text
102 * @return string
103 */
104 function botwriter_dedup_normalize_text($text) {
105 if (!is_string($text)) {
106 return '';
107 }
108 $text = wp_strip_all_tags($text);
109 $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
110 if (function_exists('mb_strtolower')) {
111 $text = mb_strtolower($text, 'UTF-8');
112 } else {
113 $text = strtolower($text);
114 }
115 $text = preg_replace('/\s+/u', ' ', $text);
116 return trim((string) $text);
117 }
118
119 /**
120 * Compute a similarity percentage (0-100) between two strings.
121 *
122 * Uses PHP's similar_text(); inputs are normalized first. For very long strings
123 * we sample the first 2000 chars to keep the cost bounded.
124 *
125 * @param string $a
126 * @param string $b
127 * @return float
128 */
129 function botwriter_dedup_similarity($a, $b) {
130 $a = botwriter_dedup_normalize_text($a);
131 $b = botwriter_dedup_normalize_text($b);
132 if ($a === '' || $b === '') {
133 return 0.0;
134 }
135 if ($a === $b) {
136 return 100.0;
137 }
138 if (strlen($a) > 2000) { $a = substr($a, 0, 2000); }
139 if (strlen($b) > 2000) { $b = substr($b, 0, 2000); }
140
141 $percent = 0.0;
142 similar_text($a, $b, $percent);
143 return (float) $percent;
144 }
145
146 /**
147 * Fetch recent log rows used for dedup comparison.
148 *
149 * @param array $opts Options from botwriter_dedup_get_options().
150 * @param int|null $id_task Restrict to this task when scope === 'task'.
151 * @return array Rows with link_post_original, aigenerated_title, aigenerated_content.
152 */
153 function botwriter_dedup_get_recent_rows($opts, $id_task = null) {
154 global $wpdb;
155 $table = $wpdb->prefix . 'botwriter_logs';
156 $table_sql = preg_replace('/[^A-Za-z0-9_]/', '', (string) $table);
157 if ($table_sql === '') {
158 return array();
159 }
160
161 $since = gmdate('Y-m-d H:i:s', time() - ($opts['window_days'] * DAY_IN_SECONDS));
162 $limit = (int) $opts['max_history'];
163
164 if ($opts['scope'] === 'task' && $id_task) {
165 $rows = $wpdb->get_results(
166 $wpdb->prepare(
167 "SELECT link_post_original, aigenerated_title, aigenerated_content
168 FROM `" . $table_sql . "`
169 WHERE id_task = %d
170 AND task_status = 'completed'
171 AND created_at >= %s
172 ORDER BY id DESC
173 LIMIT %d",
174 (int) $id_task,
175 $since,
176 $limit
177 ),
178 ARRAY_A
179 );
180 } else {
181 $rows = $wpdb->get_results(
182 $wpdb->prepare(
183 "SELECT link_post_original, aigenerated_title, aigenerated_content
184 FROM `" . $table_sql . "`
185 WHERE task_status = 'completed'
186 AND created_at >= %s
187 ORDER BY id DESC
188 LIMIT %d",
189 $since,
190 $limit
191 ),
192 ARRAY_A
193 );
194 }
195
196 return is_array($rows) ? $rows : array();
197 }
198
199 /**
200 * Decide whether a candidate article (URL + title + optional content) should be
201 * considered a duplicate of something already published recently.
202 *
203 * @param array $candidate {
204 * @type string $url Source URL (link_post_original).
205 * @type string $title Source article title.
206 * @type string $content Source article content (optional).
207 * @type int $id_task Current task id (optional, used when scope = 'task').
208 * }
209 * @return array {
210 * @type bool $is_duplicate
211 * @type string $reason 'disabled' | 'url' | 'title' | 'content' | 'none'
212 * @type float $score 0-100 similarity (only when is_duplicate)
213 * @type string $matched_url URL of the matched previous post
214 * @type string $action 'skip' | 'log_only'
215 * }
216 */
217 function botwriter_dedup_is_duplicate(array $candidate) {
218 $opts = botwriter_dedup_get_options();
219
220 $result = array(
221 'is_duplicate' => false,
222 'reason' => 'none',
223 'score' => 0.0,
224 'matched_url' => '',
225 'action' => $opts['action'],
226 );
227
228 if (!$opts['enabled']) {
229 $result['reason'] = 'disabled';
230 return $result;
231 }
232
233 $cand_url = isset($candidate['url']) ? (string) $candidate['url'] : '';
234 $cand_title = isset($candidate['title']) ? (string) $candidate['title'] : '';
235 $cand_content = isset($candidate['content']) ? (string) $candidate['content'] : '';
236 $id_task = isset($candidate['id_task']) ? (int) $candidate['id_task'] : null;
237
238 $cand_url_norm = $opts['url_normalize'] ? botwriter_dedup_normalize_url($cand_url) : strtolower(trim($cand_url));
239
240 $rows = botwriter_dedup_get_recent_rows($opts, $id_task);
241 if (empty($rows)) {
242 return $result;
243 }
244
245 foreach ($rows as $row) {
246 $row_url = isset($row['link_post_original']) ? (string) $row['link_post_original'] : '';
247 if ($row_url !== '' && $cand_url_norm !== '') {
248 $row_url_norm = $opts['url_normalize'] ? botwriter_dedup_normalize_url($row_url) : strtolower(trim($row_url));
249 if ($row_url_norm !== '' && $row_url_norm === $cand_url_norm) {
250 $result['is_duplicate'] = true;
251 $result['reason'] = 'url';
252 $result['score'] = 100.0;
253 $result['matched_url'] = $row_url;
254 return $result;
255 }
256 }
257 }
258
259 if ($cand_title !== '' && $opts['title_threshold'] > 0) {
260 foreach ($rows as $row) {
261 $row_title = isset($row['aigenerated_title']) ? (string) $row['aigenerated_title'] : '';
262 if ($row_title === '') {
263 continue;
264 }
265 $score = botwriter_dedup_similarity($cand_title, $row_title);
266 if ($score >= $opts['title_threshold']) {
267 $result['is_duplicate'] = true;
268 $result['reason'] = 'title';
269 $result['score'] = $score;
270 $result['matched_url'] = isset($row['link_post_original']) ? (string) $row['link_post_original'] : '';
271 return $result;
272 }
273 }
274 }
275
276 if ($cand_content !== '' && $opts['content_threshold'] > 0) {
277 foreach ($rows as $row) {
278 $row_content = isset($row['aigenerated_content']) ? (string) $row['aigenerated_content'] : '';
279 if ($row_content === '') {
280 continue;
281 }
282 $score = botwriter_dedup_similarity($cand_content, $row_content);
283 if ($score >= $opts['content_threshold']) {
284 $result['is_duplicate'] = true;
285 $result['reason'] = 'content';
286 $result['score'] = $score;
287 $result['matched_url'] = isset($row['link_post_original']) ? (string) $row['link_post_original'] : '';
288 return $result;
289 }
290 }
291 }
292
293 return $result;
294 }
295