UNPKG

yoastseo-dep

Version:

Yoast clientside page analysis

1,459 lines (1,291 loc) 50.7 kB
/* eslint-disable max-statements,complexity */ // External dependencies. import { enableFeatures } from "@yoast/feature-flag"; import { __, setLocaleData, sprintf } from "@wordpress/i18n"; import { forEach, has, includes, isEmpty, isNull, isObject, isString, isUndefined, merge, pickBy } from "lodash-es"; import { getLogger } from "loglevel"; // YoastSEO.js dependencies. import SEOAssessor from "../scoring/seoAssessor"; import ContentAssessor from "../scoring/contentAssessor"; import TaxonomyAssessor from "../scoring/taxonomyAssessor"; import Paper from "../values/Paper"; import AssessmentResult from "../values/AssessmentResult"; import RelatedKeywordAssessor from "../scoring/relatedKeywordAssessor"; import InclusiveLanguageAssessor from "../scoring/inclusiveLanguageAssessor"; import { build } from "../parse/build"; import LanguageProcessor from "../parse/language/LanguageProcessor"; // Internal dependencies. import CornerstoneContentAssessor from "../scoring/cornerstone/contentAssessor"; import CornerstoneRelatedKeywordAssessor from "../scoring/cornerstone/relatedKeywordAssessor"; import CornerstoneSEOAssessor from "../scoring/cornerstone/seoAssessor"; import InvalidTypeError from "../errors/invalidType"; import MissingArgumentError from "../errors/missingArgument"; import includesAny from "../helpers/includesAny"; import { configureShortlinker } from "../helpers/shortlinker"; import RelatedKeywordTaxonomyAssessor from "../scoring/relatedKeywordTaxonomyAssessor"; import Scheduler from "./scheduler"; import Transporter from "./transporter"; import wrapTryCatchAroundAction from "./wrapTryCatchAroundAction"; // Tree assessor functionality. import { ReadabilityScoreAggregator, SEOScoreAggregator } from "../parsedPaper/assess/scoreAggregators"; const logger = getLogger( "yoast-analysis-worker" ); logger.setDefaultLevel( "error" ); /** * Analysis Web Worker. * * Worker API: https://developer.mozilla.org/en-US/docs/Web/API/Worker * Webpack loader: https://github.com/webpack-contrib/worker-loader */ export default class AnalysisWebWorker { /* eslint-disable max-statements */ /** * Initializes the AnalysisWebWorker class. * * @param {Object} scope The scope for the messaging. Expected to have the * `onmessage` event and the `postMessage` function. * @param {Researcher} researcher The researcher to use. */ constructor( scope, researcher ) { this._scope = scope; this._configuration = { contentAnalysisActive: true, keywordAnalysisActive: true, inclusiveLanguageAnalysisActive: false, useCornerstone: false, useTaxonomy: false, // The locale used for language-specific configurations in Flesch-reading ease and Sentence length assessments. locale: "en_US", customAnalysisType: "", }; this._scheduler = new Scheduler(); this._paper = null; this._relatedKeywords = {}; this._researcher = researcher; this._contentAssessor = null; this._seoAssessor = null; this._relatedKeywordAssessor = null; this.additionalAssessors = {}; this._inclusiveLanguageOptions = {}; /* * The cached analyses results. * * A single result has the following structure: * {AssessmentResult[]} readability.results An array of assessment results; in serialized format. * {number} readability.score The overall score. * * The results have the following structure. * {Object} readability Content assessor results. * {Object} seo SEO assessor results, per keyword identifier or empty string for the main. * {Object} seo[ "" ] The result of the paper analysis for the main keyword. * {Object} seo[ key ] Same as above, but instead for a related keyword. * {Object} inclusiveLanguage Inclusive language assessor results. */ this._results = { readability: { results: [], score: 0, }, seo: { "": { results: [], score: 0, }, }, inclusiveLanguage: { results: [], score: 0, }, }; this._registeredAssessments = []; this._registeredMessageHandlers = {}; this._registeredParsers = []; // Set up everything for the analysis on the tree. this.setupTreeAnalysis(); this.bindActions(); this.assessRelatedKeywords = this.assessRelatedKeywords.bind( this ); // Bind register functions to this scope. this.registerAssessment = this.registerAssessment.bind( this ); this.registerMessageHandler = this.registerMessageHandler.bind( this ); this.refreshAssessment = this.refreshAssessment.bind( this ); this.setCustomContentAssessorClass = this.setCustomContentAssessorClass.bind( this ); this.setCustomCornerstoneContentAssessorClass = this.setCustomCornerstoneContentAssessorClass.bind( this ); this.setCustomSEOAssessorClass = this.setCustomSEOAssessorClass.bind( this ); this.setCustomCornerstoneSEOAssessorClass = this.setCustomCornerstoneSEOAssessorClass.bind( this ); this.setCustomRelatedKeywordAssessorClass = this.setCustomRelatedKeywordAssessorClass.bind( this ); this.setCustomCornerstoneRelatedKeywordAssessorClass = this.setCustomCornerstoneRelatedKeywordAssessorClass.bind( this ); this.registerAssessor = this.registerAssessor.bind( this ); this.registerResearch = this.registerResearch.bind( this ); this.registerHelper = this.registerHelper.bind( this ); this.registerResearcherConfig = this.registerResearcherConfig.bind( this ); this.setInclusiveLanguageOptions = this.setInclusiveLanguageOptions.bind( this ); // Bind event handlers to this scope. this.handleMessage = this.handleMessage.bind( this ); // Wrap try/catch around actions. this.analyzeRelatedKeywords = wrapTryCatchAroundAction( logger, this.analyze, "An error occurred while running the related keywords analysis." ); /* * Overwrite this.analyze after we use it in this.analyzeRelatedKeywords so that this.analyzeRelatedKeywords * doesn't use the overwritten version. Therefore, this order shouldn't be changed. */ this.analyze = wrapTryCatchAroundAction( logger, this.analyze, "An error occurred while running the analysis." ); this.runResearch = wrapTryCatchAroundAction( logger, this.runResearch, "An error occurred after running the '%%name%%' research." ); } /* eslint-enable max-statements */ /** * Binds actions to this scope. * * @returns {void} */ bindActions() { // Bind actions to this scope. this.analyze = this.analyze.bind( this ); this.analyzeDone = this.analyzeDone.bind( this ); this.analyzeRelatedKeywordsDone = this.analyzeRelatedKeywordsDone.bind( this ); this.loadScript = this.loadScript.bind( this ); this.loadScriptDone = this.loadScriptDone.bind( this ); this.customMessage = this.customMessage.bind( this ); this.customMessageDone = this.customMessageDone.bind( this ); this.clearCache = this.clearCache.bind( this ); this.runResearch = this.runResearch.bind( this ); this.runResearchDone = this.runResearchDone.bind( this ); } /** * Sets a custom content assessor class. * * @param {Class} ContentAssessorClass A content assessor class. * @param {string} customAnalysisType The type of analysis. * @param {Object} customAssessorOptions The options to use. * * @returns {void} */ setCustomContentAssessorClass( ContentAssessorClass, customAnalysisType, customAssessorOptions ) { this._CustomContentAssessorClasses[ customAnalysisType ] = ContentAssessorClass; this._CustomContentAssessorOptions[ customAnalysisType ] = customAssessorOptions; this._contentAssessor = this.createContentAssessor(); } /** * Sets a custom cornerstone content assessor class. * * @param {Class} CornerstoneContentAssessorClass A cornerstone content assessor class. * @param {string} customAnalysisType The type of analysis. * @param {Object} customAssessorOptions The options to use. * * @returns {void} */ setCustomCornerstoneContentAssessorClass( CornerstoneContentAssessorClass, customAnalysisType, customAssessorOptions ) { this._CustomCornerstoneContentAssessorClasses[ customAnalysisType ] = CornerstoneContentAssessorClass; this._CustomCornerstoneContentAssessorOptions[ customAnalysisType ] = customAssessorOptions; this._contentAssessor = this.createContentAssessor(); } /** * Sets a custom SEO assessor class. * * @param {Class} SEOAssessorClass An SEO assessor class. * @param {string} customAnalysisType The type of analysis. * @param {Object} customAssessorOptions The options to use. * * @returns {void} */ setCustomSEOAssessorClass( SEOAssessorClass, customAnalysisType, customAssessorOptions ) { this._CustomSEOAssessorClasses[ customAnalysisType ] = SEOAssessorClass; this._CustomSEOAssessorOptions[ customAnalysisType ] = customAssessorOptions; this._seoAssessor = this.createSEOAssessor(); } /** * Sets a custom cornerstone SEO assessor class. * * @param {Class} CornerstoneSEOAssessorClass A cornerstone SEO assessor class. * @param {string} customAnalysisType The type of analysis. * @param {Object} customAssessorOptions The options to use. * * @returns {void} */ setCustomCornerstoneSEOAssessorClass( CornerstoneSEOAssessorClass, customAnalysisType, customAssessorOptions ) { this._CustomCornerstoneSEOAssessorClasses[ customAnalysisType ] = CornerstoneSEOAssessorClass; this._CustomCornerstoneSEOAssessorOptions[ customAnalysisType ] = customAssessorOptions; this._seoAssessor = this.createSEOAssessor(); } /** * Sets a custom related keyword assessor class. * * @param {Class} RelatedKeywordAssessorClass A related keyword assessor class. * @param {string} customAnalysisType The type of analysis. * @param {Object} customAssessorOptions The options to use. * * @returns {void} */ setCustomRelatedKeywordAssessorClass( RelatedKeywordAssessorClass, customAnalysisType, customAssessorOptions ) { this._CustomRelatedKeywordAssessorClasses[ customAnalysisType ] = RelatedKeywordAssessorClass; this._CustomRelatedKeywordAssessorOptions[ customAnalysisType ] = customAssessorOptions; this._relatedKeywordAssessor = this.createRelatedKeywordsAssessor(); } /** * Sets a custom cornerstone related keyword assessor class. * * @param {Class} CornerstoneRelatedKeywordAssessorClass A cornerstone related keyword assessor class. * @param {string} customAnalysisType The type of analysis. * @param {Object} customAssessorOptions The options to use. * * @returns {void} */ setCustomCornerstoneRelatedKeywordAssessorClass( CornerstoneRelatedKeywordAssessorClass, customAnalysisType, customAssessorOptions ) { this._CustomCornerstoneRelatedKeywordAssessorClasses[ customAnalysisType ] = CornerstoneRelatedKeywordAssessorClass; this._CustomCornerstoneRelatedKeywordAssessorOptions[ customAnalysisType ] = customAssessorOptions; this._relatedKeywordAssessor = this.createRelatedKeywordsAssessor(); } /** * Sets the options to use for the Inclusive language analysis. * * @param {{infoLinks: {}}} options The options to use. * * @returns {void} */ setInclusiveLanguageOptions( options ) { this._inclusiveLanguageOptions = options; } /** * Sets up the web worker for running the tree readability and SEO analysis. * * @returns {void} */ setupTreeAnalysis() { // Researcher /* * Disabled code: * this._treeResearcher = new TreeResearcher(); */ this._treeResearcher = null; // Assessors this._contentTreeAssessor = null; this._seoTreeAssessor = null; this._relatedKeywordTreeAssessor = null; // Custom assessor classes. this._CustomSEOAssessorClasses = {}; this._CustomCornerstoneSEOAssessorClasses = {}; this._CustomContentAssessorClasses = {}; this._CustomCornerstoneContentAssessorClasses = {}; this._CustomRelatedKeywordAssessorClasses = {}; this._CustomCornerstoneRelatedKeywordAssessorClasses = {}; // Custom assessor options. this._CustomSEOAssessorOptions = {}; this._CustomCornerstoneSEOAssessorOptions = {}; this._CustomContentAssessorOptions = {}; this._CustomCornerstoneContentAssessorOptions = {}; this._CustomRelatedKeywordAssessorOptions = {}; this._CustomCornerstoneRelatedKeywordAssessorOptions = {}; // Registered assessments this._registeredTreeAssessments = []; // Score aggregators this._seoScoreAggregator = new SEOScoreAggregator(); this._contentScoreAggregator = new ReadabilityScoreAggregator(); // Tree representation of text to analyze this._tree = null; // Tree builder. this._treeBuilder = null; } /** * Registers this web worker with the scope passed to it's constructor. * * @returns {void} */ register() { this._scope.onmessage = this.handleMessage; this._scope.analysisWorker = this; } /** * Receives the post message and determines the action. * * See: https://developer.mozilla.org/en-US/docs/Web/API/Worker/onmessage * * @param {MessageEvent} event The post message event. * @param {Object} event.data The data object. * @param {string} event.data.type The action type. * @param {string} event.data.id The request id. * @param {string} event.data.payload The payload of the action. * * @returns {void} */ handleMessage( { data: { type, id, payload } } ) { payload = Transporter.parse( payload ); logger.debug( "AnalysisWebWorker incoming:", type, id, payload ); switch ( type ) { case "initialize": this.initialize( id, payload ); this._scheduler.startPolling(); break; case "analyze": this._scheduler.schedule( { id, execute: this.analyze, done: this.analyzeDone, data: payload, type: type, } ); break; case "analyzeRelatedKeywords": this._scheduler.schedule( { id, execute: this.analyzeRelatedKeywords, done: this.analyzeRelatedKeywordsDone, data: payload, type: type, } ); break; case "loadScript": this._scheduler.schedule( { id, execute: this.loadScript, done: this.loadScriptDone, data: payload, type: type, } ); break; case "runResearch": this._scheduler.schedule( { id, execute: this.runResearch, done: this.runResearchDone, data: payload, } ); break; case "customMessage": { const name = payload.name; if ( name && this._registeredMessageHandlers[ name ] ) { this._scheduler.schedule( { id, execute: this.customMessage, done: this.customMessageDone, data: payload, type: type, } ); break; } this.customMessageDone( id, { error: new Error( "No message handler registered for messages with name: " + name ) } ); break; } default: console.warn( "AnalysisWebWorker unrecognized action:", type ); } } /** * Initializes the appropriate content assessor. * * @returns {null|Assessor} The chosen content assessor. */ createContentAssessor() { const { contentAnalysisActive, useCornerstone, customAnalysisType, } = this._configuration; if ( contentAnalysisActive === false ) { return null; } let assessor; if ( useCornerstone === true ) { /* * Use a custom cornerstone content assessor if available, * otherwise set the default cornerstone content assessor. */ assessor = this._CustomCornerstoneContentAssessorClasses[ customAnalysisType ] ? new this._CustomCornerstoneContentAssessorClasses[ customAnalysisType ]( this._researcher, this._CustomCornerstoneContentAssessorOptions[ customAnalysisType ] ) : new CornerstoneContentAssessor( this._researcher ); // Add the readability assessment for cornerstone content to the cornerstone content assessor. this._registeredAssessments.forEach( ( { name, assessment, type } ) => { if ( isUndefined( assessor.getAssessment( name ) ) && type === "cornerstoneReadability" ) { assessor.addAssessment( name, assessment ); } } ); } else { /* * For non-cornerstone content, use a custom SEO assessor if available, * otherwise use the default SEO assessor. */ assessor = this._CustomContentAssessorClasses[ customAnalysisType ] ? new this._CustomContentAssessorClasses[ customAnalysisType ]( this._researcher, this._CustomContentAssessorOptions[ customAnalysisType ] ) : new ContentAssessor( this._researcher ); // Add the readability assessment for regular content to the regular content assessor. this._registeredAssessments.forEach( ( { name, assessment, type } ) => { if ( isUndefined( assessor.getAssessment( name ) ) && type === "readability" ) { assessor.addAssessment( name, assessment ); } } ); } return assessor; } /** * Initializes the appropriate SEO assessor. * * @returns {null|Assessor} The chosen SEO assessor. */ createSEOAssessor() { const { keywordAnalysisActive, useCornerstone, useTaxonomy, customAnalysisType, } = this._configuration; if ( keywordAnalysisActive === false ) { return null; } let assessor; if ( useTaxonomy === true ) { assessor = new TaxonomyAssessor( this._researcher ); } else { // Set cornerstone SEO assessor for cornerstone content. if ( useCornerstone === true ) { // Use a custom cornerstone SEO assessor if available, otherwise set the default cornerstone SEO assessor. assessor = this._CustomCornerstoneSEOAssessorClasses[ customAnalysisType ] ? new this._CustomCornerstoneSEOAssessorClasses[ customAnalysisType ]( this._researcher, this._CustomCornerstoneSEOAssessorOptions[ customAnalysisType ] ) : new CornerstoneSEOAssessor( this._researcher ); } else { /* * For non-cornerstone content, use a custom SEO assessor if available, * otherwise use the default SEO assessor. */ assessor = this._CustomSEOAssessorClasses[ customAnalysisType ] ? new this._CustomSEOAssessorClasses[ customAnalysisType ]( this._researcher, this._CustomSEOAssessorOptions[ customAnalysisType ] ) : new SEOAssessor( this._researcher ); } } this._registeredAssessments.forEach( ( { name, assessment, type } ) => { if ( isUndefined( assessor.getAssessment( name ) ) && type === "seo" ) { assessor.addAssessment( name, assessment ); } } ); return assessor; } /** * Initializes the appropriate inclusive language assessor. * * @returns {null|Assessor} The chosen inclusive language assessor. */ createInclusiveLanguageAssessor() { const { inclusiveLanguageAnalysisActive } = this._configuration; if ( inclusiveLanguageAnalysisActive === false ) { return null; } return new InclusiveLanguageAssessor( this._researcher, this._inclusiveLanguageOptions ); } /** * Initializes the appropriate SEO assessor for related keywords. * * @returns {null|Assessor} The chosen related keywords assessor. */ createRelatedKeywordsAssessor() { const { keywordAnalysisActive, useCornerstone, useTaxonomy, customAnalysisType, } = this._configuration; if ( keywordAnalysisActive === false ) { return null; } let assessor; if ( useTaxonomy === true ) { assessor = new RelatedKeywordTaxonomyAssessor( this._researcher ); } else { // Set cornerstone related keyword assessor for cornerstone content. if ( useCornerstone === true ) { // Use a custom related keyword assessor if available, otherwise use the default related keyword assessor. assessor = this._CustomCornerstoneRelatedKeywordAssessorClasses[ customAnalysisType ] ? new this._CustomCornerstoneRelatedKeywordAssessorClasses[ customAnalysisType ]( this._researcher, this._CustomCornerstoneRelatedKeywordAssessorOptions[ customAnalysisType ] ) : new CornerstoneRelatedKeywordAssessor( this._researcher ); } else { /* * For non-cornerstone content, use a custom related keyword assessor if available, * otherwise use the default related keyword assessor. */ assessor = this._CustomRelatedKeywordAssessorClasses[ customAnalysisType ] ? new this._CustomRelatedKeywordAssessorClasses[ customAnalysisType ]( this._researcher, this._CustomRelatedKeywordAssessorOptions[ customAnalysisType ] ) : new RelatedKeywordAssessor( this._researcher ); } } this._registeredAssessments.forEach( ( { name, assessment, type } ) => { if ( isUndefined( assessor.getAssessment( name ) ) && type === "relatedKeyphrase" ) { assessor.addAssessment( name, assessment ); } } ); return assessor; } /** * Creates an SEO assessor for a tree, based on the given combination of cornerstone, taxonomy and related keyphrase flags. * * @param {Object} assessorConfig The assessor configuration. * @param {boolean} [assessorConfig.relatedKeyphrase] If this assessor is for a related keyphrase, instead of the main one. * @param {boolean} [assessorConfig.taxonomy] If this assessor is for a taxonomy page, instead of a regular page. * @param {boolean} [assessorConfig.cornerstone] If this assessor is for cornerstone content. * * @returns {module:parsedPaper/assess.TreeAssessor} The created tree assessor. */ /* * Disabled code: * createSEOTreeAssessor( assessorConfig ) { * return constructSEOAssessor( this._treeResearcher, assessorConfig ); * } */ /** * Sends a message. * * @param {string} type The message type. * @param {number} id The request id. * @param {Object} [payload] The payload to deliver. * * @returns {void} */ send( type, id, payload = {} ) { logger.debug( "AnalysisWebWorker outgoing:", type, id, payload ); payload = Transporter.serialize( payload ); this._scope.postMessage( { type, id, payload, } ); } /** * Checks which assessors should update giving a configuration. * * @param {Object} configuration The configuration to check. * @param {Assessor} [contentAssessor=null] The content assessor. * @param {Assessor} [seoAssessor=null] The SEO assessor. * @param {Assessor} [inclusiveLanguageAssessor=null] The inclusive language assessor. * * @returns {Object} Containing seo, readability, and inclusiveLanguage with true or false. */ static shouldAssessorsUpdate( configuration, contentAssessor = null, seoAssessor = null, inclusiveLanguageAssessor = null ) { const readability = [ "contentAnalysisActive", "useCornerstone", "locale", "translations", "customAnalysisType", ]; const seo = [ "keywordAnalysisActive", "useCornerstone", "useTaxonomy", "locale", "translations", "researchData", "customAnalysisType", ]; const inclusiveLanguage = [ "inclusiveLanguageAnalysisActive", "locale", "translations", ]; const configurationKeys = Object.keys( configuration ); return { readability: isNull( contentAssessor ) || includesAny( configurationKeys, readability ), seo: isNull( seoAssessor ) || includesAny( configurationKeys, seo ), inclusiveLanguage: isNull( inclusiveLanguageAssessor ) || includesAny( configurationKeys, inclusiveLanguage ), }; } /** * Configures the analysis worker. * * @param {number} id The request id. * @param {Object} configuration The configuration object. * @param {boolean} [configuration.contentAnalysisActive] Whether the content analysis is active. * @param {boolean} [configuration.keywordAnalysisActive] Whether the keyword analysis is active. * @param {boolean} [configuration.useCornerstone] Whether the paper is cornerstone or not. * @param {boolean} [configuration.useTaxonomy] Whether the taxonomy assessor should be used. * @param {string} [configuration.locale] The locale used in the seo assessor. * @param {Object} [configuration.translations] The translation strings. * @param {Object} [configuration.researchData] Extra research data. * @param {Object} [configuration.defaultQueryParams] The default query params for the Shortlinker. * @param {string} [configuration.logLevel] Log level, see: https://github.com/pimterry/loglevel#documentation * @param {string[]} [configuration.enabledFeatures] A list of feature name flags of the experimental features to enable. * * @returns {void} */ initialize( id, configuration ) { const update = AnalysisWebWorker.shouldAssessorsUpdate( configuration, this._contentAssessor, this._seoAssessor, this._inclusiveLanguageAssessor ); if ( has( configuration, "translations" ) ) { Object.values( configuration.translations ).forEach( translation => { // Don't proceed if translation object is null or otherwise falsy. if ( translation ) { const { domain, locale_data: localeData } = translation; setLocaleData( localeData[ domain ], domain ); } } ); } if ( has( configuration, "researchData" ) ) { forEach( configuration.researchData, ( data, research ) => { this._researcher.addResearchData( research, data ); } ); delete configuration.researchData; } if ( has( configuration, "defaultQueryParams" ) ) { configureShortlinker( { params: configuration.defaultQueryParams } ); delete configuration.defaultQueryParams; } if ( has( configuration, "logLevel" ) ) { logger.setLevel( configuration.logLevel, false ); delete configuration.logLevel; } if ( has( configuration, "enabledFeatures" ) ) { // Make feature flags available inside of the web worker. enableFeatures( configuration.enabledFeatures ); delete configuration.enabledFeatures; } this._configuration = merge( this._configuration, configuration ); if ( update.readability ) { this._contentAssessor = this.createContentAssessor(); /* * Disabled code: * this._contentTreeAssessor = constructReadabilityAssessor( this._treeResearcher, configuration.useCornerstone ); */ this._contentTreeAssessor = null; } if ( update.seo ) { this._seoAssessor = this.createSEOAssessor(); this._relatedKeywordAssessor = this.createRelatedKeywordsAssessor(); // Tree assessors /* * Disabled code: * const { useCornerstone, useTaxonomy } = this._configuration; * this._seoTreeAssessor = useTaxonomy * ? this.createSEOTreeAssessor( { taxonomy: true } ) * : this.createSEOTreeAssessor( { cornerstone: useCornerstone } ); * this._relatedKeywordTreeAssessor = this.createSEOTreeAssessor( { * cornerstone: useCornerstone, relatedKeyphrase: true, * } ); */ } if ( update.inclusiveLanguage ) { this._inclusiveLanguageAssessor = this.createInclusiveLanguageAssessor(); } // Reset the paper in order to not use the cached results on analyze. this.clearCache(); this.send( "initialize:done", id ); } /** * Registers a custom assessor. * * @param {string} name The name of the assessor. * @param {Function} AssessorClass The assessor class to instantiate. * @param {Function} shouldUpdate Function that checks whether the assessor should update. * * @returns {void} */ registerAssessor( name, AssessorClass, shouldUpdate ) { const assessor = new AssessorClass( this._researcher ); this.additionalAssessors[ name ] = { assessor, shouldUpdate }; } /** * Register an assessment for a specific plugin. * * @param {string} name The name of the assessment. * @param {function} assessment The function to run as an assessment. * @param {string} pluginName The name of the plugin associated with the assessment. * @param {string} type The type of the assessment. The default type is seo. * * @returns {boolean} Whether registering the assessment was successful. */ registerAssessment( name, assessment, pluginName, type = "seo" ) { const { useCornerstone } = this._configuration; if ( ! isString( name ) ) { throw new InvalidTypeError( "Failed to register assessment for plugin " + pluginName + ". Expected parameter `name` to be a string." ); } if ( ! isObject( assessment ) ) { throw new InvalidTypeError( "Failed to register assessment for plugin " + pluginName + ". Expected parameter `assessment` to be a function." ); } if ( ! isString( pluginName ) ) { throw new InvalidTypeError( "Failed to register assessment for plugin " + pluginName + ". Expected parameter `pluginName` to be a string." ); } // Prefix the name with the pluginName so the test name is always unique. const combinedName = pluginName + "-" + name; if ( this._seoAssessor !== null && type === "seo" ) { this._seoAssessor.addAssessment( combinedName, assessment ); } if ( this._contentAssessor !== null && type === "readability" ) { this._contentAssessor.addAssessment( combinedName, assessment ); } if ( this._contentAssessor !== null && type === "cornerstoneReadability" && useCornerstone ) { this._contentAssessor.addAssessment( combinedName, assessment ); } if ( this._relatedKeywordAssessor !== null && type === "relatedKeyphrase" ) { this._relatedKeywordAssessor.addAssessment( combinedName, assessment ); } this._registeredAssessments.push( { combinedName, assessment, type } ); this.refreshAssessment( name, pluginName ); return true; } /** * Register a message handler for a specific plugin. * * @param {string} name The name of the message handler. * @param {function} handler The function to run as an message handler. * @param {string} pluginName The name of the plugin associated with the message handler. * * @returns {boolean} Whether registering the message handler was successful. */ registerMessageHandler( name, handler, pluginName ) { if ( ! isString( name ) ) { throw new InvalidTypeError( "Failed to register handler for plugin " + pluginName + ". Expected parameter `name` to be a string." ); } if ( ! isObject( handler ) ) { throw new InvalidTypeError( "Failed to register handler for plugin " + pluginName + ". Expected parameter `handler` to be a function." ); } if ( ! isString( pluginName ) ) { throw new InvalidTypeError( "Failed to register handler for plugin " + pluginName + ". Expected parameter `pluginName` to be a string." ); } // Prefix the name with the pluginName so the test name is always unique. name = pluginName + "-" + name; this._registeredMessageHandlers[ name ] = handler; } /** * Refreshes an assessment in the analysis. * * Custom assessments can use this to mark their assessment as needing a * refresh. * * @param {string} name The name of the assessment. * @param {string} pluginName The name of the plugin associated with the assessment. * * @returns {boolean} Whether refreshing the assessment was successful. */ refreshAssessment( name, pluginName ) { if ( ! isString( name ) ) { throw new InvalidTypeError( "Failed to refresh assessment for plugin " + pluginName + ". Expected parameter `name` to be a string." ); } if ( ! isString( pluginName ) ) { throw new InvalidTypeError( "Failed to refresh assessment for plugin " + pluginName + ". Expected parameter `pluginName` to be a string." ); } this.clearCache(); } /** * Register a parser that parses a formatted text * to a structured tree representation that can be further analyzed. * * @param {Object} parser The parser to register. * @param {function(Paper): boolean} parser.isApplicable A method that checks whether this parser is applicable for a paper. * @param {function(Paper): module:parsedPaper/structure.Node } parser.parse A method that parses a paper to a structured tree representation. * * @returns {void} */ registerParser( parser ) { if ( typeof parser.isApplicable !== "function" ) { throw new InvalidTypeError( "Failed to register the custom parser. Expected parameter 'parser' to have a method 'isApplicable'." ); } if ( typeof parser.parse !== "function" ) { throw new InvalidTypeError( "Failed to register the custom parser. Expected parameter 'parser' to have a method 'parse'." ); } this._registeredParsers.push( parser ); } /** * Clears the worker cache to force a new analysis. * * @returns {void} */ clearCache() { this._paper = null; } /** * Changes the locale in the configuration. * * If the locale is different: * - Update the configuration locale. * - Create the content assessor. * * @param {string} locale The locale to set. * * @returns {void} */ setLocale( locale ) { if ( this._configuration.locale === locale ) { return; } this._configuration.locale = locale; this._contentAssessor = this.createContentAssessor(); } /** * Checks if the paper contains changes that are used for readability. * * @param {Paper} paper The paper to check against the cached paper. * * @returns {boolean} True if there are changes detected. */ shouldReadabilityUpdate( paper ) { if ( this._paper === null ) { return true; } if ( this._paper.getText() !== paper.getText() ) { return true; } return this._paper.getLocale() !== paper.getLocale(); } /** * Checks if the paper contains changes that are used for inclusive language analysis. * * @param {Paper} paper The paper to check against the cached paper. * * @returns {boolean} True if there are changes detected. */ shouldInclusiveLanguageUpdate( paper ) { if ( this._paper === null ) { return true; } if ( this._paper.getText() !== paper.getText() ) { return true; } if ( this._paper.getTextTitle() !== paper.getTextTitle() ) { return true; } return this._paper.getLocale() !== paper.getLocale(); } /** * Updates the results for the inclusive language assessor. * * @param {boolean} shouldInclusiveLanguageUpdate Whether the results of the inclusive language assessor should be updated. * @returns {void} */ updateInclusiveLanguageAssessor( shouldInclusiveLanguageUpdate ) { if ( this._configuration.inclusiveLanguageAnalysisActive && this._inclusiveLanguageAssessor && shouldInclusiveLanguageUpdate ) { this._inclusiveLanguageAssessor.assess( this._paper ); this._results.inclusiveLanguage = { results: this._inclusiveLanguageAssessor.results, score: this._inclusiveLanguageAssessor.calculateOverallScore(), }; } } /** * Checks if the related keyword contains changes that are used for seo. * * @param {string} key The identifier of the related keyword. * @param {Object} relatedKeyword The related keyword object. * @param {string} relatedKeyword.keyword The keyword. * @param {string} relatedKeyword.synonyms The synonyms. * * @returns {boolean} True if there are changes detected. */ shouldSeoUpdate( key, { keyword, synonyms } ) { if ( isUndefined( this._relatedKeywords[ key ] ) ) { return true; } if ( this._relatedKeywords[ key ].keyword !== keyword ) { return true; } return this._relatedKeywords[ key ].synonyms !== synonyms; } /** * Checks whether the additional assessor should be updated. * * @param {Paper} paper The paper to check. * @returns {Object} An object containing the information whether each additional assessor needs to be updated. */ shouldAdditionalAssessorsUpdate( paper ) { const shouldCustomAssessorsUpdate = {}; Object.keys( this.additionalAssessors ).forEach( assessorName => { shouldCustomAssessorsUpdate[ assessorName ] = this.additionalAssessors[ assessorName ].shouldUpdate( this._paper, paper ); } ); return shouldCustomAssessorsUpdate; } /** * Updates the results for the additional assessor. * * @param {boolean} shouldCustomAssessorsUpdate Whether the results of the additional assessor should be updated. * @returns {void} */ updateAdditionalAssessors( shouldCustomAssessorsUpdate ) { Object.keys( this.additionalAssessors ).forEach( assessorName => { const { assessor } = this.additionalAssessors[ assessorName ]; if ( ! this._results[ assessorName ] || shouldCustomAssessorsUpdate[ assessorName ] ) { assessor.assess( this._paper ); this._results[ assessorName ] = { results: assessor.results, score: assessor.calculateOverallScore(), }; } } ); } /** * Runs analyses on a paper. * * The paper includes the keyword and synonyms data. However, this is * possibly just one instance of these. From here we are going to split up * this data and keep track of the different sets of keyword-synonyms and * their results. * * @param {number} id The request id. * @param {Object} payload The payload object. * @param {Object} payload.paper The paper to analyze. * @param {Object} [payload.relatedKeywords] The related keywords. * * @returns {Object} The result, may not contain readability or seo. */ async analyze( id, { paper, relatedKeywords = {} } ) { const paperHasChanges = this._paper === null || ! this._paper.equals( paper ); const shouldReadabilityUpdate = this.shouldReadabilityUpdate( paper ); const shouldInclusiveLanguageUpdate = this.shouldInclusiveLanguageUpdate( paper ); const shouldCustomAssessorsUpdate = this.shouldAdditionalAssessorsUpdate( paper ); // Only set the paper and build the tree if the paper has any changes. if ( paperHasChanges ) { this._paper = paper; this._researcher.setPaper( this._paper ); const languageProcessor = new LanguageProcessor( this._researcher ); const shortcodes = this._paper._attributes && this._paper._attributes.shortcodes; this._paper.setTree( build( this._paper, languageProcessor, shortcodes ) ); // Update the configuration locale to the paper locale. this.setLocale( this._paper.getLocale() ); } if ( this._configuration.keywordAnalysisActive && this._seoAssessor ) { // Only assess the focus keyphrase if the paper has any changes. if ( paperHasChanges ) { // Assess the SEO of the content regarding the main keyphrase. this._results.seo[ "" ] = await this.assess( this._paper, this._tree, { oldAssessor: this._seoAssessor, treeAssessor: this._seoTreeAssessor, scoreAggregator: this._seoScoreAggregator, } ); } // Only assess the related keyphrases when they have been given. if ( ! isEmpty( relatedKeywords ) ) { // Get the related keyphrase keys (one for each keyphrase). const requestedRelatedKeywordKeys = Object.keys( relatedKeywords ); // Analyze the SEO for each related keyphrase and wait for the results. const relatedKeyphraseResults = await this.assessRelatedKeywords( paper, this._tree, relatedKeywords ); // Put the related keyphrase results on the SEO results, under the right key. relatedKeyphraseResults.forEach( result => { this._results.seo[ result.key ] = result.results; } ); // Clear the results of unrequested related keyphrases, but only if there are requested related keyphrases. if ( requestedRelatedKeywordKeys.length > 1 ) { this._results.seo = pickBy( this._results.seo, ( relatedKeyword, key ) => includes( requestedRelatedKeywordKeys, key ) || key === "" ); } } } if ( this._configuration.contentAnalysisActive && this._contentAssessor && shouldReadabilityUpdate ) { const analysisCombination = { oldAssessor: this._contentAssessor, treeAssessor: this._contentTreeAssessor, scoreAggregator: this._contentScoreAggregator, }; // Set the locale (we are more lenient for languages that have full analysis support). analysisCombination.scoreAggregator.setLocale( this._configuration.locale ); this._results.readability = await this.assess( this._paper, this._tree, analysisCombination ); } this.updateInclusiveLanguageAssessor( shouldInclusiveLanguageUpdate ); this.updateAdditionalAssessors( shouldCustomAssessorsUpdate ); return this._results; } /** * Assesses a given paper and tree combination * using an original Assessor (that works on a string representation of the text) * and a new Tree Assessor (that works on a tree representation). * * The results of both analyses are combined using the given score aggregator. * * @param {Paper} paper The paper to analyze. * @param {module:parsedPaper/structure.Node} tree The tree to analyze. * * @param {Object} analysisCombination Which assessors and score aggregator to use. * @param {Assessor} analysisCombination.oldAssessor The original assessor. * @param {module:parsedPaper/assess.TreeAssessor} analysisCombination.treeAssessor The new assessor. * @param {module:parsedPaper/assess.ScoreAggregator} analysisCombination.scoreAggregator The score aggregator to use. * * @returns {Promise<{score: number, results: AssessmentResult[]}>} The analysis results. */ async assess( paper, tree, analysisCombination ) { // Disabled code: The variable `treeAssessor` is removed from here. const { oldAssessor, scoreAggregator } = analysisCombination; /* * Assess the paper and the tree * using the original assessor and the tree assessor. */ oldAssessor.assess( paper ); const oldAssessmentResults = oldAssessor.results; const treeAssessmentResults = []; /* * Disable code: * // Only assess tree if it has been built. * if ( tree ) { * const treeAssessorResult = await treeAssessor.assess( paper, tree ); * treeAssessmentResults = treeAssessorResult.results; * } else { * // Cannot assess the tree, generate errors on the assessments that use the tree assessor. * const treeAssessments = treeAssessor.getAssessments(); * treeAssessmentResults = treeAssessments.map( assessment => this.generateAssessmentError( assessment ) ); * } */ // Combine the results of the tree assessor and old assessor. const results = [ ...treeAssessmentResults, ...oldAssessmentResults ]; // Aggregate the results. const score = scoreAggregator.aggregate( results ); return { results: results, score: score, }; } /** * Generates an error message ("grey bullet") for the given assessment. * * @param {module:parsedPaper/assess.Assessment} assessment The assessment to generate an error message for. * * @returns {AssessmentResult} The generated assessment result. */ generateAssessmentError( assessment ) { const result = new AssessmentResult(); result.setScore( -1 ); result.setText( sprintf( /* translators: %1$s expands to the name of the assessment. */ __( "An error occurred in the '%1$s' assessment", "wordpress-seo" ), assessment.name ) ); return result; } /** * Assesses the SEO of a paper and tree combination on the given related keyphrases and their synonyms. * * The old assessor as well as the new tree assessor are used and their results are combined. * * @param {Paper} paper The paper to analyze. * @param {module:parsedPaper/structure} tree The tree to analyze. * @param {Object} relatedKeywords The related keyphrases to use in the analysis. * * @returns {Promise<[{results: {score: number, results: AssessmentResult[]}, key: string}]>} The results, one for each keyphrase. */ async assessRelatedKeywords( paper, tree, relatedKeywords ) { const keywordKeys = Object.keys( relatedKeywords ); return await Promise.all( keywordKeys.map( key => { this._relatedKeywords[ key ] = relatedKeywords[ key ]; const relatedPaper = Paper.parse( { ...paper.serialize(), keyword: this._relatedKeywords[ key ].keyword, synonyms: this._relatedKeywords[ key ].synonyms, } ); // Which combination of (tree) assessors and score aggregator to use. const analysisCombination = { oldAssessor: this._relatedKeywordAssessor, treeAssessor: this._relatedKeywordTreeAssessor, scoreAggregator: this._seoScoreAggregator, }; // We need to remember the key, since the SEO results are stored in an object, not an array. return this.assess( relatedPaper, tree, analysisCombination ).then( results => ( { key: key, results: results } ) ); } ) ); } /** * Loads a new script from an external source. * * @param {number} id The request id. * @param {string} url The url of the script to load; * * @returns {Object} An object containing whether or not the url was loaded, the url and possibly an error message. */ loadScript( id, { url } ) { if ( isUndefined( url ) ) { return { loaded: false, url, message: "Load Script was called without an URL." }; } try { this._scope.importScripts( url ); } catch ( error ) { return { loaded: false, url, message: error.message }; } return { loaded: true, url }; } /** * Sends the load script result back. * * @param {number} id The request id. * @param {Object} result The result. * * @returns {void} */ loadScriptDone( id, result ) { if ( ! result.loaded ) { this.send( "loadScript:failed", id, result ); return; } this.send( "loadScript:done", id, result ); } /** * Sends the analyze result back. * * @param {number} id The request id. * @param {Object} result The result. * * @returns {void} */ analyzeDone( id, result ) { if ( result.error ) { this.send( "analyze:failed", id, result ); return; } this.send( "analyze:done", id, result ); } /** * Sends the analyze related keywords result back. * * @param {number} id The request id. * @param {Object} result The result. * * @returns {void} */ analyzeRelatedKeywordsDone( id, result ) { if ( result.error ) { this.send( "analyzeRelatedKeywords:failed", id, result ); return; } this.send( "analyzeRelatedKeywords:done", id, result ); } /** * Handle a custom message using the registered handler. * * @param {number} id The request id. * @param {string} name The name of the message. * @param {Object} data The data of the message. * * @returns {Object} An object containing either success and data or an error. */ customMessage( id, { name, data } ) { try { return { success: true, data: this._registeredMessageHandlers[ name ]( data ), }; } catch ( error ) { return { error }; } } /** * Send the result of a custom message back. * * @param {number} id The request id. * @param {Object} result The result. * * @returns {void} */ customMessageDone( id, result ) { if ( result.success ) { this.send( "customMessage:done", id, result.data ); return; } this.send( "customMessage:failed", result.error ); } /** * Registers custom research to the researcher. * * @param {string} name The name of the research. * @param {function} research The research function to add. * * @returns {void} */ registerResearch( name, research ) { if ( ! isString( name ) ) { throw new InvalidTypeError( "Failed to register the custom research. Expected parameter `name` to be a string." ); } if ( ! isObject( research ) ) { throw new InvalidTypeError( "Failed to register the custom research. Expected parameter `research` to be a function." ); } const researcher = this._researcher; if ( ! researcher.hasResearch( name ) ) { researcher.addResearch( name, research ); } } /** * Runs the specified research in the worker. Optionally pass a paper. * * @param {number} id The request id. * @param {string} name The name of the research to run. * @param {Paper} [paper] The paper to run the research on if it shouldn't * be run on the latest paper. * * @returns {Object} The result of the research. */ runResearch( id, { name, paper = null } ) { // Save morphology data if it is available in the current researcher. const morphologyData = this._researcher.getData( "morphology" ); const researcher = this._researcher; // When a specific paper is passed we create a temporary new researcher. if ( paper !== null ) { researcher.setPaper( paper ); researcher.addResearchData( "morphology", morphologyData ); // Build and set the tree if it's not been set before. if ( paper.getTree() === null ) { const languageProcessor = new LanguageProcessor( researcher ); const shortcodes = paper._attributes && paper._attributes.shortcodes; paper.setTree( build( paper, languageProcessor, shortcodes ) ); } } return researcher.getResearch( name ); } /** * Send the result of a custom message back. * * @param {number} id The request id. * @param {Object} result The result. * * @returns {void} */ runResearchDone( id, result ) { if ( result.error ) { this.send( "runResearch:failed", id, result ); return; } this.send( "runResearch:done", id, result ); } /** * Registers a custom helper to the researcher. * * @param {string} name The name of the helper. * @param {function} helper The helper function to add. * * @returns {void} */ registerHelper( name, helper ) { if ( ! isString( name ) ) { throw new InvalidTypeError( "Failed to register the custom helper. Expected parameter `name` to be a string." ); } if ( ! isObject( helper ) ) { throw new InvalidTypeError( "Failed to register the custom helper. Expected parameter `helper` to be a function." ); } const researcher = this._researcher; if ( ! researcher.hasHelper( name ) ) { researcher.addHelper( name, helper ); } } /** * Registers a configuration to the researcher. * * @param {string} na