import { autop } from '@wordpress/autop'; import { isValidGtin } from '../lib/gtin'; import { countKeyphrases, hasKeyphrase, keyphrase, normalize, pageLanguage, sentences, words, } from './keyphrase'; import { BANNED_PHRASES, FORBIDDEN_OPENERS, GENERIC_HEADINGS, NEGATIVE_WORDS, POSITIVE_WORDS, POWER_WORDS, } from './words'; /** What the analyzer looks at: the rendered head and the post as edited. */ export interface AnalysisInput { /** The SEO title as it will be printed. */ title: string; /** The meta description as it will be printed. */ description: string; /** The post slug (the last part of the URL). */ slug: string; /** The post content as HTML. */ content: string; /** The first focus keyword, empty when none. */ keyword: string; /** Every focus keyword, the first included: the density counts them all. */ keywords?: string[]; /** Whether the post has a featured image. */ hasFeaturedImage: boolean; /** Alt texts of images outside the content: the featured image, a product's gallery. */ imageAlts?: string[]; /** Host of this site, to tell internal links from external ones. */ siteHost: string; /** Archive pages only run the tests a title, a description and a URL can pass. */ scope?: AnalysisScope; /** A secondary keyword supports the first one: it is judged on the text and a subheading only. */ role?: KeywordRole; /** The post type: products pass the length test from 200 words, the agent writes them at 150–400. */ postType?: string; /** Whether another post targets the same keyword; null while unknown. */ keywordUsedElsewhere?: boolean | null; /** On a WooCommerce product: what the product data says, as typed. */ product?: ProductInput; /** The post's language from Polylang or WPML; without one it is read from the text. */ language?: string; /** The site's language, when the text does not tell. */ siteLanguage?: string; } /** What a product's own data holds, for the tests of its listing. */ export interface ProductInput { /** The price it sells at; empty when none is set. */ price: string; image: boolean; /** Pictures in the gallery, the product image aside. */ gallery: number; /** The short description, as plain text. */ shortDescription: string; gtin: string; mpn: string; brand: string; /** Categories other than the default one ("Uncategorized"). */ categories: number; reviews: number; } export type AnalysisScope = 'post' | 'term'; export type KeywordRole = 'primary' | 'secondary'; /** * Headings that ask, in the languages shops write in: "How…", "Cum…", * "Cómo…". Words that are also English nouns ("care", "come") are left * out; a heading that ends in a question mark counts in any language. */ const QUESTION_OPENERS = /^(how|what|why|when|where|which|who|is|are|can|does|do|should|cum|ce|de ce|când|cand|unde|cine|cât|câte|cómo|qué|por qué|cuándo|dónde|cuál|comment|pourquoi|quand|où|quel|quelle|wie|warum|wann|welche|welcher|cosa|perché|quando|dove|quale)(?![\p{L}])/iu; /** A FAQ section's heading, in the same languages. */ export const FAQ_HEADINGS = /(?:^|[^\p{L}])(faq|frequently asked|întrebări frecvente|intrebari frecvente|preguntas frecuentes|questions fréquentes|häufig gestellte fragen|domande frequenti|perguntas frequentes|veelgestelde vragen|najczęściej zadawane pytania)(?![\p{L}])/iu; /** * Where a page's FAQ is: its FAQ block, else the part under a heading that * names it, down to the next heading of its level; the whole content when * only FAQ schema says there is one. */ const faqRegion = (content: string): string => { const block = //i.exec( content ); if (block) { return block[0]; } const heading = /]*>([\s\S]*?)<\/h\1>/gi; let found = heading.exec(content); while (found) { if (FAQ_HEADINGS.test(found[2].replace(/<[^>]*>/g, ' '))) { const rest = content.slice(found.index + found[0].length); const next = new RegExp(`]`, 'i').exec(rest); return next ? rest.slice(0, next.index) : rest; } found = heading.exec(content); } return content; }; /** Words a product page is complete at, the Recomaze rule for the length test. */ const PRODUCT_WORDS = 200; const TERM_TESTS = [ 'keywordInTitle', 'keywordInMetaDescription', 'keywordInPermalink', 'lengthPermalink', 'titleStartWithKeyword', 'titleSentiment', 'titleHasPowerWords', ]; /** * What a secondary keyword is judged on: a term that supports the first * keyword belongs in the text and in a subheading, while the title and the * address hold the first one and cannot fit them all. On an archive, its * description. */ const SECONDARY_TESTS = ['keywordInContent', 'keywordInSubheadings']; const SECONDARY_TERM_TESTS = ['keywordInMetaDescription']; /** * A key takeaways list's heading, in the languages shops write in, with and * without their accents: "Key takeaways", "Pe scurt", "En bref". */ const TAKEAWAYS_HEADINGS = /(?:^|[^\p{L}])(key takeaways|takeaways|tl;dr|in short|at a glance|quick answer|the short answer|pe scurt|ce trebuie s[ăa] re[țţt]ii|de re[țţt]inut|ideile principale|idei principale|punctele cheie|puncte cheie|concluzii cheie|principalele concluzii|esen[țţt]ialul|[îi]n rezumat|rezumat|en bref|[àa] retenir|points cl[ée]s|l['’]essentiel|en r[ée]sum[ée]|das wichtigste|auf einen blick|kurz gesagt|zusammenfassung|kernaussagen|en resumen|puntos clave|lo esencial|conclusiones clave|in breve|punti chiave|in sintesi|em resumo|pontos-chave|principais conclus[õo]es|in het kort|belangrijkste punten|w skr[óo]cie|najwa[żz]niejsze|r[öo]viden|a l[ée]nyeg)(?![\p{L}])/iu; /** The longest label a takeaways heading can be: a sentence of body text is not one. */ const TAKEAWAYS_LABEL_CHARS = 80; export type AnalysisGroup = | 'basic' | 'additional' | 'title' | 'content' | 'product' | 'aeo'; export interface AnalysisResult { id: string; group: AnalysisGroup; /** Points earned. */ score: number; /** Points the test is worth. */ max: number; passed: boolean; /** Passed for part of its points: the fix says how to earn the rest. */ partial?: boolean; /** One line saying what was found. */ message: string; /** What to do when the test fails; empty when it passes. */ fix: string; /** A test Rank Math runs and the Recomaze rules do not ask for: shown * beside the others for comparison, never counted as something to do * and never sent to be fixed. */ rankMathOnly?: boolean; } /** The tests that measure the page against its focus keyword: without one they have nothing to say. */ export const KEYWORD_TESTS: readonly string[] = [ 'keywordInTitle', 'keywordInMetaDescription', 'keywordInPermalink', 'keywordIn10Percent', 'keywordInContent', 'keywordInSubheadings', 'keywordInImageAlt', 'keywordDensity', 'keywordNotUsed', 'titleStartWithKeyword', ]; export interface Analysis { score: number; results: AnalysisResult[]; /** Tests that need a focus keyword, counted separately when it is missing. */ needsKeyword: boolean; } export const GROUP_LABELS: Record = { basic: 'Basic SEO', additional: 'Additional', title: 'Title readability', content: 'Content readability', product: 'Product listing', aeo: 'AI answers', }; /** What the groups are about, for the tooltips on the list. */ export const GROUP_TIPS: Partial> = { product: "What Google's product results, merchant listings and AI shopping answers read from the product data itself: the price, the pictures, the short description, the identifiers, the brand, the category and the reviews. Informative for now: these tests do not weigh on the score.", aeo: 'The rules the Recomaze agent writes by, so that answer engines (ChatGPT, Gemini, Perplexity, Google AI Overviews) lift and cite the page: an answer first, question headings, a FAQ, a table where things are compared, no filler phrases, no prices. Informative for now: these tests do not weigh on the score.', }; const stripTags = (html: string): string => html .replace(//g, ' ') .replace(/<(script|style)[\s\S]*?<\/\1>/gi, ' ') .replace(/<[^>]+>/g, ' ') .replace(/ /g, ' ') .replace(/&[a-z#0-9]+;/gi, ' ') .replace(/\s+/g, ' ') .trim(); /** German's spelling of the umlauts in URLs (größe → groesse), which WordPress uses on German sites. */ const germanize = (text: string): string => text .replace(/ä/g, 'ae') .replace(/ö/g, 'oe') .replace(/ü/g, 'ue') .replace(/ß/g, 'ss'); /** * The start of a title up to its middle, the word the middle falls in * whole: where Rank Math wants the keyword. */ const firstHalf = (title: string): string => { const middle = Math.floor(title.length / 2); const end = title.slice(middle).search(/\s/); return end < 0 ? title : title.slice(0, middle + end); }; /** A slug as it reads: percent-encoded letters decoded. */ const decodeSlug = (slug: string): string => { try { return decodeURIComponent(slug); } catch { return slug; } }; const matches = (html: string, pattern: RegExp): string[] => Array.from(html.matchAll(pattern), match => match[1] ?? match[0]); /** Space between two pieces of markup: whitespace and the editor's block comments. */ const GAP = '(?:\\s|)*'; /** * Whether a page opens with a key takeaways list: the takeaways section the * agent's articles carry, a heading or a bold line that names it in the * page's language with the list under it, or the shape the agent's rewrite * writes, a heading and a list right after the opening paragraph. */ const hasTakeaways = (content: string): boolean => { // The way it read before the other languages, kept as it was. if ( /(key takeaways|takeaways|tl;dr|in short|at a glance|quick answer|the short answer)[\s\S]{0,400}<(ul|ol)\b/i.test( content ) ) { return true; } if ( /\bclass=["'](?:[^"']*\s)?section-takeaways(?:\s[^"']*)?["'][^>]*>[\s\S]{0,600}?<(ul|ol)\b/i.test( content ) ) { return true; } for (const label of content.matchAll( /<(h[1-6]|p|strong|b)\b[^>]*>([\s\S]*?)<\/\1\s*>/gi )) { const text = stripTags(label[2]); if ( text.length <= TAKEAWAYS_LABEL_CHARS && TAKEAWAYS_HEADINGS.test(text) && /^[\s\S]{0,400}?<(ul|ol)\b/i.test( content.slice((label.index ?? 0) + label[0].length) ) ) { return true; } } const opening = content.search(/<\/p\s*>/i); return ( opening >= 0 && new RegExp( `^<\\/p\\s*>${GAP}]*>[\\s\\S]*?<\\/h\\1\\s*>${GAP} { const anchors = Array.from( content.matchAll(/]*href=["']([^"']+)["'][^>]*>/gi) ); let internal = 0; let external = 0; let externalFollow = 0; for (const anchor of anchors) { const href = anchor[1]; if (/^(#|mailto:|tel:|javascript:)/i.test(href)) { continue; } let host = ''; try { host = new URL(href, `https://${siteHost}`).host.replace(/^www\./, ''); } catch { continue; } if (host === siteHost.replace(/^www\./, '')) { internal += 1; } else { external += 1; if (!/rel=["'][^"']*nofollow/i.test(anchor[0])) { externalFollow += 1; } } } return { internal, external, externalFollow }; }; /** * One test's result. A passed test that earned part of its points is * partial, with its fix to earn the rest, unless it is complete by the * Recomaze rules: then it reads as passed whatever Rank Math's scale says. */ const result = ( id: string, group: AnalysisGroup, max: number, passed: boolean, message: string, fix: string, score = passed ? max : 0, complete = score >= max ): AnalysisResult => ({ id, group, max, score, passed, partial: passed && !complete, message, fix: passed && complete ? '' : fix, }); /** * A Rank Math test the Recomaze rules do not ask for. It weighs nothing and * keeps its note whatever it finds, since passing it is no better than * failing it. */ const rankMathTest = ( id: string, group: AnalysisGroup, passed: boolean, message: string, note: string ): AnalysisResult => ({ ...result(id, group, 0, passed, message, note), fix: note, rankMathOnly: true, }); /** How many subheadings make a post long enough for a table of contents. */ const TOC_MIN_SECTIONS = 4; /** * The tests Rank Math runs on the edited post, weighed by the Recomaze * rules: Rank Math's points, except that keyword density has its floor at * 0.3%, products are complete at 200 words and articles at 600 (on Rank * Math's points), sentiment, power words and external links are shown only * for comparison, the table of contents is advice for long posts and a * number in the title is not asked for (the weighted tests sum to 89 and * the score is their share). Then the AI-answers tests, which weigh nothing * yet but are advice all the same. Tests that need a focus keyword * fail with a hint when none is set; the keyword-used-before check shows * only once known. A secondary keyword runs only the tests it is judged on. */ export const analyze = (input: AnalysisInput): Analysis => { const keyword = input.keyword.trim(); const hasKeyword = keyword !== ''; // Content kept without paragraph tags (the classic Text tab, imported // products) has its paragraphs in blank lines, as WordPress prints it. const content = /]/i.test(input.content) ? input.content : autop(input.content); const text = stripTags(content); const wordList = words(text); const wordCount = wordList.length; const results: AnalysisResult[] = []; // The keyword's words in one sentence, in any order and form. const language = pageLanguage( input.language, `${input.title} ${text}`, input.siteLanguage ); const phrase = keyphrase(keyword, language); const has = (where: string): boolean => hasKeyword && hasKeyphrase(where, phrase); // Basic SEO results.push( result( 'keywordInTitle', 'basic', 36, has(input.title), hasKeyword ? has(input.title) ? 'The focus keyword is in the SEO title.' : 'The focus keyword is missing from the SEO title.' : 'No focus keyword set.', hasKeyword ? 'Put the focus keyword in the SEO title, ideally near the start.' : 'Add a focus keyword: the phrase this page should rank for.' ) ); results.push( result( 'keywordInMetaDescription', 'basic', 2, has(input.description), has(input.description) ? 'The focus keyword is in the meta description.' : 'The focus keyword is missing from the meta description.', 'Mention the focus keyword in the meta description; Google bolds it in the snippet.' ) ); const slug = decodeSlug(input.slug); // The slug's words as one sentence; German sites spell ö as oe. const slugText = slug.replace(/[-_]/g, ' '); const keywordInSlug = has(slugText) || (hasKeyword && hasKeyphrase( slugText, keyphrase(germanize(keyword.toLowerCase()), language) )); results.push( result( 'keywordInPermalink', 'basic', 5, keywordInSlug, keywordInSlug ? 'The focus keyword is in the URL.' : 'The focus keyword is missing from the URL.', input.scope === 'term' ? 'Use the focus keyword in the slug of this archive.' : 'Use the focus keyword in the post slug.' ) ); // Up to 400 words the whole text is the beginning, as in Rank Math: // the sentences of the first tenth, the last one cut where it ends. const openingWords = wordCount > 400 ? Math.floor(wordCount / 10) : wordCount; let opening = ''; let taken = 0; for (const sentence of sentences(content)) { if (taken >= openingWords) { break; } const part = words(sentence).slice(0, openingWords - taken); taken += part.length; opening += `${part.join(' ')}.\n`; } const inFirstTenth = has(opening); results.push( result( 'keywordIn10Percent', 'basic', 3, inFirstTenth, inFirstTenth ? 'The focus keyword appears early in the content.' : 'The focus keyword does not appear early in the content.', 'Use the focus keyword early, in the opening paragraph.' ) ); const inContent = has(content); results.push( result( 'keywordInContent', 'basic', 3, inContent, inContent ? 'The focus keyword appears in the content.' : 'The focus keyword does not appear in the content.', 'Write the focus keyword into the content itself.' ) ); // The Recomaze rule: a product page is complete at 200 words (the agent // writes them at 150–400), an article at 600. The points stay Rank // Math's, which gives an article all of them from 2,500 words; past 600 // Recomaze asks for no more, so the test reads as passed, as Rank Math // shows it. const isProduct = input.postType === 'product'; const lengthScore = isProduct ? wordCount >= PRODUCT_WORDS ? 8 : wordCount >= PRODUCT_WORDS / 2 ? 4 : 0 : wordCount >= 2500 ? 8 : wordCount >= 2000 ? 5 : wordCount >= 1500 ? 4 : wordCount >= 1000 ? 3 : wordCount >= 600 ? 2 : 0; results.push( result( 'lengthContent', 'basic', 8, lengthScore > 0, `The content is ${wordCount} words long.`, isProduct ? `Aim for at least ${PRODUCT_WORDS} words: what it is, who it is for, the specifications, care and a few questions answered.` : 'Aim for at least 600 words; long, useful content ranks for more queries.', lengthScore, isProduct ? lengthScore >= 8 : lengthScore > 0 ) ); // Additional const headings = matches(content, /]*>([\s\S]*?)<\/h[2-6]>/gi).map( stripTags ); const inHeading = headings.some(heading => has(heading)); results.push( result( 'keywordInSubheadings', 'additional', 3, inHeading, inHeading ? 'The focus keyword is in a subheading.' : 'The focus keyword is in none of the subheadings.', 'Use the focus keyword in at least one H2 or H3.' ) ); // Quote-aware: "Men's boots" in double quotes is one alt text. const alts = [ ...Array.from( content.matchAll(/]*\salt=(["'])([\s\S]*?)\1/gi), match => match[2] ), ...(input.imageAlts ?? []), ]; const inAlt = alts.some(alt => has(alt)); results.push( result( 'keywordInImageAlt', 'additional', 2, inAlt, inAlt ? 'An image alt text carries the focus keyword.' : 'No image alt text carries the focus keyword.', 'Describe one image with the focus keyword in its alt text.' ) ); // Mentions of every focus keyword over the words of the text, as Rank // Math and Yoast count it: a three-word keyword is one mention, not three, // and the words of a longer keyword are not counted again for a shorter. const occurrences = hasKeyword ? countKeyphrases( content, [...new Set([keyword, ...(input.keywords ?? [])].map(normalize))] .filter(Boolean) .map(each => keyphrase(each, language)) ) : 0; const density = wordCount > 0 ? (occurrences * 100) / wordCount : 0; // The Recomaze rule: the floor sits at 0.3% (Rank Math: 0.5%), since the // agent names the subject with variants rather than the keyphrase. Above // 2.5% it is stuffing, and earns nothing. let densityScore = 0; if (density >= 1 && density <= 2.5) { densityScore = 6; } else if (density >= 0.3 && density < 1) { densityScore = 3; } results.push( result( 'keywordDensity', 'additional', 6, densityScore > 0, hasKeyword ? `Keyword density is ${density.toFixed(2)}%: ${occurrences} mention${occurrences === 1 ? '' : 's'} in ${wordCount} words.` : 'No focus keyword to count.', !hasKeyword ? 'Add a focus keyword: the phrase this page should rank for.' : density > 2.5 ? 'The keyword is used too often; keep the density between 1% and 2.5%.' : 'Use the focus keyword a few more times; aim for 1% to 2.5% of the words (0.3% is the floor).', densityScore ) ); const slugLength = slug.length; results.push( result( 'lengthPermalink', 'additional', 4, slugLength > 0 && slugLength <= 75, `The URL slug is ${slugLength} characters long.`, 'Shorten the slug to 75 characters or fewer.' ) ); // The Recomaze rule: external links weigh nothing (the agent links out // only where a source backs a claim, so "none" is often right); Rank // Math gives them 4 + 2 points. const links = linkHosts(content, input.siteHost); results.push( rankMathTest( 'linksHasExternals', 'additional', links.external > 0, links.external > 0 ? `${links.external} link${links.external === 1 ? '' : 's'} to other sites.` : 'No links to other sites.', 'Not scored: Recomaze links out only where a source backs a claim, so none is often right.' ) ); results.push( rankMathTest( 'linksNotAllExternals', 'additional', links.externalFollow > 0, links.externalFollow > 0 ? 'At least one external link passes authority (no nofollow).' : 'Every external link is nofollow, or there are none.', 'Not scored: a source worth citing is worth a followed link; Recomaze asks for none otherwise.' ) ); results.push( result( 'linksHasInternal', 'additional', 5, links.internal > 0, links.internal > 0 ? `${links.internal} internal link${links.internal === 1 ? '' : 's'}.` : 'No internal links.', 'Link to other pages of this site so visitors and crawlers move on.' ) ); // Title readability const titleWords = words(input.title); // In the first half of the title, as Rank Math reads "at the beginning": // the keyword as typed starting there, or all its words there. const titleText = normalize(input.title); const keywordAt = hasKeyword ? ` ${titleText} `.indexOf(` ${normalize(keyword)} `) : -1; const startsWithKeyword = (keywordAt >= 0 && keywordAt < Math.floor(titleText.length / 2)) || has(firstHalf(input.title)); if (hasKeyword && typeof input.keywordUsedElsewhere === 'boolean') { results.push( result( 'keywordNotUsed', 'additional', 0, !input.keywordUsedElsewhere, input.keywordUsedElsewhere ? 'Another post already targets this focus keyword.' : 'No other post targets this focus keyword.', 'Two pages on the same keyword compete with each other; give each its own.' ) ); } results.push( result( 'titleStartWithKeyword', 'title', 3, startsWithKeyword, startsWithKeyword ? 'The focus keyword is in the first half of the SEO title.' : 'The focus keyword is not in the first half of the SEO title.', 'Move the focus keyword toward the start of the SEO title.' ) ); const sentiment = titleWords.some( word => POSITIVE_WORDS.includes(word) || NEGATIVE_WORDS.includes(word) ); // The Recomaze rule: sentiment and power words weigh nothing (the agent // writes plain, specific titles without hooks); Rank Math gives a point each. results.push( rankMathTest( 'titleSentiment', 'title', sentiment, sentiment ? 'The title carries a positive or negative word.' : 'The title reads neutral.', 'Not scored: Recomaze prefers a plain, specific title to a word with feeling.' ) ); const power = titleWords.some(word => POWER_WORDS.includes(word)); results.push( rankMathTest( 'titleHasPowerWords', 'title', power, power ? 'The title has a power word.' : 'The title has no power word.', 'Not scored: Recomaze prefers the brand, the model and the type to words like "best" or "proven".' ) ); // Content readability const hasToc = /wp:recomaze\/toc|recomaze-toc|wp-block-table-of-contents|class=["'][^"']*\btoc\b|ez-toc|lwptoc/i.test( content ); // The Recomaze rule: the table of contents weighs nothing and is advice // only for a post long enough to need one; the publishing pipeline adds // the block from four H2 sections up. Subheadings and a FAQ's questions // are not sections of their own. const sections = matches(content, /]*>([\s\S]*?)<\/h2>/gi).filter( heading => stripTags(heading).trim() !== '' ).length; const needsToc = sections >= TOC_MIN_SECTIONS; results.push( result( 'contentHasTOC', 'content', 0, hasToc || !needsToc, hasToc ? 'The content has a table of contents.' : needsToc ? `${sections} sections and no table of contents.` : 'Short enough to go without a table of contents.', 'Add the Table of contents block; readers and Google jump to the sections. Articles the agent publishes get it on their own.' ) ); const paragraphs = matches(content, /]*>([\s\S]*?)<\/p>/gi).map( stripTags ); const longest = paragraphs.reduce( (max, paragraph) => Math.max(max, words(paragraph).length), 0 ); const shortParagraphs = paragraphs.length === 0 ? wordCount <= 120 : longest <= 120; results.push( result( 'contentHasShortParagraphs', 'content', 3, shortParagraphs, shortParagraphs ? 'Every paragraph is short enough.' : paragraphs.length === 0 ? `The text is one block of ${wordCount} words.` : `The longest paragraph has ${longest} words.`, 'Split paragraphs longer than 120 words.' ) ); const media = (content.match(/= 4 ? 6 : media === 3 ? 4 : media === 2 ? 2 : media === 1 ? 1 : 0; results.push( result( 'contentHasAssets', 'content', 6, mediaScore > 0, `${media} image${media === 1 ? '' : 's'} or video${media === 1 ? '' : 's'} in the post.`, 'Add images or a video; pages with media keep readers longer.', mediaScore ) ); // Product listing: the product's own data, as listings read it; advice // that weighs nothing, like the AI answers. if (input.product) { const product = input.product; const pictures = (product.image ? 1 : 0) + product.gallery; const shortWords = words(product.shortDescription).length; const identified = isValidGtin(product.gtin) || (product.mpn.trim() !== '' && product.brand.trim() !== ''); results.push( result( 'productPrice', 'product', 0, product.price.trim() !== '', product.price.trim() !== '' ? 'The product has a price.' : 'The product has no price.', 'Set the regular price, or the prices of the variations: without one Google shows no price and merchant listings leave the product out.' ), result( 'productImages', 'product', 0, product.image && product.gallery >= 2, product.image ? `${pictures} product picture${pictures === 1 ? '' : 's'}.` : 'No product image.', 'Set a product image and add two or more to the gallery: shoppers and Google want the product from several sides.' ), result( 'productShortDescription', 'product', 0, shortWords >= 15, shortWords === 0 ? 'No short description.' : `The short description has ${shortWords} word${shortWords === 1 ? '' : 's'}.`, 'Write two or three sentences on what it is and who it is for: listings, feeds and AI answers quote the short description first.' ), result( 'productIdentifier', 'product', 0, identified, isValidGtin(product.gtin) ? 'The product carries a GTIN.' : identified ? 'An MPN and a brand identify the product.' : product.gtin.trim() !== '' ? `The GTIN ${product.gtin.trim()} is not a valid barcode (its digits or check digit are wrong), and there is no MPN with a brand.` : 'No GTIN, and no MPN with a brand.', 'Add the barcode number (GTIN) under Product, or the MPN and the brand: Google matches the product to searches and offers by them.' ), result( 'productBrand', 'product', 0, product.brand.trim() !== '', product.brand.trim() !== '' ? 'The product has a brand.' : 'No brand.', 'Set the brand under Product, or pick it in the brand taxonomy.' ), result( 'productCategory', 'product', 0, product.categories > 0, product.categories > 0 ? `In ${product.categories} categor${product.categories === 1 ? 'y' : 'ies'}.` : 'Only in the default category.', 'Put the product in a real category: it builds the breadcrumbs and the category in the schema.' ), result( 'productReviews', 'product', 0, product.reviews > 0, product.reviews > 0 ? `${product.reviews} review${product.reviews === 1 ? '' : 's'}: the rating shows in results.` : 'No reviews yet.', 'Ask buyers for a review: star ratings show in results from the first ones.' ) ); } // AI answers: how the Recomaze agent writes, judged without a weight yet. const firstParagraph = paragraphs.find(entry => entry.trim() !== '') ?? ''; const firstWords = words(firstParagraph).length; const opener = FORBIDDEN_OPENERS.find(phrase => normalize(firstParagraph).startsWith(phrase) ); // The opener as the page writes it, accents and all. const openerShown = opener ? firstParagraph .trim() .split(/\s+/) .slice(0, opener.split(' ').length) .join(' ') .replace(/[,.:;]+$/, '') : ''; const answerFirst = firstWords > 0 && firstWords <= 60 && !opener && !/\?\s*$/.test(firstParagraph.trim()); results.push( result( 'aeoAnswerFirst', 'aeo', 0, answerFirst, firstWords === 0 ? 'No opening paragraph.' : answerFirst ? `The opening paragraph answers in ${firstWords} words.` : opener ? `The opening paragraph starts with "${openerShown}".` : firstWords > 60 ? `The opening paragraph runs ${firstWords} words.` : 'The opening paragraph ends on a question.', 'Open with the answer: two declarative sentences, under 60 words, that an AI can quote on their own. No setup, no rhetorical question.' ) ); const headingTexts = headings.map(heading => heading.trim()).filter(Boolean); const generic = headingTexts.filter(heading => GENERIC_HEADINGS.includes(normalize(heading)) ); const pointed = headingTexts.filter( heading => /\?\s*$/.test(heading) || /\b(vs\.?|versus)\b/i.test(heading) || QUESTION_OPENERS.test(heading) ); const pointedHeadings = generic.length === 0 && (headingTexts.length < 3 || pointed.length > 0); results.push( result( 'aeoHeadings', 'aeo', 0, pointedHeadings, generic.length > 0 ? `Generic subheading${generic.length === 1 ? '' : 's'}: ${generic.slice(0, 3).join(', ')}.` : headingTexts.length === 0 ? 'No subheadings.' : `${pointed.length} of ${headingTexts.length} subheadings ask a question, compare or tell what to do.`, 'Write subheadings as the questions people ask ("How long does…?"), comparisons ("X vs Y") or actions; drop Introduction, Overview, Benefits, Tips and Conclusion.' ) ); const takeaways = hasTakeaways(content); results.push( result( 'aeoKeyTakeaways', 'aeo', 0, wordCount < 600 || takeaways, wordCount < 600 ? 'Short post; no takeaways list needed.' : takeaways ? 'The post has a key takeaways list.' : 'No key takeaways list.', 'Add a key takeaways list near the top, under a heading in the page\'s language ("Key takeaways", "Pe scurt"): three to five sentences that stand on their own.' ) ); const faqMarker = /wp:recomaze\/faq|wp:yoast\/faq-block|wp:rank-math\/faq-block|schema\.org\/FAQPage|"@type"\s*:\s*"FAQPage"|class=["'][^"']*\b(section-faq|faq)\b/i.test( content ) || headingTexts.some(heading => FAQ_HEADINGS.test(heading)); // The questions of the FAQ itself: every question heading of the page // counted a post's own "Which size…?" sections as FAQ entries. const questions = matches( faqRegion(content), /<(?:h[2-4]|strong|dt|summary)[^>]*>([\s\S]*?)<\/(?:h[2-4]|strong|dt|summary)>/gi ) .map(stripTags) .filter(entry => /\?\s*$/.test(entry)).length; results.push( result( 'aeoFaq', 'aeo', 0, faqMarker && questions >= 3, faqMarker && questions >= 3 ? `A FAQ with ${questions} questions.` : faqMarker ? `A FAQ with ${questions} question${questions === 1 ? '' : 's'}; answer engines want four to six.` : 'No FAQ.', 'Add a FAQ block with four to six questions people actually ask, each with its own intent and a direct answer; the block prints FAQPage schema.' ) ); // A comparison names its rivals or ranks them: "X vs Y", "Top 10", // "Best skillets for beginners". "Best way to…" and "Which size…" are // how-tos, not comparisons. const compared = [input.title, ...headingTexts].join(' '); const comparison = /\b(vs\.?|versus|compar\w*|alternatives?|top \d+|\d+ best)\b/i.test( compared ) || /\bbest\b(?! way\b| time\b| practices?\b)[^?.]*\b(for|under)\b/i.test( compared ); const hasTable = / lower.includes(` ${phrase}`) ); results.push( result( 'aeoNoBannedPhrases', 'aeo', 0, banned.length === 0, banned.length === 0 ? 'No filler phrases or hedging.' : `Filler or hedging: "${banned.slice(0, 3).join('", "')}"${banned.length > 3 ? ` and ${banned.length - 3} more` : ''}.`, 'Cut the filler ("in today\'s", "game-changer", "seamless"…) and the hedging ("perhaps", "some say"); say it plainly.' ) ); const prices = ( text.match( /(?:[$€£]\s?\d[\d.,]*)|(?:\b\d[\d.,]*\s?(?:€|\$|£|(?:usd|eur|gbp|ron|lei|chf|kr|zł|pln|czk|huf)(?=[\s.,;:!?)]|$)))|(?:\b(?:usd|eur|gbp|ron|chf|pln|czk|huf)\s?\d)|\bstarting (?:at|from) \d/gi ) ?? [] ).length; const stock = /\b(in stock|out of stock|back[- ]?order|last units|limited (?:quantity|stock)|only \d+ left)\b/i.test( text ) || /(?:^| )(in stoc|stoc limitat|fara stoc|stoc epuizat|lipsa (?:din )?stoc|ultimele (?:bucati|produse)|doar \d+ (?:bucati|buc))(?= |$)/.test( normalize(text) ); // A product page is where the price and the stock belong. const onProduct = input.postType === 'product'; results.push( result( 'aeoNoPricesOrStock', 'aeo', 0, onProduct || (prices === 0 && !stock), onProduct ? 'A product page: its price and stock belong here.' : prices === 0 && !stock ? 'No prices or stock status in the text.' : prices > 0 ? `${prices} price${prices === 1 ? '' : 's'} in the text${stock ? ', and stock status' : ''}.` : 'Stock status in the text.', 'Prices and stock change; an evergreen page that states them is wrong most of its life. Keep them on the product page and say "entry-level" or "premium" instead.' ) ); const term = input.scope === 'term'; const tests = input.role === 'secondary' ? term ? SECONDARY_TERM_TESTS : SECONDARY_TESTS : term ? TERM_TESTS : null; const kept = tests ? results.filter(item => tests.includes(item.id)) : results; const total = kept.reduce((sum, item) => sum + item.max, 0); const earned = kept.reduce((sum, item) => sum + item.score, 0); return { score: total > 0 ? Math.round((earned / total) * 100) : 0, results: kept, needsKeyword: !hasKeyword, }; }; /** The three bands Rank Math colours a score with. */ export const scoreTone = (score: number): 'great' | 'good' | 'bad' => score >= 81 ? 'great' : score >= 51 ? 'good' : 'bad';