Joseph W. Anady commited on
Commit
fc66ad6
·
1 Parent(s): 8abeff6

Initial release: Schema.org JSON-LD Validator (Gradio Space)

Browse files
Files changed (2) hide show
  1. app.py +224 -0
  2. requirements.txt +1 -0
app.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema.org JSON-LD Validator — Hugging Face Space.
2
+
3
+ Paste any JSON-LD or fetch from a URL, validate the syntax and
4
+ key Schema.org @graph structure.
5
+
6
+ Built by: https://www.thatdevpro.com (ThatDevPro)
7
+ Companion to: https://github.com/Janady13/aio-surfaces
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ import urllib.parse
14
+ import urllib.request
15
+ from typing import Any
16
+
17
+ import gradio as gr
18
+
19
+ UA = "schema-validator/1.0 (+https://huggingface.co/spaces/Janady07/schema-validator)"
20
+
21
+
22
+ def _fetch_html(url: str) -> str:
23
+ req = urllib.request.Request(url, headers={"User-Agent": UA})
24
+ with urllib.request.urlopen(req, timeout=15) as r:
25
+ return r.read().decode("utf-8", "ignore")
26
+
27
+
28
+ def _extract_jsonld_from_html(html: str) -> list[str]:
29
+ """Pull all <script type=application/ld+json> blocks."""
30
+ pattern = re.compile(
31
+ r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',
32
+ re.I | re.S,
33
+ )
34
+ return [m.group(1).strip() for m in pattern.finditer(html)]
35
+
36
+
37
+ def _validate_jsonld(text: str) -> dict[str, Any]:
38
+ """Parse + run basic Schema.org @graph validation."""
39
+ result: dict[str, Any] = {
40
+ "valid_json": False,
41
+ "errors": [],
42
+ "warnings": [],
43
+ "stats": {},
44
+ }
45
+ try:
46
+ data = json.loads(text)
47
+ result["valid_json"] = True
48
+ except json.JSONDecodeError as e:
49
+ result["errors"].append(f"JSON parse error: {e}")
50
+ return result
51
+
52
+ # Check @context
53
+ if isinstance(data, dict):
54
+ nodes = []
55
+ if "@graph" in data:
56
+ nodes = data["@graph"]
57
+ if not isinstance(data.get("@context"), (str, dict, list)):
58
+ result["warnings"].append(
59
+ "Missing top-level @context — should be 'https://schema.org' or equivalent"
60
+ )
61
+ if (isinstance(data.get("@context"), str) and
62
+ "schema.org" not in data["@context"]):
63
+ result["warnings"].append(
64
+ f"@context is {data['@context']!r}, expected to include 'schema.org'"
65
+ )
66
+ else:
67
+ nodes = [data]
68
+ elif isinstance(data, list):
69
+ nodes = data
70
+ else:
71
+ result["errors"].append(f"Top-level must be object or array, got {type(data).__name__}")
72
+ return result
73
+
74
+ # Per-node checks
75
+ types_seen = []
76
+ ids_seen = []
77
+ same_as_count = 0
78
+ identifier_count = 0
79
+ for i, node in enumerate(nodes):
80
+ if not isinstance(node, dict):
81
+ result["warnings"].append(f"Node {i}: not an object (got {type(node).__name__})")
82
+ continue
83
+ t = node.get("@type")
84
+ nid = node.get("@id")
85
+ if t is None:
86
+ result["warnings"].append(f"Node {i}: missing @type")
87
+ else:
88
+ types_seen.append(t if isinstance(t, str) else (",".join(t) if isinstance(t, list) else str(t)))
89
+ if nid:
90
+ ids_seen.append(nid)
91
+ if "sameAs" in node:
92
+ sa = node["sameAs"]
93
+ same_as_count += len(sa) if isinstance(sa, list) else 1
94
+ if "identifier" in node:
95
+ ids_field = node["identifier"]
96
+ identifier_count += len(ids_field) if isinstance(ids_field, list) else 1
97
+
98
+ result["stats"] = {
99
+ "node_count": len(nodes),
100
+ "types_seen": types_seen,
101
+ "node_ids": ids_seen,
102
+ "sameAs_total": same_as_count,
103
+ "identifier_total": identifier_count,
104
+ }
105
+
106
+ # Best-practice nudges
107
+ if "Organization" in str(types_seen) and same_as_count < 3:
108
+ result["warnings"].append(
109
+ "Organization has < 3 sameAs entries — recommend at least 3 trusted profiles "
110
+ "(Wikidata, Crunchbase, LinkedIn, GitHub) for entity disambiguation"
111
+ )
112
+ if "Person" in str(types_seen) and identifier_count == 0:
113
+ result["warnings"].append(
114
+ "Person node has no identifier — recommend at least one (ORCID, googleKgMID, wikidata)"
115
+ )
116
+
117
+ return result
118
+
119
+
120
+ def validate(input_text: str, mode: str) -> tuple[str, str]:
121
+ """Returns (status_message, validation_report_json)."""
122
+ input_text = (input_text or "").strip()
123
+ if not input_text:
124
+ return "Provide JSON-LD text or a URL.", ""
125
+
126
+ if mode == "URL":
127
+ url = input_text
128
+ if not url.startswith(("http://", "https://")):
129
+ url = "https://" + url
130
+ try:
131
+ html = _fetch_html(url)
132
+ except Exception as e:
133
+ return f"Could not fetch {url}: {e}", ""
134
+ blocks = _extract_jsonld_from_html(html)
135
+ if not blocks:
136
+ return f"No <script type=\"application/ld+json\"> blocks found at {url}", ""
137
+
138
+ all_reports = []
139
+ for i, block in enumerate(blocks):
140
+ r = _validate_jsonld(block)
141
+ r["block_index"] = i
142
+ all_reports.append(r)
143
+
144
+ ok = sum(1 for r in all_reports if r["valid_json"] and not r["errors"])
145
+ status = (
146
+ f"Found {len(blocks)} JSON-LD block(s) at {url}. "
147
+ f"{ok} parsed cleanly, "
148
+ f"{sum(1 for r in all_reports if r['errors'])} with errors, "
149
+ f"{sum(len(r['warnings']) for r in all_reports)} warnings total."
150
+ )
151
+ return status, json.dumps(all_reports, indent=2)
152
+
153
+ # Direct paste mode
154
+ report = _validate_jsonld(input_text)
155
+ status_lines = ["Direct JSON-LD validation:"]
156
+ if not report["valid_json"]:
157
+ status_lines.append("❌ JSON is not parseable")
158
+ elif report["errors"]:
159
+ status_lines.append(f"❌ {len(report['errors'])} errors")
160
+ else:
161
+ status_lines.append("✓ JSON parses cleanly")
162
+ if report["warnings"]:
163
+ status_lines.append(f"⚠ {len(report['warnings'])} warnings (see report)")
164
+ if report.get("stats"):
165
+ s = report["stats"]
166
+ status_lines.append(
167
+ f"Stats: {s.get('node_count', 0)} node(s), "
168
+ f"{s.get('sameAs_total', 0)} sameAs entries, "
169
+ f"{s.get('identifier_total', 0)} identifier entries"
170
+ )
171
+ return "\n".join(status_lines), json.dumps(report, indent=2)
172
+
173
+
174
+ with gr.Blocks(
175
+ title="Schema.org JSON-LD Validator",
176
+ theme=gr.themes.Soft(primary_hue="green"),
177
+ ) as demo:
178
+ gr.Markdown(
179
+ """
180
+ # Schema.org JSON-LD Validator
181
+
182
+ Validate a Schema.org JSON-LD block — either paste it directly, or
183
+ give a URL and we'll fetch all `<script type="application/ld+json">`
184
+ blocks from the page and check each.
185
+
186
+ Best-practice checks include:
187
+ - JSON parses
188
+ - `@context` is `schema.org`
189
+ - Every node has `@type` and `@id`
190
+ - `Organization` has ≥ 3 `sameAs` entries
191
+ - `Person` has at least one `identifier` (ORCID / Wikidata / Google KG MID)
192
+ """
193
+ )
194
+
195
+ with gr.Row():
196
+ mode = gr.Radio(
197
+ choices=["URL", "Paste JSON-LD"],
198
+ value="URL",
199
+ label="Input mode",
200
+ scale=1,
201
+ )
202
+ inp = gr.Textbox(
203
+ label="URL or JSON-LD",
204
+ placeholder="https://www.thatdevpro.com OR paste JSON-LD…",
205
+ lines=8,
206
+ scale=3,
207
+ )
208
+ btn = gr.Button("Validate", variant="primary")
209
+ status = gr.Textbox(label="Status", lines=4, interactive=False)
210
+ out = gr.Code(label="Validation report", language="json", lines=24)
211
+ btn.click(fn=validate, inputs=[inp, mode], outputs=[status, out])
212
+
213
+ gr.Markdown(
214
+ """
215
+ ---
216
+ Built by **[ThatDevPro](https://www.thatdevpro.com)** ·
217
+ Companion to **[aio-surfaces](https://github.com/Janady13/aio-surfaces)** (the toolkit that generates Schema.org @graph from a typed site config) ·
218
+ Source for this validator: open issue if interested.
219
+ """
220
+ )
221
+
222
+
223
+ if __name__ == "__main__":
224
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio>=4.40