@rankcli/agent-runtime 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +242 -0
- package/dist/analyzer-2CSWIQGD.mjs +6 -0
- package/dist/chunk-YNZYHEYM.mjs +774 -0
- package/dist/index.d.mts +4012 -0
- package/dist/index.d.ts +4012 -0
- package/dist/index.js +29672 -0
- package/dist/index.mjs +28602 -0
- package/package.json +53 -0
- package/scripts/build-deno.ts +134 -0
- package/src/audit/ai/analyzer.ts +347 -0
- package/src/audit/ai/index.ts +29 -0
- package/src/audit/ai/prompts/content-analysis.ts +271 -0
- package/src/audit/ai/types.ts +179 -0
- package/src/audit/checks/additional-checks.ts +439 -0
- package/src/audit/checks/ai-citation-worthiness.ts +399 -0
- package/src/audit/checks/ai-content-structure.ts +325 -0
- package/src/audit/checks/ai-readiness.ts +339 -0
- package/src/audit/checks/anchor-text.ts +179 -0
- package/src/audit/checks/answer-conciseness.ts +322 -0
- package/src/audit/checks/asset-minification.ts +270 -0
- package/src/audit/checks/bing-optimization.ts +206 -0
- package/src/audit/checks/brand-mention-optimization.ts +349 -0
- package/src/audit/checks/caching-headers.ts +305 -0
- package/src/audit/checks/canonical-advanced.ts +150 -0
- package/src/audit/checks/canonical-domain.ts +196 -0
- package/src/audit/checks/citation-quality.ts +358 -0
- package/src/audit/checks/client-rendering.ts +542 -0
- package/src/audit/checks/color-contrast.ts +342 -0
- package/src/audit/checks/content-freshness.ts +170 -0
- package/src/audit/checks/content-science.ts +589 -0
- package/src/audit/checks/conversion-elements.ts +526 -0
- package/src/audit/checks/crawlability.ts +220 -0
- package/src/audit/checks/directory-listing.ts +172 -0
- package/src/audit/checks/dom-analysis.ts +191 -0
- package/src/audit/checks/dom-size.ts +246 -0
- package/src/audit/checks/duplicate-content.ts +194 -0
- package/src/audit/checks/eeat-signals.ts +990 -0
- package/src/audit/checks/entity-seo.ts +396 -0
- package/src/audit/checks/featured-snippet.ts +473 -0
- package/src/audit/checks/freshness-signals.ts +443 -0
- package/src/audit/checks/funnel-intent.ts +463 -0
- package/src/audit/checks/hreflang.ts +174 -0
- package/src/audit/checks/html-compliance.ts +302 -0
- package/src/audit/checks/image-dimensions.ts +167 -0
- package/src/audit/checks/images.ts +160 -0
- package/src/audit/checks/indexnow.ts +275 -0
- package/src/audit/checks/interactive-tools.ts +475 -0
- package/src/audit/checks/internal-link-graph.ts +436 -0
- package/src/audit/checks/keyword-analysis.ts +239 -0
- package/src/audit/checks/keyword-cannibalization.ts +385 -0
- package/src/audit/checks/keyword-placement.ts +471 -0
- package/src/audit/checks/links.ts +203 -0
- package/src/audit/checks/llms-txt.ts +224 -0
- package/src/audit/checks/local-seo.ts +296 -0
- package/src/audit/checks/mobile.ts +167 -0
- package/src/audit/checks/modern-images.ts +226 -0
- package/src/audit/checks/navboost-signals.ts +395 -0
- package/src/audit/checks/on-page.ts +209 -0
- package/src/audit/checks/page-resources.ts +285 -0
- package/src/audit/checks/pagination.ts +180 -0
- package/src/audit/checks/performance.ts +153 -0
- package/src/audit/checks/platform-presence.ts +580 -0
- package/src/audit/checks/redirect-analysis.ts +153 -0
- package/src/audit/checks/redirect-chain.ts +389 -0
- package/src/audit/checks/resource-hints.ts +420 -0
- package/src/audit/checks/responsive-css.ts +247 -0
- package/src/audit/checks/responsive-images.ts +396 -0
- package/src/audit/checks/review-ecosystem.ts +415 -0
- package/src/audit/checks/robots-validation.ts +373 -0
- package/src/audit/checks/security-headers.ts +172 -0
- package/src/audit/checks/security.ts +144 -0
- package/src/audit/checks/serp-preview.ts +251 -0
- package/src/audit/checks/site-maturity.ts +444 -0
- package/src/audit/checks/social-meta.test.ts +275 -0
- package/src/audit/checks/social-meta.ts +134 -0
- package/src/audit/checks/soft-404.ts +151 -0
- package/src/audit/checks/structured-data.ts +238 -0
- package/src/audit/checks/tech-detection.ts +496 -0
- package/src/audit/checks/topical-clusters.ts +435 -0
- package/src/audit/checks/tracker-bloat.ts +462 -0
- package/src/audit/checks/tracking-verification.test.ts +371 -0
- package/src/audit/checks/tracking-verification.ts +636 -0
- package/src/audit/checks/url-safety.ts +682 -0
- package/src/audit/deno-entry.ts +66 -0
- package/src/audit/discovery/index.ts +15 -0
- package/src/audit/discovery/link-crawler.ts +232 -0
- package/src/audit/discovery/repo-routes.ts +347 -0
- package/src/audit/engine.ts +620 -0
- package/src/audit/fixes/index.ts +209 -0
- package/src/audit/fixes/social-meta-fixes.test.ts +329 -0
- package/src/audit/fixes/social-meta-fixes.ts +463 -0
- package/src/audit/index.ts +74 -0
- package/src/audit/runner.test.ts +299 -0
- package/src/audit/runner.ts +130 -0
- package/src/audit/types.ts +1953 -0
- package/src/content/featured-snippet.ts +367 -0
- package/src/content/generator.test.ts +534 -0
- package/src/content/generator.ts +501 -0
- package/src/content/headline.ts +317 -0
- package/src/content/index.ts +62 -0
- package/src/content/intent.ts +258 -0
- package/src/content/keyword-density.ts +349 -0
- package/src/content/readability.ts +262 -0
- package/src/executor.ts +336 -0
- package/src/fixer.ts +416 -0
- package/src/frameworks/detector.test.ts +248 -0
- package/src/frameworks/detector.ts +371 -0
- package/src/frameworks/index.ts +68 -0
- package/src/frameworks/recipes/angular.yaml +171 -0
- package/src/frameworks/recipes/astro.yaml +206 -0
- package/src/frameworks/recipes/django.yaml +180 -0
- package/src/frameworks/recipes/laravel.yaml +137 -0
- package/src/frameworks/recipes/nextjs.yaml +268 -0
- package/src/frameworks/recipes/nuxt.yaml +175 -0
- package/src/frameworks/recipes/rails.yaml +188 -0
- package/src/frameworks/recipes/react.yaml +202 -0
- package/src/frameworks/recipes/sveltekit.yaml +154 -0
- package/src/frameworks/recipes/vue.yaml +137 -0
- package/src/frameworks/recipes/wordpress.yaml +209 -0
- package/src/frameworks/suggestion-engine.ts +320 -0
- package/src/geo/geo-content.test.ts +305 -0
- package/src/geo/geo-content.ts +266 -0
- package/src/geo/geo-history.test.ts +473 -0
- package/src/geo/geo-history.ts +433 -0
- package/src/geo/geo-tracker.test.ts +359 -0
- package/src/geo/geo-tracker.ts +411 -0
- package/src/geo/index.ts +10 -0
- package/src/git/commit-helper.test.ts +261 -0
- package/src/git/commit-helper.ts +329 -0
- package/src/git/index.ts +12 -0
- package/src/git/pr-helper.test.ts +284 -0
- package/src/git/pr-helper.ts +307 -0
- package/src/index.ts +66 -0
- package/src/keywords/ai-keyword-engine.ts +1062 -0
- package/src/keywords/ai-summarizer.ts +387 -0
- package/src/keywords/ci-mode.ts +555 -0
- package/src/keywords/engine.ts +359 -0
- package/src/keywords/index.ts +151 -0
- package/src/keywords/llm-judge.ts +357 -0
- package/src/keywords/nlp-analysis.ts +706 -0
- package/src/keywords/prioritizer.ts +295 -0
- package/src/keywords/site-crawler.ts +342 -0
- package/src/keywords/sources/autocomplete.ts +139 -0
- package/src/keywords/sources/competitive-search.ts +450 -0
- package/src/keywords/sources/competitor-analysis.ts +374 -0
- package/src/keywords/sources/dataforseo.ts +206 -0
- package/src/keywords/sources/free-sources.ts +294 -0
- package/src/keywords/sources/gsc.ts +123 -0
- package/src/keywords/topic-grouping.ts +327 -0
- package/src/keywords/types.ts +144 -0
- package/src/keywords/wizard.ts +457 -0
- package/src/loader.ts +40 -0
- package/src/reports/index.ts +7 -0
- package/src/reports/report-generator.test.ts +293 -0
- package/src/reports/report-generator.ts +713 -0
- package/src/scheduler/alerts.test.ts +458 -0
- package/src/scheduler/alerts.ts +328 -0
- package/src/scheduler/index.ts +8 -0
- package/src/scheduler/scheduled-audit.test.ts +377 -0
- package/src/scheduler/scheduled-audit.ts +149 -0
- package/src/test/integration-test.ts +325 -0
- package/src/tools/analyzer.ts +373 -0
- package/src/tools/crawl.ts +293 -0
- package/src/tools/files.ts +301 -0
- package/src/tools/h1-fixer.ts +249 -0
- package/src/tools/index.ts +67 -0
- package/src/tracking/github-action.ts +326 -0
- package/src/tracking/google-analytics.ts +265 -0
- package/src/tracking/index.ts +45 -0
- package/src/tracking/report-generator.ts +386 -0
- package/src/tracking/search-console.ts +335 -0
- package/src/types.ts +134 -0
- package/src/utils/http.ts +302 -0
- package/src/wasm-adapter.ts +297 -0
- package/src/wasm-entry.ts +14 -0
- package/tsconfig.json +17 -0
- package/tsup.wasm.config.ts +26 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,990 @@
|
|
|
1
|
+
// E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness) Signals Detection
|
|
2
|
+
// Based on Google's Quality Rater Guidelines and advanced SEO research
|
|
3
|
+
|
|
4
|
+
import * as cheerio from 'cheerio';
|
|
5
|
+
import type { AuditIssue } from '../types.js';
|
|
6
|
+
|
|
7
|
+
export interface AuthorInfo {
|
|
8
|
+
name: string | null;
|
|
9
|
+
bio: string | null;
|
|
10
|
+
credentials: string[];
|
|
11
|
+
socialLinks: string[];
|
|
12
|
+
hasSchema: boolean;
|
|
13
|
+
photoPresent: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OrganizationInfo {
|
|
17
|
+
name: string | null;
|
|
18
|
+
type: string | null;
|
|
19
|
+
hasSchema: boolean;
|
|
20
|
+
hasContactInfo: boolean;
|
|
21
|
+
hasSocialProfiles: boolean;
|
|
22
|
+
trustBadges: string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ReviewInfo {
|
|
26
|
+
hasReviewer: boolean;
|
|
27
|
+
reviewerName: string | null;
|
|
28
|
+
reviewerCredentials: string[];
|
|
29
|
+
reviewDate: string | null;
|
|
30
|
+
nextReviewDate: string | null;
|
|
31
|
+
reviewType: 'medical' | 'expert' | 'fact-check' | 'editorial' | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TrustBadgeInfo {
|
|
35
|
+
pressFeatures: string[]; // "As seen in Forbes", "Featured in..."
|
|
36
|
+
certifications: string[]; // "Clinically studied", "FDA approved"
|
|
37
|
+
awards: string[];
|
|
38
|
+
partnerships: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface EEATData {
|
|
42
|
+
author: AuthorInfo;
|
|
43
|
+
organization: OrganizationInfo;
|
|
44
|
+
reviewer: ReviewInfo;
|
|
45
|
+
trustBadges: TrustBadgeInfo;
|
|
46
|
+
experienceSignals: string[];
|
|
47
|
+
expertiseSignals: string[];
|
|
48
|
+
authoritySignals: string[];
|
|
49
|
+
trustSignals: string[];
|
|
50
|
+
citationCount: number;
|
|
51
|
+
authoritativeSources: string[];
|
|
52
|
+
contentDate: {
|
|
53
|
+
published: string | null;
|
|
54
|
+
modified: string | null;
|
|
55
|
+
};
|
|
56
|
+
hasVideoContent: boolean;
|
|
57
|
+
videoExperienceSignals: string[];
|
|
58
|
+
isYMYL: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Credential patterns that indicate expertise
|
|
62
|
+
const CREDENTIAL_PATTERNS = [
|
|
63
|
+
/\b(MD|M\.D\.|Dr\.|Doctor)\b/i,
|
|
64
|
+
/\b(PhD|Ph\.D\.|Doctorate)\b/i,
|
|
65
|
+
/\b(JD|J\.D\.|Attorney|Lawyer|Esq\.)\b/i,
|
|
66
|
+
/\b(CPA|Certified Public Accountant)\b/i,
|
|
67
|
+
/\b(CFP|Certified Financial Planner)\b/i,
|
|
68
|
+
/\b(RN|Registered Nurse)\b/i,
|
|
69
|
+
/\b(PE|Professional Engineer)\b/i,
|
|
70
|
+
/\b(MBA|Master of Business)\b/i,
|
|
71
|
+
/\b(Professor|Prof\.)\b/i,
|
|
72
|
+
/\b(Licensed|Certified|Accredited)\b/i,
|
|
73
|
+
/\b(\d+\+?\s*years?\s*(of\s+)?experience)\b/i,
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// Trust badge patterns
|
|
77
|
+
const TRUST_BADGE_PATTERNS = [
|
|
78
|
+
/BBB\s*(Accredited|A\+|Rating)/i,
|
|
79
|
+
/Norton\s*Secure/i,
|
|
80
|
+
/McAfee\s*Secure/i,
|
|
81
|
+
/TRUSTe/i,
|
|
82
|
+
/SSL\s*(Secure|Certified)/i,
|
|
83
|
+
/Verified\s*(by|Business)/i,
|
|
84
|
+
/Award[\-\s]*(Winning|Winner)/i,
|
|
85
|
+
/ISO\s*\d+/i,
|
|
86
|
+
/HIPAA\s*Compliant/i,
|
|
87
|
+
/GDPR\s*Compliant/i,
|
|
88
|
+
/PCI[\-\s]*(DSS|Compliant)/i,
|
|
89
|
+
/SOC\s*2/i,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// Authoritative source domains
|
|
93
|
+
const AUTHORITATIVE_DOMAINS = [
|
|
94
|
+
'wikipedia.org',
|
|
95
|
+
'gov',
|
|
96
|
+
'edu',
|
|
97
|
+
'nytimes.com',
|
|
98
|
+
'wsj.com',
|
|
99
|
+
'bbc.com',
|
|
100
|
+
'reuters.com',
|
|
101
|
+
'nature.com',
|
|
102
|
+
'science.org',
|
|
103
|
+
'pubmed.ncbi.nlm.nih.gov',
|
|
104
|
+
'who.int',
|
|
105
|
+
'cdc.gov',
|
|
106
|
+
'nih.gov',
|
|
107
|
+
'harvard.edu',
|
|
108
|
+
'stanford.edu',
|
|
109
|
+
'mit.edu',
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
// YMYL (Your Money Your Life) topic indicators
|
|
113
|
+
const YMYL_PATTERNS = [
|
|
114
|
+
/\b(medical|health|disease|treatment|symptom|diagnosis|medication|drug)\b/i,
|
|
115
|
+
/\b(financial|investment|tax|insurance|mortgage|credit|loan|retirement)\b/i,
|
|
116
|
+
/\b(legal|attorney|lawyer|lawsuit|court|divorce|custody)\b/i,
|
|
117
|
+
/\b(safety|emergency|danger|warning|crisis)\b/i,
|
|
118
|
+
/\b(government|voting|election|law|regulation)\b/i,
|
|
119
|
+
/\b(religion|ethnic|race|gender|sexuality)\b/i,
|
|
120
|
+
/\b(buy|purchase|price|cost|shop)\b/i,
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Extract author information from the page
|
|
125
|
+
*/
|
|
126
|
+
export function extractAuthorInfo(html: string): AuthorInfo {
|
|
127
|
+
const $ = cheerio.load(html);
|
|
128
|
+
const info: AuthorInfo = {
|
|
129
|
+
name: null,
|
|
130
|
+
bio: null,
|
|
131
|
+
credentials: [],
|
|
132
|
+
socialLinks: [],
|
|
133
|
+
hasSchema: false,
|
|
134
|
+
photoPresent: false,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// Check for author schema
|
|
138
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
139
|
+
try {
|
|
140
|
+
const data = JSON.parse($(el).html() || '');
|
|
141
|
+
const checkAuthor = (obj: Record<string, unknown>) => {
|
|
142
|
+
if (obj['@type'] === 'Person' || obj.author) {
|
|
143
|
+
info.hasSchema = true;
|
|
144
|
+
const author = obj.author || obj;
|
|
145
|
+
if (typeof author === 'object' && author !== null) {
|
|
146
|
+
info.name = (author as Record<string, string>).name || info.name;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
if (Array.isArray(data)) {
|
|
152
|
+
data.forEach(checkAuthor);
|
|
153
|
+
} else {
|
|
154
|
+
checkAuthor(data);
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Invalid JSON
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Check for author meta tags
|
|
162
|
+
const authorMeta = $('meta[name="author"]').attr('content');
|
|
163
|
+
if (authorMeta) {
|
|
164
|
+
info.name = info.name || authorMeta;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Check for author link
|
|
168
|
+
const authorLink = $('link[rel="author"]').attr('href');
|
|
169
|
+
if (authorLink) {
|
|
170
|
+
info.socialLinks.push(authorLink);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Look for author byline patterns
|
|
174
|
+
const bylineSelectors = [
|
|
175
|
+
'.author',
|
|
176
|
+
'.byline',
|
|
177
|
+
'[rel="author"]',
|
|
178
|
+
'.post-author',
|
|
179
|
+
'.article-author',
|
|
180
|
+
'.author-name',
|
|
181
|
+
'.written-by',
|
|
182
|
+
'[itemprop="author"]',
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
for (const selector of bylineSelectors) {
|
|
186
|
+
const el = $(selector).first();
|
|
187
|
+
if (el.length) {
|
|
188
|
+
const text = el.text().trim();
|
|
189
|
+
if (text && text.length < 100) {
|
|
190
|
+
info.name = info.name || text.replace(/^(by|written by|author:)\s*/i, '');
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Look for author bio
|
|
197
|
+
const bioSelectors = ['.author-bio', '.author-description', '.about-author', '[itemprop="description"]'];
|
|
198
|
+
for (const selector of bioSelectors) {
|
|
199
|
+
const el = $(selector).first();
|
|
200
|
+
if (el.length) {
|
|
201
|
+
info.bio = el.text().trim().substring(0, 500);
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Check for author photo
|
|
207
|
+
info.photoPresent =
|
|
208
|
+
$('img.author-photo, img.author-image, .author img, [itemprop="image"]').length > 0 ||
|
|
209
|
+
$('img[alt*="author"], img[alt*="Author"]').length > 0;
|
|
210
|
+
|
|
211
|
+
// Extract credentials from author name/bio
|
|
212
|
+
const textToCheck = `${info.name || ''} ${info.bio || ''}`;
|
|
213
|
+
for (const pattern of CREDENTIAL_PATTERNS) {
|
|
214
|
+
const match = textToCheck.match(pattern);
|
|
215
|
+
if (match) {
|
|
216
|
+
info.credentials.push(match[0]);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check for social profile links near author info
|
|
221
|
+
$('.author a, .byline a, .author-social a').each((_, el) => {
|
|
222
|
+
const href = $(el).attr('href');
|
|
223
|
+
if (
|
|
224
|
+
href &&
|
|
225
|
+
(href.includes('linkedin.com') ||
|
|
226
|
+
href.includes('twitter.com') ||
|
|
227
|
+
href.includes('github.com') ||
|
|
228
|
+
href.includes('medium.com'))
|
|
229
|
+
) {
|
|
230
|
+
info.socialLinks.push(href);
|
|
231
|
+
}
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
return info;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Extract organization information
|
|
239
|
+
*/
|
|
240
|
+
export function extractOrganizationInfo(html: string): OrganizationInfo {
|
|
241
|
+
const $ = cheerio.load(html);
|
|
242
|
+
const info: OrganizationInfo = {
|
|
243
|
+
name: null,
|
|
244
|
+
type: null,
|
|
245
|
+
hasSchema: false,
|
|
246
|
+
hasContactInfo: false,
|
|
247
|
+
hasSocialProfiles: false,
|
|
248
|
+
trustBadges: [],
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Check for organization schema
|
|
252
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
253
|
+
try {
|
|
254
|
+
const data = JSON.parse($(el).html() || '');
|
|
255
|
+
const checkOrg = (obj: Record<string, unknown>) => {
|
|
256
|
+
const type = obj['@type'] as string;
|
|
257
|
+
if (type === 'Organization' || type === 'LocalBusiness' || type === 'Corporation') {
|
|
258
|
+
info.hasSchema = true;
|
|
259
|
+
info.name = obj.name as string || info.name;
|
|
260
|
+
info.type = type;
|
|
261
|
+
if (obj.contactPoint || obj.telephone || obj.email) {
|
|
262
|
+
info.hasContactInfo = true;
|
|
263
|
+
}
|
|
264
|
+
if (obj.sameAs && Array.isArray(obj.sameAs) && (obj.sameAs as string[]).length > 0) {
|
|
265
|
+
info.hasSocialProfiles = true;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
if (Array.isArray(data)) {
|
|
271
|
+
data.forEach(checkOrg);
|
|
272
|
+
} else {
|
|
273
|
+
checkOrg(data);
|
|
274
|
+
}
|
|
275
|
+
} catch {
|
|
276
|
+
// Invalid JSON
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Check for contact information
|
|
281
|
+
const hasContact =
|
|
282
|
+
$('a[href^="tel:"]').length > 0 ||
|
|
283
|
+
$('a[href^="mailto:"]').length > 0 ||
|
|
284
|
+
$('.contact, #contact, [class*="contact"]').length > 0;
|
|
285
|
+
info.hasContactInfo = info.hasContactInfo || hasContact;
|
|
286
|
+
|
|
287
|
+
// Check for social profile links
|
|
288
|
+
const socialLinks = $('a[href*="facebook.com"], a[href*="twitter.com"], a[href*="linkedin.com"], a[href*="instagram.com"]');
|
|
289
|
+
info.hasSocialProfiles = info.hasSocialProfiles || socialLinks.length > 0;
|
|
290
|
+
|
|
291
|
+
// Look for trust badges
|
|
292
|
+
const pageText = $.text();
|
|
293
|
+
for (const pattern of TRUST_BADGE_PATTERNS) {
|
|
294
|
+
const match = pageText.match(pattern);
|
|
295
|
+
if (match) {
|
|
296
|
+
info.trustBadges.push(match[0]);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return info;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Detect experience signals (first-hand experience indicators)
|
|
305
|
+
*/
|
|
306
|
+
export function detectExperienceSignals(html: string): string[] {
|
|
307
|
+
const $ = cheerio.load(html);
|
|
308
|
+
$('script, style').remove();
|
|
309
|
+
const text = $('body').text();
|
|
310
|
+
const signals: string[] = [];
|
|
311
|
+
|
|
312
|
+
const experiencePatterns = [
|
|
313
|
+
/\bI (tested|tried|used|reviewed|experienced|personally)\b/i,
|
|
314
|
+
/\bin my experience\b/i,
|
|
315
|
+
/\bwe (tested|tried|used|reviewed|found)\b/i,
|
|
316
|
+
/\bfirst-hand\b/i,
|
|
317
|
+
/\bhands-on\b/i,
|
|
318
|
+
/\bI've been (using|doing|working)\b/i,
|
|
319
|
+
/\bfor (\d+) years? I've\b/i,
|
|
320
|
+
/\bour team (tested|reviewed|evaluated)\b/i,
|
|
321
|
+
];
|
|
322
|
+
|
|
323
|
+
for (const pattern of experiencePatterns) {
|
|
324
|
+
if (pattern.test(text)) {
|
|
325
|
+
const match = text.match(pattern);
|
|
326
|
+
if (match) {
|
|
327
|
+
signals.push(match[0]);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Check for original images (simplified - checking for non-stock patterns)
|
|
333
|
+
const hasOriginalImages =
|
|
334
|
+
$('img[src*="author"], img[src*="team"], img[src*="review"], img[src*="test"]').length > 0;
|
|
335
|
+
if (hasOriginalImages) {
|
|
336
|
+
signals.push('Original images present');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Check for user reviews/testimonials
|
|
340
|
+
const hasReviews =
|
|
341
|
+
$('[class*="review"], [class*="testimonial"], [itemtype*="Review"]').length > 0;
|
|
342
|
+
if (hasReviews) {
|
|
343
|
+
signals.push('User reviews/testimonials present');
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return signals;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Detect expertise signals
|
|
351
|
+
*/
|
|
352
|
+
export function detectExpertiseSignals(html: string): string[] {
|
|
353
|
+
const $ = cheerio.load(html);
|
|
354
|
+
const text = $('body').text();
|
|
355
|
+
const signals: string[] = [];
|
|
356
|
+
|
|
357
|
+
// Check for technical depth
|
|
358
|
+
const technicalPatterns = [
|
|
359
|
+
/\b(methodology|analysis|research|study|data)\b/i,
|
|
360
|
+
/\b(according to|based on|supported by)\b/i,
|
|
361
|
+
/\b(statistics|metrics|measurements)\b/i,
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
for (const pattern of technicalPatterns) {
|
|
365
|
+
if (pattern.test(text)) {
|
|
366
|
+
signals.push('Technical/research depth');
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Check for credentials in content
|
|
372
|
+
for (const pattern of CREDENTIAL_PATTERNS) {
|
|
373
|
+
if (pattern.test(text)) {
|
|
374
|
+
const match = text.match(pattern);
|
|
375
|
+
if (match) {
|
|
376
|
+
signals.push(`Credential: ${match[0]}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Check for "reviewed by" or "medically reviewed"
|
|
382
|
+
if (/\b(reviewed by|fact-checked by|medically reviewed|expert reviewed)\b/i.test(text)) {
|
|
383
|
+
signals.push('Expert review indicated');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return signals;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Count and categorize external citations
|
|
391
|
+
*/
|
|
392
|
+
export function analyzeCitations(html: string): { count: number; authoritativeSources: string[] } {
|
|
393
|
+
const $ = cheerio.load(html);
|
|
394
|
+
const authoritativeSources: string[] = [];
|
|
395
|
+
let citationCount = 0;
|
|
396
|
+
|
|
397
|
+
$('a[href^="http"]').each((_, el) => {
|
|
398
|
+
const href = $(el).attr('href') || '';
|
|
399
|
+
try {
|
|
400
|
+
const url = new URL(href);
|
|
401
|
+
const hostname = url.hostname.toLowerCase();
|
|
402
|
+
|
|
403
|
+
// Check if it's an authoritative source
|
|
404
|
+
for (const domain of AUTHORITATIVE_DOMAINS) {
|
|
405
|
+
if (hostname.includes(domain) || hostname.endsWith(`.${domain}`)) {
|
|
406
|
+
authoritativeSources.push(hostname);
|
|
407
|
+
citationCount++;
|
|
408
|
+
break;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
} catch {
|
|
412
|
+
// Invalid URL
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
return { count: citationCount, authoritativeSources: [...new Set(authoritativeSources)] };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Extract reviewer information (medically reviewed, fact-checked, etc.)
|
|
421
|
+
*/
|
|
422
|
+
export function extractReviewerInfo(html: string): ReviewInfo {
|
|
423
|
+
const $ = cheerio.load(html);
|
|
424
|
+
const text = $('body').text();
|
|
425
|
+
const info: ReviewInfo = {
|
|
426
|
+
hasReviewer: false,
|
|
427
|
+
reviewerName: null,
|
|
428
|
+
reviewerCredentials: [],
|
|
429
|
+
reviewDate: null,
|
|
430
|
+
nextReviewDate: null,
|
|
431
|
+
reviewType: null,
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
// Check for "reviewed by" patterns
|
|
435
|
+
const reviewPatterns = [
|
|
436
|
+
/medically reviewed by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)/i,
|
|
437
|
+
/reviewed by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)/i,
|
|
438
|
+
/fact[- ]checked by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)/i,
|
|
439
|
+
/verified by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)/i,
|
|
440
|
+
/expert reviewed by\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)/i,
|
|
441
|
+
];
|
|
442
|
+
|
|
443
|
+
for (const pattern of reviewPatterns) {
|
|
444
|
+
const match = text.match(pattern);
|
|
445
|
+
if (match) {
|
|
446
|
+
info.hasReviewer = true;
|
|
447
|
+
info.reviewerName = match[1];
|
|
448
|
+
|
|
449
|
+
// Determine review type
|
|
450
|
+
if (/medical/i.test(match[0])) {
|
|
451
|
+
info.reviewType = 'medical';
|
|
452
|
+
} else if (/fact[- ]check/i.test(match[0])) {
|
|
453
|
+
info.reviewType = 'fact-check';
|
|
454
|
+
} else if (/expert/i.test(match[0])) {
|
|
455
|
+
info.reviewType = 'expert';
|
|
456
|
+
} else {
|
|
457
|
+
info.reviewType = 'editorial';
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Extract credentials if present near the name
|
|
461
|
+
for (const credPattern of CREDENTIAL_PATTERNS) {
|
|
462
|
+
const credMatch = text.substring(match.index || 0, (match.index || 0) + 200).match(credPattern);
|
|
463
|
+
if (credMatch) {
|
|
464
|
+
info.reviewerCredentials.push(credMatch[0]);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
break;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// Check for review date patterns
|
|
472
|
+
const reviewDatePatterns = [
|
|
473
|
+
/last reviewed[:\s]+(\w+\s+\d{1,2},?\s+\d{4})/i,
|
|
474
|
+
/reviewed on[:\s]+(\w+\s+\d{1,2},?\s+\d{4})/i,
|
|
475
|
+
/review date[:\s]+(\d{4}-\d{2}-\d{2})/i,
|
|
476
|
+
];
|
|
477
|
+
|
|
478
|
+
for (const pattern of reviewDatePatterns) {
|
|
479
|
+
const match = text.match(pattern);
|
|
480
|
+
if (match) {
|
|
481
|
+
info.reviewDate = match[1];
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Check for next review date (important for health content freshness)
|
|
487
|
+
const nextReviewPatterns = [
|
|
488
|
+
/next review[:\s]+(\w+\s+\d{4})/i,
|
|
489
|
+
/due for review[:\s]+(\w+\s+\d{4})/i,
|
|
490
|
+
/review due[:\s]+(\w+\s+\d{4})/i,
|
|
491
|
+
];
|
|
492
|
+
|
|
493
|
+
for (const pattern of nextReviewPatterns) {
|
|
494
|
+
const match = text.match(pattern);
|
|
495
|
+
if (match) {
|
|
496
|
+
info.nextReviewDate = match[1];
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Check for reviewer elements
|
|
502
|
+
const reviewerSelectors = [
|
|
503
|
+
'.reviewed-by', '.fact-checker', '.medical-reviewer',
|
|
504
|
+
'[class*="reviewer"]', '[class*="fact-check"]',
|
|
505
|
+
];
|
|
506
|
+
for (const selector of reviewerSelectors) {
|
|
507
|
+
const el = $(selector).first();
|
|
508
|
+
if (el.length) {
|
|
509
|
+
info.hasReviewer = true;
|
|
510
|
+
const elText = el.text().trim();
|
|
511
|
+
if (elText.length < 100 && !info.reviewerName) {
|
|
512
|
+
info.reviewerName = elText.replace(/^(reviewed by|fact-checked by|verified by)\s*/i, '');
|
|
513
|
+
}
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return info;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Extract trust badges ("As seen in", "Clinically studied", awards, etc.)
|
|
523
|
+
*/
|
|
524
|
+
export function extractTrustBadges(html: string): TrustBadgeInfo {
|
|
525
|
+
const $ = cheerio.load(html);
|
|
526
|
+
const text = $('body').text().toLowerCase();
|
|
527
|
+
const htmlLower = html.toLowerCase();
|
|
528
|
+
|
|
529
|
+
const info: TrustBadgeInfo = {
|
|
530
|
+
pressFeatures: [],
|
|
531
|
+
certifications: [],
|
|
532
|
+
awards: [],
|
|
533
|
+
partnerships: [],
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
// Press/media features ("As seen in", "Featured in")
|
|
537
|
+
const pressPatterns = [
|
|
538
|
+
/as seen (?:in|on)\s+([^.]+)/gi,
|
|
539
|
+
/featured (?:in|on)\s+([^.]+)/gi,
|
|
540
|
+
/covered by\s+([^.]+)/gi,
|
|
541
|
+
/mentioned (?:in|by)\s+([^.]+)/gi,
|
|
542
|
+
];
|
|
543
|
+
|
|
544
|
+
for (const pattern of pressPatterns) {
|
|
545
|
+
let match;
|
|
546
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
547
|
+
// Extract publication names
|
|
548
|
+
const publications = match[1]
|
|
549
|
+
.split(/,|\band\b/)
|
|
550
|
+
.map(p => p.trim())
|
|
551
|
+
.filter(p => p.length > 2 && p.length < 50);
|
|
552
|
+
info.pressFeatures.push(...publications);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Check for press logo sections
|
|
557
|
+
const pressSelectors = [
|
|
558
|
+
'.as-seen-in', '.featured-in', '.press-logos', '.media-logos',
|
|
559
|
+
'[class*="press"]', '[class*="featured"]', '.trust-badges',
|
|
560
|
+
];
|
|
561
|
+
for (const selector of pressSelectors) {
|
|
562
|
+
if ($(selector).length > 0) {
|
|
563
|
+
// Check for common press outlet names in images
|
|
564
|
+
$(`${selector} img`).each((_, el) => {
|
|
565
|
+
const alt = $(el).attr('alt') || '';
|
|
566
|
+
const src = $(el).attr('src') || '';
|
|
567
|
+
const combined = (alt + src).toLowerCase();
|
|
568
|
+
const outlets = ['forbes', 'nytimes', 'wsj', 'bbc', 'cnn', 'techcrunch', 'wired', 'bloomberg'];
|
|
569
|
+
for (const outlet of outlets) {
|
|
570
|
+
if (combined.includes(outlet) && !info.pressFeatures.includes(outlet)) {
|
|
571
|
+
info.pressFeatures.push(outlet);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Certifications and research backing
|
|
579
|
+
const certPatterns = [
|
|
580
|
+
{ pattern: /clinically (?:studied|proven|tested)/gi, type: 'Clinically studied' },
|
|
581
|
+
{ pattern: /fda[- ](?:approved|cleared|registered)/gi, type: 'FDA approved' },
|
|
582
|
+
{ pattern: /dermatologist[- ](?:tested|recommended)/gi, type: 'Dermatologist tested' },
|
|
583
|
+
{ pattern: /doctor[- ](?:recommended|approved)/gi, type: 'Doctor recommended' },
|
|
584
|
+
{ pattern: /lab[- ]tested/gi, type: 'Lab tested' },
|
|
585
|
+
{ pattern: /third[- ]party[- ]tested/gi, type: 'Third-party tested' },
|
|
586
|
+
{ pattern: /scientifically[- ](?:proven|backed)/gi, type: 'Scientifically proven' },
|
|
587
|
+
{ pattern: /peer[- ]reviewed/gi, type: 'Peer reviewed' },
|
|
588
|
+
{ pattern: /iso[- ]\d+[- ]certified/gi, type: 'ISO certified' },
|
|
589
|
+
{ pattern: /organic[- ]certified/gi, type: 'Organic certified' },
|
|
590
|
+
{ pattern: /usda[- ]organic/gi, type: 'USDA Organic' },
|
|
591
|
+
];
|
|
592
|
+
|
|
593
|
+
for (const { pattern, type } of certPatterns) {
|
|
594
|
+
if (pattern.test(text)) {
|
|
595
|
+
info.certifications.push(type);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Awards
|
|
600
|
+
const awardPatterns = [
|
|
601
|
+
/(?:won|received|awarded)\s+(?:the\s+)?([^.]+award)/gi,
|
|
602
|
+
/award[- ]winning/gi,
|
|
603
|
+
/best (?:of|in) \d{4}/gi,
|
|
604
|
+
/editor'?s? choice/gi,
|
|
605
|
+
/top[- ]rated/gi,
|
|
606
|
+
/#1 (?:rated|ranked|best)/gi,
|
|
607
|
+
];
|
|
608
|
+
|
|
609
|
+
for (const pattern of awardPatterns) {
|
|
610
|
+
const match = text.match(pattern);
|
|
611
|
+
if (match) {
|
|
612
|
+
info.awards.push(match[0]);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Partnerships ("Trusted by", "Used by")
|
|
617
|
+
const partnerPatterns = [
|
|
618
|
+
/trusted by\s+([^.]+)/gi,
|
|
619
|
+
/used by\s+([^.]+)/gi,
|
|
620
|
+
/partnered? with\s+([^.]+)/gi,
|
|
621
|
+
/official partner/gi,
|
|
622
|
+
];
|
|
623
|
+
|
|
624
|
+
for (const pattern of partnerPatterns) {
|
|
625
|
+
const match = text.match(pattern);
|
|
626
|
+
if (match) {
|
|
627
|
+
info.partnerships.push(match[0].substring(0, 100));
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Deduplicate
|
|
632
|
+
info.pressFeatures = [...new Set(info.pressFeatures)].slice(0, 10);
|
|
633
|
+
info.certifications = [...new Set(info.certifications)];
|
|
634
|
+
info.awards = [...new Set(info.awards)].slice(0, 5);
|
|
635
|
+
info.partnerships = [...new Set(info.partnerships)].slice(0, 5);
|
|
636
|
+
|
|
637
|
+
return info;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Detect video content and video experience signals
|
|
642
|
+
*/
|
|
643
|
+
export function detectVideoExperienceSignals(html: string): { hasVideo: boolean; signals: string[] } {
|
|
644
|
+
const $ = cheerio.load(html);
|
|
645
|
+
const signals: string[] = [];
|
|
646
|
+
|
|
647
|
+
// Check for video elements
|
|
648
|
+
const hasVideoElement = $('video').length > 0;
|
|
649
|
+
const hasYouTubeEmbed = $('iframe[src*="youtube"], iframe[src*="youtu.be"]').length > 0;
|
|
650
|
+
const hasVimeoEmbed = $('iframe[src*="vimeo"]').length > 0;
|
|
651
|
+
const hasWistiaEmbed = $('script[src*="wistia"], [class*="wistia"]').length > 0;
|
|
652
|
+
const hasVideoSchema = html.includes('"@type":"VideoObject"') || html.includes('"@type": "VideoObject"');
|
|
653
|
+
|
|
654
|
+
const hasVideo = hasVideoElement || hasYouTubeEmbed || hasVimeoEmbed || hasWistiaEmbed;
|
|
655
|
+
|
|
656
|
+
if (hasVideo) {
|
|
657
|
+
signals.push('Video content present');
|
|
658
|
+
|
|
659
|
+
if (hasVideoSchema) {
|
|
660
|
+
signals.push('VideoObject schema present');
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Check for video context suggesting experience
|
|
664
|
+
const videoContextSelectors = [
|
|
665
|
+
'.video-demo', '.product-video', '.review-video', '.tutorial-video',
|
|
666
|
+
'[class*="demo"]', '[class*="walkthrough"]', '[class*="how-to"]',
|
|
667
|
+
];
|
|
668
|
+
|
|
669
|
+
for (const selector of videoContextSelectors) {
|
|
670
|
+
if ($(selector).length > 0) {
|
|
671
|
+
signals.push('Demonstration/tutorial video present');
|
|
672
|
+
break;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Check video titles/descriptions for experience indicators
|
|
677
|
+
const videoTitles = $('video, iframe').map((_, el) => {
|
|
678
|
+
return $(el).attr('title') || $(el).attr('aria-label') || '';
|
|
679
|
+
}).get().join(' ').toLowerCase();
|
|
680
|
+
|
|
681
|
+
if (/review|test|demo|hands-on|unboxing|how-to|tutorial/.test(videoTitles)) {
|
|
682
|
+
signals.push('Experience-based video content');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Check for video testimonials
|
|
687
|
+
if ($('[class*="video-testimonial"], [class*="video-review"]').length > 0) {
|
|
688
|
+
signals.push('Video testimonials present');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
return { hasVideo, signals };
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Extract content dates
|
|
696
|
+
*/
|
|
697
|
+
export function extractContentDates(html: string): { published: string | null; modified: string | null } {
|
|
698
|
+
const $ = cheerio.load(html);
|
|
699
|
+
let published: string | null = null;
|
|
700
|
+
let modified: string | null = null;
|
|
701
|
+
|
|
702
|
+
// Check schema
|
|
703
|
+
$('script[type="application/ld+json"]').each((_, el) => {
|
|
704
|
+
try {
|
|
705
|
+
const data = JSON.parse($(el).html() || '');
|
|
706
|
+
const checkDates = (obj: Record<string, unknown>) => {
|
|
707
|
+
published = published || (obj.datePublished as string) || null;
|
|
708
|
+
modified = modified || (obj.dateModified as string) || null;
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
if (Array.isArray(data)) {
|
|
712
|
+
data.forEach(checkDates);
|
|
713
|
+
} else {
|
|
714
|
+
checkDates(data);
|
|
715
|
+
}
|
|
716
|
+
} catch {
|
|
717
|
+
// Invalid JSON
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
// Check meta tags
|
|
722
|
+
published = published || $('meta[property="article:published_time"]').attr('content') || null;
|
|
723
|
+
modified = modified || $('meta[property="article:modified_time"]').attr('content') || null;
|
|
724
|
+
|
|
725
|
+
// Check time elements
|
|
726
|
+
if (!published) {
|
|
727
|
+
const timeEl = $('time[datetime]').first();
|
|
728
|
+
published = timeEl.attr('datetime') || null;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
return { published, modified };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Detect if content is YMYL (Your Money Your Life)
|
|
736
|
+
*/
|
|
737
|
+
export function detectYMYL(html: string, url: string): boolean {
|
|
738
|
+
const $ = cheerio.load(html);
|
|
739
|
+
const text = ($('title').text() + ' ' + $('h1').text() + ' ' + $('body').text()).toLowerCase();
|
|
740
|
+
|
|
741
|
+
for (const pattern of YMYL_PATTERNS) {
|
|
742
|
+
if (pattern.test(text)) {
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// Check URL path for YMYL indicators
|
|
748
|
+
const urlLower = url.toLowerCase();
|
|
749
|
+
if (
|
|
750
|
+
urlLower.includes('/health') ||
|
|
751
|
+
urlLower.includes('/finance') ||
|
|
752
|
+
urlLower.includes('/legal') ||
|
|
753
|
+
urlLower.includes('/medical') ||
|
|
754
|
+
urlLower.includes('/money')
|
|
755
|
+
) {
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* Main function: Analyze E-E-A-T signals
|
|
764
|
+
*/
|
|
765
|
+
export function analyzeEEATSignals(
|
|
766
|
+
html: string,
|
|
767
|
+
url: string
|
|
768
|
+
): { issues: AuditIssue[]; data: EEATData } {
|
|
769
|
+
const issues: AuditIssue[] = [];
|
|
770
|
+
|
|
771
|
+
const author = extractAuthorInfo(html);
|
|
772
|
+
const organization = extractOrganizationInfo(html);
|
|
773
|
+
const reviewer = extractReviewerInfo(html);
|
|
774
|
+
const trustBadges = extractTrustBadges(html);
|
|
775
|
+
const experienceSignals = detectExperienceSignals(html);
|
|
776
|
+
const expertiseSignals = detectExpertiseSignals(html);
|
|
777
|
+
const citations = analyzeCitations(html);
|
|
778
|
+
const contentDate = extractContentDates(html);
|
|
779
|
+
const videoExperience = detectVideoExperienceSignals(html);
|
|
780
|
+
const isYMYL = detectYMYL(html, url);
|
|
781
|
+
|
|
782
|
+
// Build authority signals list
|
|
783
|
+
const authoritySignals: string[] = [];
|
|
784
|
+
if (organization.hasSchema) authoritySignals.push('Organization schema present');
|
|
785
|
+
if (author.hasSchema) authoritySignals.push('Author schema present');
|
|
786
|
+
if (citations.authoritativeSources.length > 0) {
|
|
787
|
+
authoritySignals.push(`Citations to ${citations.authoritativeSources.length} authoritative sources`);
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
// Build trust signals list
|
|
791
|
+
const trustSignals: string[] = [];
|
|
792
|
+
if (organization.hasContactInfo) trustSignals.push('Contact information present');
|
|
793
|
+
if (organization.trustBadges.length > 0) {
|
|
794
|
+
trustSignals.push(`Trust badges: ${organization.trustBadges.join(', ')}`);
|
|
795
|
+
}
|
|
796
|
+
if (url.startsWith('https://')) trustSignals.push('HTTPS enabled');
|
|
797
|
+
|
|
798
|
+
// Generate issues for YMYL content with missing E-E-A-T signals
|
|
799
|
+
if (isYMYL) {
|
|
800
|
+
if (!author.name && !author.hasSchema) {
|
|
801
|
+
issues.push({
|
|
802
|
+
code: 'YMYL_NO_AUTHOR',
|
|
803
|
+
severity: 'warning',
|
|
804
|
+
category: 'content',
|
|
805
|
+
title: 'YMYL content without author information',
|
|
806
|
+
description: 'This appears to be YMYL (Your Money Your Life) content but has no visible author.',
|
|
807
|
+
impact: 'Google prioritizes E-E-A-T signals heavily for YMYL topics.',
|
|
808
|
+
howToFix: 'Add author byline with credentials, bio, and author schema.',
|
|
809
|
+
affectedUrls: [url],
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
if (author.credentials.length === 0 && expertiseSignals.length === 0) {
|
|
814
|
+
issues.push({
|
|
815
|
+
code: 'YMYL_NO_EXPERTISE',
|
|
816
|
+
severity: 'warning',
|
|
817
|
+
category: 'content',
|
|
818
|
+
title: 'YMYL content without expertise signals',
|
|
819
|
+
description: 'YMYL content lacks visible expertise indicators (credentials, qualifications).',
|
|
820
|
+
impact: 'Expertise is crucial for YMYL content ranking.',
|
|
821
|
+
howToFix: 'Include author credentials, "reviewed by" statements, or cite expert sources.',
|
|
822
|
+
affectedUrls: [url],
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
if (citations.authoritativeSources.length === 0) {
|
|
827
|
+
issues.push({
|
|
828
|
+
code: 'YMYL_NO_CITATIONS',
|
|
829
|
+
severity: 'warning',
|
|
830
|
+
category: 'content',
|
|
831
|
+
title: 'YMYL content without authoritative citations',
|
|
832
|
+
description: 'YMYL content has no links to authoritative sources (.gov, .edu, etc.).',
|
|
833
|
+
impact: 'Citing authoritative sources builds trust for sensitive topics.',
|
|
834
|
+
howToFix: 'Add citations to government, academic, or recognized industry sources.',
|
|
835
|
+
affectedUrls: [url],
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
// General E-E-A-T recommendations
|
|
841
|
+
if (!author.name && !organization.name) {
|
|
842
|
+
issues.push({
|
|
843
|
+
code: 'NO_ENTITY_IDENTIFIED',
|
|
844
|
+
severity: 'notice',
|
|
845
|
+
category: 'content',
|
|
846
|
+
title: 'No author or organization identified',
|
|
847
|
+
description: 'Page lacks clear attribution to an author or organization.',
|
|
848
|
+
impact: 'Anonymous content may be seen as less trustworthy.',
|
|
849
|
+
howToFix: 'Add author byline or organization information with structured data.',
|
|
850
|
+
affectedUrls: [url],
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (!organization.hasSchema) {
|
|
855
|
+
issues.push({
|
|
856
|
+
code: 'NO_ORGANIZATION_SCHEMA',
|
|
857
|
+
severity: 'notice',
|
|
858
|
+
category: 'structured-data',
|
|
859
|
+
title: 'Missing Organization schema',
|
|
860
|
+
description: 'No Organization structured data found.',
|
|
861
|
+
impact: 'Missed opportunity to establish organizational identity for E-E-A-T.',
|
|
862
|
+
howToFix: 'Add Organization schema with name, logo, contact, and social profiles.',
|
|
863
|
+
affectedUrls: [url],
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (!author.hasSchema && author.name) {
|
|
868
|
+
issues.push({
|
|
869
|
+
code: 'AUTHOR_NO_SCHEMA',
|
|
870
|
+
severity: 'notice',
|
|
871
|
+
category: 'structured-data',
|
|
872
|
+
title: 'Author without Person schema',
|
|
873
|
+
description: 'Author name is present but no Person structured data.',
|
|
874
|
+
impact: 'Person schema helps Google understand author entity relationships.',
|
|
875
|
+
howToFix: 'Add Person schema with sameAs links to author profiles.',
|
|
876
|
+
affectedUrls: [url],
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (!contentDate.modified && contentDate.published) {
|
|
881
|
+
issues.push({
|
|
882
|
+
code: 'NO_MODIFIED_DATE',
|
|
883
|
+
severity: 'notice',
|
|
884
|
+
category: 'content',
|
|
885
|
+
title: 'No content modification date',
|
|
886
|
+
description: 'Content has published date but no last-modified date.',
|
|
887
|
+
impact: 'Modified dates signal content freshness to search engines.',
|
|
888
|
+
howToFix: 'Add article:modified_time meta tag or dateModified in schema.',
|
|
889
|
+
affectedUrls: [url],
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
if (experienceSignals.length === 0 && !isYMYL) {
|
|
894
|
+
issues.push({
|
|
895
|
+
code: 'NO_EXPERIENCE_SIGNALS',
|
|
896
|
+
severity: 'notice',
|
|
897
|
+
category: 'content',
|
|
898
|
+
title: 'No first-hand experience signals detected',
|
|
899
|
+
description: 'Content lacks indicators of personal/first-hand experience.',
|
|
900
|
+
impact: 'The "Experience" in E-E-A-T values authentic, first-hand knowledge.',
|
|
901
|
+
howToFix: 'Add personal insights, original photos, or hands-on testing details.',
|
|
902
|
+
affectedUrls: [url],
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Additional issues for enhanced E-E-A-T signals (2025 best practices)
|
|
907
|
+
|
|
908
|
+
// YMYL content without medical/expert reviewer
|
|
909
|
+
if (isYMYL && !reviewer.hasReviewer) {
|
|
910
|
+
const $ = cheerio.load(html);
|
|
911
|
+
const isHealthContent = /health|medical|disease|symptom|treatment/i.test($('title').text() + $('h1').text());
|
|
912
|
+
|
|
913
|
+
if (isHealthContent) {
|
|
914
|
+
issues.push({
|
|
915
|
+
code: 'YMYL_NO_MEDICAL_REVIEWER',
|
|
916
|
+
severity: 'warning',
|
|
917
|
+
category: 'content',
|
|
918
|
+
title: 'Health content without medical reviewer',
|
|
919
|
+
description: 'Health-related content lacks "medically reviewed by" or expert reviewer information.',
|
|
920
|
+
impact: 'Google strongly prefers health content reviewed by qualified medical professionals.',
|
|
921
|
+
howToFix: 'Add "Medically reviewed by [Name, Credentials]" with reviewer bio and review date.',
|
|
922
|
+
affectedUrls: [url],
|
|
923
|
+
});
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Content with reviewer but no review date
|
|
928
|
+
if (reviewer.hasReviewer && !reviewer.reviewDate) {
|
|
929
|
+
issues.push({
|
|
930
|
+
code: 'REVIEWER_NO_DATE',
|
|
931
|
+
severity: 'notice',
|
|
932
|
+
category: 'content',
|
|
933
|
+
title: 'Reviewer present but no review date',
|
|
934
|
+
description: 'Content shows a reviewer but does not display when the review was conducted.',
|
|
935
|
+
impact: 'Review dates help establish content freshness and credibility.',
|
|
936
|
+
howToFix: 'Add "Last reviewed: [Date]" near the reviewer attribution.',
|
|
937
|
+
affectedUrls: [url],
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// No press/authority badges for commercial content
|
|
942
|
+
const isCommercial = /buy|price|shop|product|service/i.test(html);
|
|
943
|
+
if (isCommercial && trustBadges.pressFeatures.length === 0 && trustBadges.certifications.length === 0) {
|
|
944
|
+
issues.push({
|
|
945
|
+
code: 'COMMERCIAL_NO_TRUST_BADGES',
|
|
946
|
+
severity: 'notice',
|
|
947
|
+
category: 'content',
|
|
948
|
+
title: 'Commercial content without trust signals',
|
|
949
|
+
description: 'Product/service page lacks "As seen in", certifications, or trust badges.',
|
|
950
|
+
impact: 'Trust badges and press features build credibility and influence AI recommendations.',
|
|
951
|
+
howToFix: 'Add "As seen in [Press]" logos, certifications, or customer testimonials with ratings.',
|
|
952
|
+
affectedUrls: [url],
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Video experience for product/review content
|
|
957
|
+
const isReviewContent = /review|comparison|best|top \d+/i.test(html);
|
|
958
|
+
if (isReviewContent && !videoExperience.hasVideo) {
|
|
959
|
+
issues.push({
|
|
960
|
+
code: 'REVIEW_NO_VIDEO',
|
|
961
|
+
severity: 'notice',
|
|
962
|
+
category: 'content',
|
|
963
|
+
title: 'Review content without video',
|
|
964
|
+
description: 'Product review or comparison content lacks video demonstrations.',
|
|
965
|
+
impact: 'Video content signals first-hand experience, highly valued by Google and AI tools.',
|
|
966
|
+
howToFix: 'Add hands-on video reviews showing actual product testing and demonstration.',
|
|
967
|
+
affectedUrls: [url],
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
return {
|
|
972
|
+
issues,
|
|
973
|
+
data: {
|
|
974
|
+
author,
|
|
975
|
+
organization,
|
|
976
|
+
reviewer,
|
|
977
|
+
trustBadges,
|
|
978
|
+
experienceSignals: [...experienceSignals, ...videoExperience.signals],
|
|
979
|
+
expertiseSignals,
|
|
980
|
+
authoritySignals,
|
|
981
|
+
trustSignals,
|
|
982
|
+
citationCount: citations.count,
|
|
983
|
+
authoritativeSources: citations.authoritativeSources,
|
|
984
|
+
contentDate,
|
|
985
|
+
hasVideoContent: videoExperience.hasVideo,
|
|
986
|
+
videoExperienceSignals: videoExperience.signals,
|
|
987
|
+
isYMYL,
|
|
988
|
+
},
|
|
989
|
+
};
|
|
990
|
+
}
|