@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.
Files changed (178) hide show
  1. package/README.md +242 -0
  2. package/dist/analyzer-2CSWIQGD.mjs +6 -0
  3. package/dist/chunk-YNZYHEYM.mjs +774 -0
  4. package/dist/index.d.mts +4012 -0
  5. package/dist/index.d.ts +4012 -0
  6. package/dist/index.js +29672 -0
  7. package/dist/index.mjs +28602 -0
  8. package/package.json +53 -0
  9. package/scripts/build-deno.ts +134 -0
  10. package/src/audit/ai/analyzer.ts +347 -0
  11. package/src/audit/ai/index.ts +29 -0
  12. package/src/audit/ai/prompts/content-analysis.ts +271 -0
  13. package/src/audit/ai/types.ts +179 -0
  14. package/src/audit/checks/additional-checks.ts +439 -0
  15. package/src/audit/checks/ai-citation-worthiness.ts +399 -0
  16. package/src/audit/checks/ai-content-structure.ts +325 -0
  17. package/src/audit/checks/ai-readiness.ts +339 -0
  18. package/src/audit/checks/anchor-text.ts +179 -0
  19. package/src/audit/checks/answer-conciseness.ts +322 -0
  20. package/src/audit/checks/asset-minification.ts +270 -0
  21. package/src/audit/checks/bing-optimization.ts +206 -0
  22. package/src/audit/checks/brand-mention-optimization.ts +349 -0
  23. package/src/audit/checks/caching-headers.ts +305 -0
  24. package/src/audit/checks/canonical-advanced.ts +150 -0
  25. package/src/audit/checks/canonical-domain.ts +196 -0
  26. package/src/audit/checks/citation-quality.ts +358 -0
  27. package/src/audit/checks/client-rendering.ts +542 -0
  28. package/src/audit/checks/color-contrast.ts +342 -0
  29. package/src/audit/checks/content-freshness.ts +170 -0
  30. package/src/audit/checks/content-science.ts +589 -0
  31. package/src/audit/checks/conversion-elements.ts +526 -0
  32. package/src/audit/checks/crawlability.ts +220 -0
  33. package/src/audit/checks/directory-listing.ts +172 -0
  34. package/src/audit/checks/dom-analysis.ts +191 -0
  35. package/src/audit/checks/dom-size.ts +246 -0
  36. package/src/audit/checks/duplicate-content.ts +194 -0
  37. package/src/audit/checks/eeat-signals.ts +990 -0
  38. package/src/audit/checks/entity-seo.ts +396 -0
  39. package/src/audit/checks/featured-snippet.ts +473 -0
  40. package/src/audit/checks/freshness-signals.ts +443 -0
  41. package/src/audit/checks/funnel-intent.ts +463 -0
  42. package/src/audit/checks/hreflang.ts +174 -0
  43. package/src/audit/checks/html-compliance.ts +302 -0
  44. package/src/audit/checks/image-dimensions.ts +167 -0
  45. package/src/audit/checks/images.ts +160 -0
  46. package/src/audit/checks/indexnow.ts +275 -0
  47. package/src/audit/checks/interactive-tools.ts +475 -0
  48. package/src/audit/checks/internal-link-graph.ts +436 -0
  49. package/src/audit/checks/keyword-analysis.ts +239 -0
  50. package/src/audit/checks/keyword-cannibalization.ts +385 -0
  51. package/src/audit/checks/keyword-placement.ts +471 -0
  52. package/src/audit/checks/links.ts +203 -0
  53. package/src/audit/checks/llms-txt.ts +224 -0
  54. package/src/audit/checks/local-seo.ts +296 -0
  55. package/src/audit/checks/mobile.ts +167 -0
  56. package/src/audit/checks/modern-images.ts +226 -0
  57. package/src/audit/checks/navboost-signals.ts +395 -0
  58. package/src/audit/checks/on-page.ts +209 -0
  59. package/src/audit/checks/page-resources.ts +285 -0
  60. package/src/audit/checks/pagination.ts +180 -0
  61. package/src/audit/checks/performance.ts +153 -0
  62. package/src/audit/checks/platform-presence.ts +580 -0
  63. package/src/audit/checks/redirect-analysis.ts +153 -0
  64. package/src/audit/checks/redirect-chain.ts +389 -0
  65. package/src/audit/checks/resource-hints.ts +420 -0
  66. package/src/audit/checks/responsive-css.ts +247 -0
  67. package/src/audit/checks/responsive-images.ts +396 -0
  68. package/src/audit/checks/review-ecosystem.ts +415 -0
  69. package/src/audit/checks/robots-validation.ts +373 -0
  70. package/src/audit/checks/security-headers.ts +172 -0
  71. package/src/audit/checks/security.ts +144 -0
  72. package/src/audit/checks/serp-preview.ts +251 -0
  73. package/src/audit/checks/site-maturity.ts +444 -0
  74. package/src/audit/checks/social-meta.test.ts +275 -0
  75. package/src/audit/checks/social-meta.ts +134 -0
  76. package/src/audit/checks/soft-404.ts +151 -0
  77. package/src/audit/checks/structured-data.ts +238 -0
  78. package/src/audit/checks/tech-detection.ts +496 -0
  79. package/src/audit/checks/topical-clusters.ts +435 -0
  80. package/src/audit/checks/tracker-bloat.ts +462 -0
  81. package/src/audit/checks/tracking-verification.test.ts +371 -0
  82. package/src/audit/checks/tracking-verification.ts +636 -0
  83. package/src/audit/checks/url-safety.ts +682 -0
  84. package/src/audit/deno-entry.ts +66 -0
  85. package/src/audit/discovery/index.ts +15 -0
  86. package/src/audit/discovery/link-crawler.ts +232 -0
  87. package/src/audit/discovery/repo-routes.ts +347 -0
  88. package/src/audit/engine.ts +620 -0
  89. package/src/audit/fixes/index.ts +209 -0
  90. package/src/audit/fixes/social-meta-fixes.test.ts +329 -0
  91. package/src/audit/fixes/social-meta-fixes.ts +463 -0
  92. package/src/audit/index.ts +74 -0
  93. package/src/audit/runner.test.ts +299 -0
  94. package/src/audit/runner.ts +130 -0
  95. package/src/audit/types.ts +1953 -0
  96. package/src/content/featured-snippet.ts +367 -0
  97. package/src/content/generator.test.ts +534 -0
  98. package/src/content/generator.ts +501 -0
  99. package/src/content/headline.ts +317 -0
  100. package/src/content/index.ts +62 -0
  101. package/src/content/intent.ts +258 -0
  102. package/src/content/keyword-density.ts +349 -0
  103. package/src/content/readability.ts +262 -0
  104. package/src/executor.ts +336 -0
  105. package/src/fixer.ts +416 -0
  106. package/src/frameworks/detector.test.ts +248 -0
  107. package/src/frameworks/detector.ts +371 -0
  108. package/src/frameworks/index.ts +68 -0
  109. package/src/frameworks/recipes/angular.yaml +171 -0
  110. package/src/frameworks/recipes/astro.yaml +206 -0
  111. package/src/frameworks/recipes/django.yaml +180 -0
  112. package/src/frameworks/recipes/laravel.yaml +137 -0
  113. package/src/frameworks/recipes/nextjs.yaml +268 -0
  114. package/src/frameworks/recipes/nuxt.yaml +175 -0
  115. package/src/frameworks/recipes/rails.yaml +188 -0
  116. package/src/frameworks/recipes/react.yaml +202 -0
  117. package/src/frameworks/recipes/sveltekit.yaml +154 -0
  118. package/src/frameworks/recipes/vue.yaml +137 -0
  119. package/src/frameworks/recipes/wordpress.yaml +209 -0
  120. package/src/frameworks/suggestion-engine.ts +320 -0
  121. package/src/geo/geo-content.test.ts +305 -0
  122. package/src/geo/geo-content.ts +266 -0
  123. package/src/geo/geo-history.test.ts +473 -0
  124. package/src/geo/geo-history.ts +433 -0
  125. package/src/geo/geo-tracker.test.ts +359 -0
  126. package/src/geo/geo-tracker.ts +411 -0
  127. package/src/geo/index.ts +10 -0
  128. package/src/git/commit-helper.test.ts +261 -0
  129. package/src/git/commit-helper.ts +329 -0
  130. package/src/git/index.ts +12 -0
  131. package/src/git/pr-helper.test.ts +284 -0
  132. package/src/git/pr-helper.ts +307 -0
  133. package/src/index.ts +66 -0
  134. package/src/keywords/ai-keyword-engine.ts +1062 -0
  135. package/src/keywords/ai-summarizer.ts +387 -0
  136. package/src/keywords/ci-mode.ts +555 -0
  137. package/src/keywords/engine.ts +359 -0
  138. package/src/keywords/index.ts +151 -0
  139. package/src/keywords/llm-judge.ts +357 -0
  140. package/src/keywords/nlp-analysis.ts +706 -0
  141. package/src/keywords/prioritizer.ts +295 -0
  142. package/src/keywords/site-crawler.ts +342 -0
  143. package/src/keywords/sources/autocomplete.ts +139 -0
  144. package/src/keywords/sources/competitive-search.ts +450 -0
  145. package/src/keywords/sources/competitor-analysis.ts +374 -0
  146. package/src/keywords/sources/dataforseo.ts +206 -0
  147. package/src/keywords/sources/free-sources.ts +294 -0
  148. package/src/keywords/sources/gsc.ts +123 -0
  149. package/src/keywords/topic-grouping.ts +327 -0
  150. package/src/keywords/types.ts +144 -0
  151. package/src/keywords/wizard.ts +457 -0
  152. package/src/loader.ts +40 -0
  153. package/src/reports/index.ts +7 -0
  154. package/src/reports/report-generator.test.ts +293 -0
  155. package/src/reports/report-generator.ts +713 -0
  156. package/src/scheduler/alerts.test.ts +458 -0
  157. package/src/scheduler/alerts.ts +328 -0
  158. package/src/scheduler/index.ts +8 -0
  159. package/src/scheduler/scheduled-audit.test.ts +377 -0
  160. package/src/scheduler/scheduled-audit.ts +149 -0
  161. package/src/test/integration-test.ts +325 -0
  162. package/src/tools/analyzer.ts +373 -0
  163. package/src/tools/crawl.ts +293 -0
  164. package/src/tools/files.ts +301 -0
  165. package/src/tools/h1-fixer.ts +249 -0
  166. package/src/tools/index.ts +67 -0
  167. package/src/tracking/github-action.ts +326 -0
  168. package/src/tracking/google-analytics.ts +265 -0
  169. package/src/tracking/index.ts +45 -0
  170. package/src/tracking/report-generator.ts +386 -0
  171. package/src/tracking/search-console.ts +335 -0
  172. package/src/types.ts +134 -0
  173. package/src/utils/http.ts +302 -0
  174. package/src/wasm-adapter.ts +297 -0
  175. package/src/wasm-entry.ts +14 -0
  176. package/tsconfig.json +17 -0
  177. package/tsup.wasm.config.ts +26 -0
  178. 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
+ }