pagesight 0.7.0 → 0.8.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pagesight",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "See your site the way search engines and AI see it.",
5
5
  "keywords": [
6
6
  "seo",
@@ -134,6 +134,168 @@ 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
+ function formatValidation(issues: ValidationIssue[]): string[] {
279
+ if (issues.length === 0) return ["All validated types have their required fields."];
280
+
281
+ const lines: string[] = [];
282
+ const required = issues.filter((i) => i.level === "required");
283
+ const recommended = issues.filter((i) => i.level === "recommended");
284
+
285
+ if (required.length > 0) {
286
+ for (const i of required) {
287
+ lines.push(`MISSING ${i.type}.${i.field} (required for Rich Results)`);
288
+ }
289
+ }
290
+ if (recommended.length > 0) {
291
+ for (const i of recommended) {
292
+ lines.push(`OPTIONAL ${i.type}.${i.field} (recommended)`);
293
+ }
294
+ }
295
+
296
+ return lines;
297
+ }
298
+
137
299
  interface RedirectHop {
138
300
  url: string;
139
301
  status: number;
@@ -381,6 +543,31 @@ export function registerMetatagsTool(server: McpServer): void {
381
543
  }
382
544
  }
383
545
 
546
+ // Structured data validation
547
+ if (parsed.jsonLd.length > 0) {
548
+ const { issues, imageUrls } = validateJsonLd(parsed.jsonLd);
549
+ if (issues.length > 0 || imageUrls.length > 0) {
550
+ output.push("", "--- Structured Data Validation ---", "");
551
+ output.push(...formatValidation(issues));
552
+
553
+ // HEAD-check image URLs from structured data
554
+ if (imageUrls.length > 0) {
555
+ const deduped = [...new Set(imageUrls)];
556
+ const imgChecks = await Promise.all(deduped.map((u) => checkImage(u, "schema")));
557
+ for (const check of imgChecks) {
558
+ if (check.error) {
559
+ output.push(`BROKEN Image: ${check.url} — ${check.error}`);
560
+ } else if (check.status && check.status >= 400) {
561
+ output.push(`BROKEN Image: ${check.url} — HTTP ${check.status}`);
562
+ } else {
563
+ const sizeKB = check.contentLength ? Math.round(check.contentLength / 1024) : null;
564
+ output.push(`OK Image: ${check.url}${sizeKB ? ` (${sizeKB} KB)` : ""}`);
565
+ }
566
+ }
567
+ }
568
+ }
569
+ }
570
+
384
571
  return {
385
572
  content: [{ type: "text", text: output.join("\n") }],
386
573
  };