pagesight 0.7.0 → 0.9.0
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/package.json +1 -1
- package/src/tools/metatags.ts +208 -0
package/package.json
CHANGED
package/src/tools/metatags.ts
CHANGED
|
@@ -134,6 +134,189 @@ function formatJsonLd(data: unknown, indent = 0): string[] {
|
|
|
134
134
|
return lines;
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// --- Structured data validation (per Google Rich Results docs) ---
|
|
138
|
+
|
|
139
|
+
interface SchemaRule {
|
|
140
|
+
required: string[];
|
|
141
|
+
recommended: string[];
|
|
142
|
+
imageFields: string[];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Required/recommended fields sourced from Google's Rich Results documentation
|
|
146
|
+
// https://developers.google.com/search/docs/appearance/structured-data
|
|
147
|
+
const SCHEMA_RULES: Record<string, SchemaRule> = {
|
|
148
|
+
WebSite: {
|
|
149
|
+
required: ["name", "url"],
|
|
150
|
+
recommended: ["potentialAction"],
|
|
151
|
+
imageFields: [],
|
|
152
|
+
},
|
|
153
|
+
Organization: {
|
|
154
|
+
required: ["name", "url"],
|
|
155
|
+
recommended: ["logo", "sameAs", "contactPoint"],
|
|
156
|
+
imageFields: ["logo"],
|
|
157
|
+
},
|
|
158
|
+
Article: {
|
|
159
|
+
required: ["headline", "image", "datePublished", "author"],
|
|
160
|
+
recommended: ["dateModified", "publisher"],
|
|
161
|
+
imageFields: ["image"],
|
|
162
|
+
},
|
|
163
|
+
NewsArticle: {
|
|
164
|
+
required: ["headline", "image", "datePublished", "author"],
|
|
165
|
+
recommended: ["dateModified", "publisher"],
|
|
166
|
+
imageFields: ["image"],
|
|
167
|
+
},
|
|
168
|
+
Product: {
|
|
169
|
+
required: ["name", "image"],
|
|
170
|
+
recommended: ["description", "offers", "brand", "review", "aggregateRating"],
|
|
171
|
+
imageFields: ["image"],
|
|
172
|
+
},
|
|
173
|
+
LocalBusiness: {
|
|
174
|
+
required: ["name", "address"],
|
|
175
|
+
recommended: ["telephone", "openingHoursSpecification", "image", "url"],
|
|
176
|
+
imageFields: ["image"],
|
|
177
|
+
},
|
|
178
|
+
FAQPage: {
|
|
179
|
+
required: ["mainEntity"],
|
|
180
|
+
recommended: [],
|
|
181
|
+
imageFields: [],
|
|
182
|
+
},
|
|
183
|
+
BreadcrumbList: {
|
|
184
|
+
required: ["itemListElement"],
|
|
185
|
+
recommended: [],
|
|
186
|
+
imageFields: [],
|
|
187
|
+
},
|
|
188
|
+
Event: {
|
|
189
|
+
required: ["name", "startDate", "location"],
|
|
190
|
+
recommended: ["endDate", "image", "description", "offers", "organizer"],
|
|
191
|
+
imageFields: ["image"],
|
|
192
|
+
},
|
|
193
|
+
Recipe: {
|
|
194
|
+
required: ["name", "image"],
|
|
195
|
+
recommended: ["author", "datePublished", "description", "recipeIngredient", "recipeInstructions"],
|
|
196
|
+
imageFields: ["image"],
|
|
197
|
+
},
|
|
198
|
+
VideoObject: {
|
|
199
|
+
required: ["name", "description", "thumbnailUrl", "uploadDate"],
|
|
200
|
+
recommended: ["duration", "contentUrl", "embedUrl"],
|
|
201
|
+
imageFields: ["thumbnailUrl"],
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
interface ValidationIssue {
|
|
206
|
+
type: string;
|
|
207
|
+
level: "required" | "recommended";
|
|
208
|
+
field: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function getNestedValue(obj: Record<string, unknown>, field: string): unknown {
|
|
212
|
+
const val = obj[field];
|
|
213
|
+
if (val !== undefined && val !== null && val !== "") return val;
|
|
214
|
+
// Check if it's a nested object with a value (e.g., logo might be {url: "..."} or a string)
|
|
215
|
+
if (typeof val === "object" && val !== null) return val;
|
|
216
|
+
return undefined;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function validateJsonLd(blocks: unknown[]): { issues: ValidationIssue[]; imageUrls: string[] } {
|
|
220
|
+
const issues: ValidationIssue[] = [];
|
|
221
|
+
const imageUrls: string[] = [];
|
|
222
|
+
|
|
223
|
+
function validateBlock(data: unknown) {
|
|
224
|
+
if (Array.isArray(data)) {
|
|
225
|
+
for (const item of data) validateBlock(item);
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (!data || typeof data !== "object") return;
|
|
229
|
+
|
|
230
|
+
const obj = data as Record<string, unknown>;
|
|
231
|
+
const rawType = obj["@type"];
|
|
232
|
+
const types = Array.isArray(rawType) ? rawType : rawType ? [rawType] : [];
|
|
233
|
+
|
|
234
|
+
for (const type of types) {
|
|
235
|
+
const rule = SCHEMA_RULES[String(type)];
|
|
236
|
+
if (!rule) continue;
|
|
237
|
+
|
|
238
|
+
for (const field of rule.required) {
|
|
239
|
+
if (getNestedValue(obj, field) === undefined) {
|
|
240
|
+
issues.push({ type: String(type), level: "required", field });
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
for (const field of rule.recommended) {
|
|
245
|
+
if (getNestedValue(obj, field) === undefined) {
|
|
246
|
+
issues.push({ type: String(type), level: "recommended", field });
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Collect image URLs for validation
|
|
251
|
+
for (const field of rule.imageFields) {
|
|
252
|
+
const val = obj[field];
|
|
253
|
+
if (typeof val === "string" && val.startsWith("http")) {
|
|
254
|
+
imageUrls.push(val);
|
|
255
|
+
} else if (val && typeof val === "object") {
|
|
256
|
+
const nested = val as Record<string, unknown>;
|
|
257
|
+
const url = nested.url ?? nested.contentUrl;
|
|
258
|
+
if (typeof url === "string" && url.startsWith("http")) {
|
|
259
|
+
imageUrls.push(url);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Recurse into nested objects
|
|
266
|
+
for (const val of Object.values(obj)) {
|
|
267
|
+
if (val && typeof val === "object" && !Array.isArray(val)) {
|
|
268
|
+
const nested = val as Record<string, unknown>;
|
|
269
|
+
if (nested["@type"]) validateBlock(nested);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
for (const block of blocks) validateBlock(block);
|
|
275
|
+
return { issues, imageUrls };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// What each recommended field enables (sourced from Google Rich Results docs)
|
|
279
|
+
const FIELD_HINTS: Record<string, string> = {
|
|
280
|
+
"WebSite.potentialAction": "enables sitelinks searchbox",
|
|
281
|
+
"Organization.logo": "appears in knowledge panel",
|
|
282
|
+
"Organization.contactPoint": "may appear in knowledge panel",
|
|
283
|
+
"Organization.sameAs": "links social profiles in knowledge panel",
|
|
284
|
+
"Article.dateModified": "shows freshness in search results",
|
|
285
|
+
"Article.publisher": "required for some article rich results",
|
|
286
|
+
"Product.offers": "enables price display in search",
|
|
287
|
+
"Product.review": "enables star ratings in search",
|
|
288
|
+
"Product.aggregateRating": "enables aggregate star ratings",
|
|
289
|
+
"Product.brand": "shown in product rich results",
|
|
290
|
+
"LocalBusiness.openingHoursSpecification": "shows business hours in maps",
|
|
291
|
+
"LocalBusiness.image": "shown in local pack results",
|
|
292
|
+
"Event.image": "shown in event rich results",
|
|
293
|
+
"Event.offers": "shows ticket prices in search",
|
|
294
|
+
"Recipe.author": "shown in recipe rich results",
|
|
295
|
+
"VideoObject.duration": "shown in video rich results",
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
function formatValidation(issues: ValidationIssue[]): string[] {
|
|
299
|
+
if (issues.length === 0) return ["All validated types have their required fields."];
|
|
300
|
+
|
|
301
|
+
const lines: string[] = [];
|
|
302
|
+
const required = issues.filter((i) => i.level === "required");
|
|
303
|
+
const recommended = issues.filter((i) => i.level === "recommended");
|
|
304
|
+
|
|
305
|
+
if (required.length > 0) {
|
|
306
|
+
for (const i of required) {
|
|
307
|
+
lines.push(`MISSING ${i.type}.${i.field} (required for Rich Results)`);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (recommended.length > 0) {
|
|
311
|
+
for (const i of recommended) {
|
|
312
|
+
const hint = FIELD_HINTS[`${i.type}.${i.field}`];
|
|
313
|
+
lines.push(`OPTIONAL ${i.type}.${i.field}${hint ? ` — ${hint}` : ""}`);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return lines;
|
|
318
|
+
}
|
|
319
|
+
|
|
137
320
|
interface RedirectHop {
|
|
138
321
|
url: string;
|
|
139
322
|
status: number;
|
|
@@ -381,6 +564,31 @@ export function registerMetatagsTool(server: McpServer): void {
|
|
|
381
564
|
}
|
|
382
565
|
}
|
|
383
566
|
|
|
567
|
+
// Structured data validation
|
|
568
|
+
if (parsed.jsonLd.length > 0) {
|
|
569
|
+
const { issues, imageUrls } = validateJsonLd(parsed.jsonLd);
|
|
570
|
+
if (issues.length > 0 || imageUrls.length > 0) {
|
|
571
|
+
output.push("", "--- Structured Data Validation ---", "");
|
|
572
|
+
output.push(...formatValidation(issues));
|
|
573
|
+
|
|
574
|
+
// HEAD-check image URLs from structured data
|
|
575
|
+
if (imageUrls.length > 0) {
|
|
576
|
+
const deduped = [...new Set(imageUrls)];
|
|
577
|
+
const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
|
|
578
|
+
for (const check of imgChecks) {
|
|
579
|
+
if (check.error) {
|
|
580
|
+
output.push(`BROKEN Image: ${check.url} — ${check.error}`);
|
|
581
|
+
} else if (check.status && check.status >= 400) {
|
|
582
|
+
output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
|
|
583
|
+
} else {
|
|
584
|
+
const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
|
|
585
|
+
output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
384
592
|
return {
|
|
385
593
|
content: [{ type: "text", text: output.join("\n") }],
|
|
386
594
|
};
|