From 73e3025917d043e17cc5e306205fd0a47d709807 Mon Sep 17 00:00:00 2001 From: A1Gard Date: Thu, 17 Oct 2024 04:01:43 +0330 Subject: [PATCH] added seo content analyzer --- app/Http/Controllers/Admin/PostController.php | 1 + .../Controllers/Admin/ProductController.php | 1 + .../2024_05_07_123414_create_posts_table.php | 1 + ...024_05_07_130016_create_products_table.php | 1 + resources/js/app.js | 1 + resources/js/panel/editor-handle.js | 24 +- resources/js/panel/seo-analyzer.js | 371 ++++++++++++++++++ resources/sass/panel/_common.scss | 93 +++++ .../views/admin/posts/post-form.blade.php | 15 +- .../sub-pages/product-step1.blade.php | 14 +- .../sitemaps/sitemap-attachments.blade.php | 1 + .../website/sitemaps/sitemap-clips.blade.php | 1 + .../sitemaps/sitemap-gallries.blade.php | 1 + .../sitemap-groups-category.blade.php | 1 + .../website/sitemaps/sitemap-posts.blade.php | 1 + .../sitemaps/sitemap-products.blade.php | 1 + .../views/website/sitemaps/sitemap.blade.php | 1 + 17 files changed, 526 insertions(+), 3 deletions(-) create mode 100644 resources/js/panel/seo-analyzer.js diff --git a/app/Http/Controllers/Admin/PostController.php b/app/Http/Controllers/Admin/PostController.php index 62f8eb6..1ac51ec 100644 --- a/app/Http/Controllers/Admin/PostController.php +++ b/app/Http/Controllers/Admin/PostController.php @@ -60,6 +60,7 @@ class PostController extends XController $post->is_pinned = $request->has('is_pin'); $post->table_of_contents = $request->has('table_of_contents'); $post->icon = $request->input('icon'); + $post->keyword = $request->input('keyword'); if ($request->has('canonical') && trim($request->input('canonical')) != ''){ $post->canonical = $request->input('canonical'); diff --git a/app/Http/Controllers/Admin/ProductController.php b/app/Http/Controllers/Admin/ProductController.php index 94998a5..c0f17ed 100644 --- a/app/Http/Controllers/Admin/ProductController.php +++ b/app/Http/Controllers/Admin/ProductController.php @@ -57,6 +57,7 @@ class ProductController extends XController $product->table = $request->input('table'); $product->description = $request->input('desc'); $product->excerpt = $request->input('excerpt'); + $product->keyword = $request->input('keyword'); $product->stock_status = $request->input('stock_status'); $product->price = $request->input('price',0); $product->buy_price = $request->input('buy_price',0); diff --git a/database/migrations/2024_05_07_123414_create_posts_table.php b/database/migrations/2024_05_07_123414_create_posts_table.php index 81512e7..7e4fb32 100644 --- a/database/migrations/2024_05_07_123414_create_posts_table.php +++ b/database/migrations/2024_05_07_123414_create_posts_table.php @@ -30,6 +30,7 @@ return new class extends Migration $table->json('theme')->nullable(); $table->text('canonical')->nullable(); $table->string('promote')->nullable(); + $table->text('keyword')->nullable(); $table->softDeletes(); $table->timestamps(); diff --git a/database/migrations/2024_05_07_130016_create_products_table.php b/database/migrations/2024_05_07_130016_create_products_table.php index 27e7d18..8d6e52d 100644 --- a/database/migrations/2024_05_07_130016_create_products_table.php +++ b/database/migrations/2024_05_07_130016_create_products_table.php @@ -38,6 +38,7 @@ return new class extends Migration $table->json('theme')->nullable(); $table->text('canonical')->nullable(); $table->string('promote')->nullable(); + $table->text('keyword')->nullable(); $table->softDeletes(); $table->timestamps(); diff --git a/resources/js/app.js b/resources/js/app.js index 525082b..1b7a562 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -31,6 +31,7 @@ import './panel/sotable-controller.js'; import './panel/prototypes.js'; import './panel/panel-window-loader.js'; import './panel/responsive-control.js'; +// import './panel/seo-analyzer.js'; // chartjs.defaults.defaultFontFamily = "Vazir"; // chartjs.defaults.defaultFontSize = 18; diff --git a/resources/js/panel/editor-handle.js b/resources/js/panel/editor-handle.js index 044f39c..fe68418 100644 --- a/resources/js/panel/editor-handle.js +++ b/resources/js/panel/editor-handle.js @@ -1,4 +1,8 @@ +import ContentSEOAnalyzer from './seo-analyzer.js' +let timeOut = null; window.addEventListener('load', function () { + + let keywordInput = document.querySelector('#keyword') ; let dirx = document.querySelector('#panel-dir').value; let editors = {}; document.querySelectorAll('.ckeditorx')?.forEach(function (el) { @@ -15,9 +19,27 @@ window.addEventListener('load', function () { skin: 'moono-dark', }); + CKEDITOR.addCss('.cke_editable { background-color: ' + website_bg + '; color: ' + website_text_color + ' ; font-family: ' + website_font + ' }'); editors[el.getAttribute('name')].on('change', function (evt) { - el.value = evt.editor.getData(); + const content = evt.editor.getData(); + el.value = content; + if (el.classList.contains('seo-analyze')){ + let keyword = keywordInput?.value; + + const analyzer = new ContentSEOAnalyzer(content, keyword); + const report = analyzer.generateReport(); + analyzer.displaySEOReport(report,'seo-hint') + } }); + + + if (el.classList.contains('seo-analyze')){ + editors[el.getAttribute('name')].fire('change'); + keywordInput?.addEventListener('input',function () { + editors[el.getAttribute('name')].fire('change'); + }); + } + }); }); diff --git a/resources/js/panel/seo-analyzer.js b/resources/js/panel/seo-analyzer.js new file mode 100644 index 0000000..d90eb25 --- /dev/null +++ b/resources/js/panel/seo-analyzer.js @@ -0,0 +1,371 @@ +class ContentSEOAnalyzer { + constructor(content, targetKeyword) { + this.content = content; + this.targetKeyword = targetKeyword.toLowerCase(); + this.plainText = this.stripHTML(content); + this.sentences = this.getSentences(); + this.paragraphs = this.getParagraphs(); + this.wordCount = this.getWordCount(); + } + + // Remove HTML tags and get plain text + stripHTML(html) { + return html.replace(/<[^>]*>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + } + + // Improved sentence detection for mixed content + getSentences() { + // First clean the text from extra spaces and normalize punctuation + let text = this.plainText + .replace(/\s+/g, ' ') + .replace(/[\u200B-\u200D\uFEFF]/g, ''); // Remove zero-width spaces + + // Handle both RTL and LTR sentence endings + // Added more Arabic/Persian punctuation marks + const sentenceEndings = [ + '.', // English period + '!', // English exclamation + '?', // English question mark + '؟', // Arabic question mark + '।', // Arabic full stop + '۔', // Urdu full stop + '،', // Arabic comma when followed by a new sentence + ';', // English semicolon when used as sentence separator + '؛', // Arabic semicolon + ]; + + // Create a regex pattern that matches any of these endings + // followed by a space and either: + // 1. An uppercase letter (for English) + // 2. An Arabic/Persian letter + // 3. A number (for both scripts) + const pattern = new RegExp( + `([${sentenceEndings.join('')}])\\s*(?=[A-Z\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF0-9])`, + 'g' + ); + + // Split into sentences + let sentences = text + .replace(pattern, '$1|') + .split('|') + .map(sentence => sentence.trim()) + .filter(sentence => { + // Remove empty sentences and very short ones (less than 2 words) + const words = sentence.split(/\s+/); + return sentence.length > 0 && words.length >= 2; + }); + + // Additional cleaning: merge incorrectly split sentences + sentences = this.cleanSentences(sentences); + + return sentences; + } + + // Helper method to clean and merge sentences that might have been incorrectly split + cleanSentences(sentences) { + const cleaned = []; + let current = ''; + + for (let sentence of sentences) { + // Check if sentence starts with lowercase or is very short + if (current && ( + sentence.charAt(0).match(/[a-z]/) || // Starts with lowercase + sentence.length < 10 || // Very short + /^[و،]/.test(sentence) // Starts with Arabic 'and' or comma + )) { + current += ' ' + sentence; + } else { + if (current) { + cleaned.push(current.trim()); + } + current = sentence; + } + } + + // Don't forget to add the last sentence + if (current) { + cleaned.push(current.trim()); + } + + // Final filtering to remove any remaining invalid sentences + return cleaned.filter(sentence => { + // Ensure minimum length and word count + const words = sentence.split(/\s+/); + return sentence.length >= 10 && words.length >= 2; + }); + } + + // Adjust the readability analysis to be more accurate with the new sentence detection + analyzeReadability() { + const sentences = this.getSentences(); + const avgSentenceLength = sentences.length ? + this.wordCount / sentences.length : 0; + + // Calculate words per sentence more accurately + const sentenceLengths = sentences.map(sentence => + sentence.split(/\s+/).filter(word => word.length > 0).length + ); + + // More accurate complex sentence detection + const complexSentences = sentenceLengths.filter(length => length > 25).length; + const complexSentencePercentage = sentences.length ? + (complexSentences / sentences.length) * 100 : 0; + + return { + avgSentenceLength, + avgWordsPerSentence: avgSentenceLength, + sentenceCount: sentences.length, + complexSentencePercentage, + sentenceLengthVariation: this.calculateVariation(sentenceLengths), + totalParagraphs: this.paragraphs.length, + readabilityScore: this.calculateReadabilityScore(sentenceLengths) + }; + } + + // Helper method to calculate statistical variation in sentence lengths + calculateVariation(lengths) { + if (lengths.length < 2) return 0; + const mean = lengths.reduce((sum, val) => sum + val, 0) / lengths.length; + const variance = lengths.reduce((sum, val) => sum + Math.pow(val - mean, 2), 0) / lengths.length; + return Math.sqrt(variance); + } + + // Calculate a more nuanced readability score + calculateReadabilityScore(sentenceLengths) { + if (sentenceLengths.length === 0) return 0; + + const avg = sentenceLengths.reduce((sum, len) => sum + len, 0) / sentenceLengths.length; + const variation = this.calculateVariation(sentenceLengths); + + // Ideal ranges: + // Average sentence length: 15-20 words + // Variation: 5-10 words (some variety but not too much) + let score = 10; + + // Penalize for extreme average lengths + if (avg < 10) score -= 2; + else if (avg > 25) score -= 2; + else if (avg > 20) score -= 1; + + // Penalize for too much or too little variation + if (variation < 3) score -= 1; // Too monotonous + else if (variation > 15) score -= 1; // Too varied + + return Math.max(0, Math.min(10, score)); + } + + // Get paragraphs from content + getParagraphs() { + return this.content + .split(/<\/p>|<\/div>||\n/) + .map(p => this.stripHTML(p)) + .filter(p => p.trim().length > 0); + } + + // Get word count + getWordCount() { + return this.plainText.split(/\s+/).filter(word => word.length > 0).length; + } + + // Calculate average sentence length + getAverageSentenceLength() { + if (this.sentences.length === 0) return 0; + const totalWords = this.sentences + .map(sentence => sentence.split(/\s+/).filter(word => word.length > 0).length) + .reduce((sum, length) => sum + length, 0); + return totalWords / this.sentences.length; + } + + // Calculate average paragraph length + getAverageParagraphLength() { + if (this.paragraphs.length === 0) return 0; + const totalWords = this.paragraphs + .map(para => para.split(/\s+/).filter(word => word.length > 0).length) + .reduce((sum, length) => sum + length, 0); + return totalWords / this.paragraphs.length; + } + + // Analyze keyword usage + analyzeKeyword() { + + // Check keyword size + let shortKeyword = false; + if (this.targetKeyword.length < 2){ + shortKeyword = true; + } + const keywordCount = (this.plainText.toLowerCase().match(new RegExp(this.targetKeyword, 'g')) || []).length; + const density = (keywordCount / this.wordCount) * 100; + + // Check keyword in first paragraph + const firstParagraphHasKeyword = this.paragraphs[0]?.toLowerCase().includes(this.targetKeyword); + + // Check keyword in headings + const headings = this.content.match(/]*>(.*?)<\/h[1-6]>/gi) || []; + const headingsWithKeyword = headings.filter(h => + this.stripHTML(h).toLowerCase().includes(this.targetKeyword) + ).length; + + return { + count: keywordCount, + density, + firstParagraphHasKeyword, + headingsWithKeyword, + shortKeyword: shortKeyword, + }; + } + + + // Generate analysis report with 0-10 rating + generateReport() { + const keywordAnalysis = this.analyzeKeyword(); + const readabilityAnalysis = this.analyzeReadability(); + + let score = 0; + const feedback = []; + + // Score components (each component adds up to 10) + + // 1. Content Length (2 points) + if (this.wordCount >= 300) score += 2; + else feedback.push('Content is too short. Aim for at least 300 words.'); + + // 2. Keyword Usage (2 points) + if (keywordAnalysis.density >= 0.5 && keywordAnalysis.density <= 2.5) score += 0.5; + if (keywordAnalysis.firstParagraphHasKeyword) score += 0.5; + if (keywordAnalysis.headingsWithKeyword > 0) score += 1; + if (keywordAnalysis.count >= 2) score += 0; + + if (keywordAnalysis.density < 0.5) feedback.push('Keyword density is too low'); + if (keywordAnalysis.density > 3.5) feedback.push('Keyword density is too high'); + if (keywordAnalysis.shortKeyword) feedback.push('Keyword is too short fix keyword to better analyze'); + if (!keywordAnalysis.firstParagraphHasKeyword) feedback.push('Include keyword in the first paragraph'); + if (keywordAnalysis.headingsWithKeyword === 0) feedback.push('Include keyword in at least one heading'); + + // 3. Readability (4 points) + if (readabilityAnalysis.avgSentenceLength <= 30) score += 1; + if (readabilityAnalysis.avgParagraphLength <= 150) score += 1; + if (readabilityAnalysis.complexSentencePercentage <= 25) score += 1; + if (this.paragraphs.length >= 3) score += 1; + + if (readabilityAnalysis.avgSentenceLength > 30) feedback.push('Sentences are too long'); + if (readabilityAnalysis.avgParagraphLength > 150) feedback.push('Paragraphs are too long'); + if (readabilityAnalysis.complexSentencePercentage > 25) feedback.push('Too many complex sentences'); + if (this.paragraphs.length < 3) feedback.push('Add more paragraphs to improve structure'); + + // 4. Structure & Formatting (2 points) + const hasHeadings = /]*>/i.test(this.content); + const hasLists = /<[ou]l[^>]*>/i.test(this.content); + + if (hasHeadings) score += 1; + if (hasLists) score += 1; + + if (!hasHeadings) feedback.push('Add headings to structure your content'); + if (!hasLists) feedback.push('Consider using lists to improve readability'); + + return { + score: Math.min(10, Math.round(score * 10) / 10), + feedback, + details: { + wordCount: this.wordCount, + keywordUsage: { + count: keywordAnalysis.count, + density: `${keywordAnalysis.density.toFixed(1)}%`, + inFirstParagraph: keywordAnalysis.firstParagraphHasKeyword, + inHeadings: keywordAnalysis.headingsWithKeyword + }, + readability: { + avgWordsPerSentence: Math.round(readabilityAnalysis.avgSentenceLength), + avgWordsPerParagraph: Math.round(readabilityAnalysis.avgParagraphLength), + complexSentences: `${readabilityAnalysis.complexSentencePercentage.toFixed(1)}%`, + paragraphCount: readabilityAnalysis.totalParagraphs + } + } + }; + } + + + +// Function to determine score status + getScoreStatus(score) { + if (score >= 7) return { class: 'good', text: 'Good' }; + if (score >= 5) return { class: 'average', text: 'Needs Improvement' }; + return { class: 'poor', text: 'Poor' }; + } + +// Function to create and display the report + displaySEOReport(report, targetElement) { + // // Add styles to document if not already present + // if (!document.getElementById('seo-report-styles')) { + // const styleSheet = document.createElement('style'); + // styleSheet.id = 'seo-report-styles'; + // styleSheet.textContent = styles; + // document.head.appendChild(styleSheet); + // } + + const scoreStatus = this.getScoreStatus(report.score); + + const reportHTML = ` +
+
+
+
+ ${report.score.toFixed(1)} +
+
+
+

SEO Score: ${scoreStatus.text}

+

Based on content analysis and keyword optimization

+
+
+ +
+
+

Recommendations

+
    + ${report.feedback.map(item => `
  • ${item}
  • `).join('')} +
+
+ +
+
+

Content Length

+
${report.details.wordCount} words
+
+ +
+

Keyword Usage

+
+ ${report.details.keywordUsage.count} times + (${report.details.keywordUsage.density}) +
+
+ +
+

Average Sentence Length

+
+ ${report.details.readability.avgWordsPerSentence} words +
+
+ +
+

Paragraph Structure

+
+ ${report.details.readability.paragraphCount} paragraphs + (avg ${report.details.readability.avgWordsPerParagraph} words) +
+
+
+
+
+ `; + + const targetDiv = document.getElementById(targetElement); + if (targetDiv) { + targetDiv.innerHTML = reportHTML; + } + } +} + +export default ContentSEOAnalyzer; diff --git a/resources/sass/panel/_common.scss b/resources/sass/panel/_common.scss index 9d72da3..7753704 100644 --- a/resources/sass/panel/_common.scss +++ b/resources/sass/panel/_common.scss @@ -381,3 +381,96 @@ a.btn,a.action-btn,a.circle-btn{ } + + + + +#seo-hint{ + + + .seo-report { + max-width: 100%; + margin: 15px 0; + background: #21252b; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + .seo-score-container { + display: flex; + align-items: center; + padding: 20px; + border-bottom: 1px solid #eee; + gap: 20px; + } + .seo-score { + position: relative; + width: 80px; + height: 80px; + } + .seo-score-circle { + width: 100%; + height: 100%; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 24px; + font-weight: bold; + color: white; + } + .seo-status { + flex: 1; + h3 { + margin: 0 0 5px 0; + font-size: 18px; + } + p { + margin: 0; + color: #eee; + } + } + .seo-details { + padding: 20px; + } + .seo-feedback { + margin-bottom: 20px; + ul { + margin: 10px 0; + padding-left: 20px; + } + li { + margin: 8px 0; + color: #ddd; + } + } + .seo-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 20px; + } + .metric-card { + background: #f8f9fa; + padding: 15px; + border-radius: 6px; + h4 { + margin: 0 0 10px 0; + color: #333; + font-size: 14px; + } + } + .metric-value { + font-size: 16px; + font-weight: 500; + color: #2a2a2a; + } + .good { + background: #22c55e; + } + .average { + background: #f59e0b; + } + .poor { + background: #ef4444; + } + +} diff --git a/resources/views/admin/posts/post-form.blade.php b/resources/views/admin/posts/post-form.blade.php index 90ab705..52f3686 100644 --- a/resources/views/admin/posts/post-form.blade.php +++ b/resources/views/admin/posts/post-form.blade.php @@ -152,7 +152,7 @@ - {{-- @trix(\App\Post::class, 'body')--}} @@ -160,6 +160,19 @@ +
+
+ + +
+ +
+
+
+
+
+ + +
+
+
+
diff --git a/resources/views/website/sitemaps/sitemap-attachments.blade.php b/resources/views/website/sitemaps/sitemap-attachments.blade.php index d1f98b3..51c52ba 100644 --- a/resources/views/website/sitemaps/sitemap-attachments.blade.php +++ b/resources/views/website/sitemaps/sitemap-attachments.blade.php @@ -1,4 +1,5 @@ @cache('sitemap_attach'. cacheNumber(), 3600) +{{--update every 1 hour--}}