autoai-optimize 1.0.0__py3-none-any.whl
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.
- autoai_optimize/__init__.py +18 -0
- autoai_optimize/analyze/__init__.py +17 -0
- autoai_optimize/analyze/classifier.py +115 -0
- autoai_optimize/analyze/extractors.py +241 -0
- autoai_optimize/analyze/hints.py +73 -0
- autoai_optimize/analyze/jsdetect.py +28 -0
- autoai_optimize/config.py +66 -0
- autoai_optimize/core.py +259 -0
- autoai_optimize/frameworks/__init__.py +3 -0
- autoai_optimize/frameworks/base.py +38 -0
- autoai_optimize/frameworks/django.py +141 -0
- autoai_optimize/frameworks/fastapi.py +227 -0
- autoai_optimize/inject/__init__.py +7 -0
- autoai_optimize/inject/html.py +115 -0
- autoai_optimize/offload.py +63 -0
- autoai_optimize/schema/__init__.py +18 -0
- autoai_optimize/schema/article.py +41 -0
- autoai_optimize/schema/base.py +52 -0
- autoai_optimize/schema/product.py +50 -0
- autoai_optimize/schema/profile.py +28 -0
- autoai_optimize/schema/registry.py +33 -0
- autoai_optimize/utils.py +135 -0
- autoai_optimize-1.0.0.dist-info/METADATA +270 -0
- autoai_optimize-1.0.0.dist-info/RECORD +26 -0
- autoai_optimize-1.0.0.dist-info/WHEEL +4 -0
- autoai_optimize-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""autoai-optimize: automatic Schema.org / JSON-LD injection for web apps.
|
|
2
|
+
|
|
3
|
+
Install once, get better Google rankings, voice-search readiness, and
|
|
4
|
+
AI-agent discoverability — zero manual structured-data work.
|
|
5
|
+
|
|
6
|
+
Public API:
|
|
7
|
+
from autoai_optimize import Config, optimize_html
|
|
8
|
+
from autoai_optimize.frameworks.fastapi import AutoAIMiddleware
|
|
9
|
+
from autoai_optimize.frameworks.django import AutoAIMiddleware as DjangoMiddleware
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from autoai_optimize.config import Config
|
|
15
|
+
from autoai_optimize.core import generate_jsonld, optimize_html
|
|
16
|
+
|
|
17
|
+
__all__ = ["Config", "__version__", "generate_jsonld", "optimize_html"]
|
|
18
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Analysis layer: page-type classification, hint parsing, entity extraction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from autoai_optimize.analyze.classifier import Classification, PageType, classify
|
|
6
|
+
from autoai_optimize.analyze.extractors import extract_article, extract_product
|
|
7
|
+
from autoai_optimize.analyze.hints import PageHint, parse_hints
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"Classification",
|
|
11
|
+
"PageHint",
|
|
12
|
+
"PageType",
|
|
13
|
+
"classify",
|
|
14
|
+
"extract_article",
|
|
15
|
+
"extract_product",
|
|
16
|
+
"parse_hints",
|
|
17
|
+
]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Heuristic page-type classifier.
|
|
2
|
+
|
|
3
|
+
Determines whether a response represents an Article or a Product page using
|
|
4
|
+
URL patterns, HTML signals, and meta tags. Confidence-scored so the core can
|
|
5
|
+
fall back to silence when uncertain — a wrong schema is worse than none.
|
|
6
|
+
|
|
7
|
+
Developer hints (see hints.py) always override this classification.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from enum import Enum
|
|
14
|
+
|
|
15
|
+
from bs4 import BeautifulSoup
|
|
16
|
+
|
|
17
|
+
ARTICLE_URL_HINTS = ("/blog/", "/post/", "/article/", "/news/", "/posts/")
|
|
18
|
+
PRODUCT_URL_HINTS = ("/product/", "/products/", "/shop/", "/p/", "/item/")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class PageType(str, Enum):
|
|
22
|
+
"""Page types autoai-optimize knows how to enrich in Phase 1."""
|
|
23
|
+
|
|
24
|
+
ARTICLE = "Article"
|
|
25
|
+
PRODUCT = "Product"
|
|
26
|
+
PROFILE = "Profile"
|
|
27
|
+
UNKNOWN = "Unknown"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class Classification:
|
|
32
|
+
"""The result of classifying a single response."""
|
|
33
|
+
|
|
34
|
+
page_type: PageType
|
|
35
|
+
confidence: float # 0.0–1.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _score_article(url_path: str, soup: BeautifulSoup) -> float:
|
|
39
|
+
"""Confidence that the page is an Article."""
|
|
40
|
+
score = 0.0
|
|
41
|
+
if any(h in url_path for h in ARTICLE_URL_HINTS):
|
|
42
|
+
score += 0.4
|
|
43
|
+
if soup.find("article"):
|
|
44
|
+
score += 0.25
|
|
45
|
+
if soup.find("time"):
|
|
46
|
+
score += 0.15
|
|
47
|
+
# Common blog/article metadata.
|
|
48
|
+
if soup.find("meta", attrs={"name": "author"}) or soup.find("meta", attrs={"property": "article:author"}):
|
|
49
|
+
score += 0.15
|
|
50
|
+
og_type = _og_type(soup)
|
|
51
|
+
if og_type == "article":
|
|
52
|
+
score += 0.3
|
|
53
|
+
return min(score, 1.0)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _score_product(url_path: str, soup: BeautifulSoup) -> float:
|
|
57
|
+
"""Confidence that the page is a Product page."""
|
|
58
|
+
score = 0.0
|
|
59
|
+
if any(h in url_path for h in PRODUCT_URL_HINTS):
|
|
60
|
+
score += 0.4
|
|
61
|
+
# Price-like text anywhere (currency symbol followed by digits).
|
|
62
|
+
if _has_price_signal(soup):
|
|
63
|
+
score += 0.25
|
|
64
|
+
# Add-to-cart / buy controls are strong commerce signals.
|
|
65
|
+
text_lc = soup.get_text(" ") if soup.find() else ""
|
|
66
|
+
if any(k in text_lc for k in ("add to cart", "add to bag", "buy now")):
|
|
67
|
+
score += 0.2
|
|
68
|
+
# og:type=product is a strong, explicit signal.
|
|
69
|
+
og_type = _og_type(soup)
|
|
70
|
+
if og_type == "product":
|
|
71
|
+
score += 0.3
|
|
72
|
+
return min(score, 1.0)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _og_type(soup: BeautifulSoup) -> str | None:
|
|
76
|
+
tag = soup.find("meta", attrs={"property": "og:type"})
|
|
77
|
+
if tag and tag.get("content"):
|
|
78
|
+
return str(tag["content"]).strip().lower()
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _has_price_signal(soup: BeautifulSoup) -> bool:
|
|
83
|
+
"""True if the page contains plausible price text (e.g. '$29.99', '€10')."""
|
|
84
|
+
import re
|
|
85
|
+
|
|
86
|
+
pattern = re.compile(r"[$€£¥₹]\s?\d|\d+[.,]\d{2}\s?(?:usd|eur|gbp)", re.IGNORECASE)
|
|
87
|
+
for _el in soup.find_all(string=pattern):
|
|
88
|
+
return True
|
|
89
|
+
# microdata/itemprop price is an even stronger, explicit signal.
|
|
90
|
+
return soup.find(itemprop="price") is not None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def classify(url_path: str, soup: BeautifulSoup) -> Classification:
|
|
94
|
+
"""Classify a parsed page. Never raises; returns UNKNOWN on no signal.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
url_path: Path portion of the request URL (e.g. "/blog/my-post").
|
|
98
|
+
soup: Parsed HTML (BeautifulSoup).
|
|
99
|
+
"""
|
|
100
|
+
art = _score_article(url_path, soup)
|
|
101
|
+
prod = _score_product(url_path, soup)
|
|
102
|
+
if art == prod == 0.0:
|
|
103
|
+
return Classification(PageType.UNKNOWN, 0.0)
|
|
104
|
+
if art >= prod:
|
|
105
|
+
return Classification(PageType.ARTICLE, art)
|
|
106
|
+
return Classification(PageType.PRODUCT, prod)
|
|
107
|
+
|
|
108
|
+
def _score_profile(url_path: str, soup: BeautifulSoup) -> float:
|
|
109
|
+
score = 0.0
|
|
110
|
+
import re
|
|
111
|
+
if "/author/" in url_path or "/profile/" in url_path or "/user/" in url_path:
|
|
112
|
+
score += 0.5
|
|
113
|
+
if soup.find(class_=re.compile("profile|bio|author-card", re.I)):
|
|
114
|
+
score += 0.4
|
|
115
|
+
return min(1.0, score)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Entity extraction per page type.
|
|
2
|
+
|
|
3
|
+
Extractors read candidate fields out of the parsed HTML. They never raise on
|
|
4
|
+
missing data — they simply omit the field. The schema builders decide whether
|
|
5
|
+
enough was found to emit valid JSON-LD.
|
|
6
|
+
|
|
7
|
+
A developer's explicit hint fields always override extracted values.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from bs4 import BeautifulSoup
|
|
15
|
+
|
|
16
|
+
from autoai_optimize.analyze.hints import PageHint
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _first_text(
|
|
20
|
+
soup: BeautifulSoup,
|
|
21
|
+
selectors: list[tuple[str, dict[str, Any]]],
|
|
22
|
+
ai_field: str | None = None,
|
|
23
|
+
) -> str | None:
|
|
24
|
+
"""Return the stripped text of the first matching selector, or None."""
|
|
25
|
+
for name, attrs in selectors:
|
|
26
|
+
tag = soup.find(name, attrs=attrs)
|
|
27
|
+
if tag and tag.get_text(strip=True):
|
|
28
|
+
if ai_field:
|
|
29
|
+
tag.attrs["data-ai-field"] = ai_field
|
|
30
|
+
return tag.get_text(strip=True)
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _meta_content(
|
|
35
|
+
soup: BeautifulSoup,
|
|
36
|
+
selectors: list[tuple[str, dict[str, Any]]],
|
|
37
|
+
ai_field: str | None = None,
|
|
38
|
+
) -> str | None:
|
|
39
|
+
for name, attrs in selectors:
|
|
40
|
+
tag = soup.find(name, attrs=attrs)
|
|
41
|
+
if tag and tag.get("content"):
|
|
42
|
+
if ai_field:
|
|
43
|
+
tag.attrs["data-ai-field"] = ai_field
|
|
44
|
+
return str(tag["content"]).strip()
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def extract_article(soup: BeautifulSoup, url: str, hint: PageHint) -> dict[str, Any]:
|
|
49
|
+
"""Extract Article fields from HTML, applying hint overrides last."""
|
|
50
|
+
if soup.body:
|
|
51
|
+
soup.body.attrs["data-ai-entity"] = "article"
|
|
52
|
+
data: dict[str, Any] = {}
|
|
53
|
+
|
|
54
|
+
title = _meta_content(
|
|
55
|
+
soup,
|
|
56
|
+
[
|
|
57
|
+
("meta", {"property": "og:title"}),
|
|
58
|
+
("meta", {"name": "twitter:title"}),
|
|
59
|
+
],
|
|
60
|
+
) or _first_text(
|
|
61
|
+
soup,
|
|
62
|
+
[
|
|
63
|
+
("h1", {}),
|
|
64
|
+
("title", {}),
|
|
65
|
+
],
|
|
66
|
+
)
|
|
67
|
+
if title:
|
|
68
|
+
data["headline"] = title
|
|
69
|
+
|
|
70
|
+
description = _meta_content(
|
|
71
|
+
soup,
|
|
72
|
+
[
|
|
73
|
+
("meta", {"name": "description"}),
|
|
74
|
+
("meta", {"property": "og:description"}),
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
if description:
|
|
78
|
+
data["description"] = description
|
|
79
|
+
|
|
80
|
+
image = _meta_content(soup, [("meta", {"property": "og:image"})])
|
|
81
|
+
if image:
|
|
82
|
+
data["image"] = image
|
|
83
|
+
|
|
84
|
+
author = _meta_content(
|
|
85
|
+
soup,
|
|
86
|
+
[
|
|
87
|
+
("meta", {"name": "author"}),
|
|
88
|
+
("meta", {"property": "article:author"}),
|
|
89
|
+
],
|
|
90
|
+
)
|
|
91
|
+
if author:
|
|
92
|
+
data["author"] = author
|
|
93
|
+
|
|
94
|
+
date_published = _meta_content(
|
|
95
|
+
soup,
|
|
96
|
+
[
|
|
97
|
+
("meta", {"property": "article:published_time"}),
|
|
98
|
+
("time", {"datetime": True}),
|
|
99
|
+
],
|
|
100
|
+
)
|
|
101
|
+
if date_published:
|
|
102
|
+
data["datePublished"] = date_published
|
|
103
|
+
|
|
104
|
+
data["url"] = url
|
|
105
|
+
|
|
106
|
+
# Developer overrides win.
|
|
107
|
+
data.update(hint.fields)
|
|
108
|
+
return data
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def extract_product(soup: BeautifulSoup, url: str, hint: PageHint) -> dict[str, Any]:
|
|
112
|
+
"""Extract Product fields from HTML, applying hint overrides last."""
|
|
113
|
+
if soup.body:
|
|
114
|
+
soup.body.attrs["data-ai-entity"] = "product"
|
|
115
|
+
|
|
116
|
+
# Inject action hint for add to cart
|
|
117
|
+
import re
|
|
118
|
+
btn = soup.find(string=re.compile(r"add to cart", re.I))
|
|
119
|
+
if btn and btn.parent:
|
|
120
|
+
btn.parent.attrs["data-ai-action"] = "add_to_cart"
|
|
121
|
+
|
|
122
|
+
data: dict[str, Any] = {}
|
|
123
|
+
|
|
124
|
+
name = _first_text(
|
|
125
|
+
soup,
|
|
126
|
+
[
|
|
127
|
+
("h1", {}),
|
|
128
|
+
("meta", {"itemprop": "name"}),
|
|
129
|
+
],
|
|
130
|
+
)
|
|
131
|
+
if name:
|
|
132
|
+
data["name"] = name
|
|
133
|
+
|
|
134
|
+
description = _meta_content(
|
|
135
|
+
soup,
|
|
136
|
+
[
|
|
137
|
+
("meta", {"name": "description"}),
|
|
138
|
+
("meta", {"property": "og:description"}),
|
|
139
|
+
],
|
|
140
|
+
)
|
|
141
|
+
if description:
|
|
142
|
+
data["description"] = description
|
|
143
|
+
|
|
144
|
+
image = _meta_content(soup, [("meta", {"property": "og:image"})])
|
|
145
|
+
if image:
|
|
146
|
+
data["image"] = image
|
|
147
|
+
|
|
148
|
+
# Price: prefer microdata itemprop, then scan for a currency pattern.
|
|
149
|
+
price = _extract_price(soup)
|
|
150
|
+
if price:
|
|
151
|
+
data["price"] = price
|
|
152
|
+
|
|
153
|
+
data["url"] = url
|
|
154
|
+
data.update(hint.fields)
|
|
155
|
+
return data
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _extract_price(soup: BeautifulSoup) -> str | None:
|
|
159
|
+
"""Best-effort price extraction → returns a numeric string like '29.99'.
|
|
160
|
+
|
|
161
|
+
Attempts to be locale-aware for common formats (US, EU, INR, GBP).
|
|
162
|
+
Returns None when no confident price is found.
|
|
163
|
+
"""
|
|
164
|
+
import re
|
|
165
|
+
|
|
166
|
+
def normalize_num(raw: str) -> str:
|
|
167
|
+
# Remove spaces and non-breaking spaces
|
|
168
|
+
s = raw.strip().replace('\xa0', '').replace(' ', '')
|
|
169
|
+
# If both '.' and ',' present, infer decimal separator by last occurrence
|
|
170
|
+
if '.' in s and ',' in s:
|
|
171
|
+
if s.rfind('.') > s.rfind(','):
|
|
172
|
+
# '.' likely decimal separator, remove grouping commas
|
|
173
|
+
s = s.replace(',', '')
|
|
174
|
+
else:
|
|
175
|
+
# ',' likely decimal separator
|
|
176
|
+
s = s.replace('.', '').replace(',', '.')
|
|
177
|
+
elif ',' in s:
|
|
178
|
+
# If comma followed by exactly 2 digits at end, treat as decimal
|
|
179
|
+
if re.search(r",\d{1,2}$", s):
|
|
180
|
+
s = s.replace(',', '.')
|
|
181
|
+
else:
|
|
182
|
+
# otherwise remove thousands separator commas
|
|
183
|
+
s = s.replace(',', '')
|
|
184
|
+
# else only dot present or only digits
|
|
185
|
+
# Strip any non-digit/non-dot characters
|
|
186
|
+
s = re.sub(r"[^0-9.]", "", s)
|
|
187
|
+
# Trim leading/trailing dots
|
|
188
|
+
s = s.strip('.')
|
|
189
|
+
return s
|
|
190
|
+
|
|
191
|
+
# 1) microdata itemprop price
|
|
192
|
+
itemprop = soup.find(itemprop="price")
|
|
193
|
+
if itemprop:
|
|
194
|
+
raw = str(itemprop.get("content") or itemprop.get_text(strip=True))
|
|
195
|
+
m = re.search(r"\d+[.,]?\d*", raw or "")
|
|
196
|
+
if m:
|
|
197
|
+
return normalize_num(m.group(0))
|
|
198
|
+
|
|
199
|
+
# 2) Symbol-based patterns (symbol before or after)
|
|
200
|
+
symbol_pattern_pre = re.compile(r"([$€£¥₹])\s?(\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})?)")
|
|
201
|
+
symbol_pattern_post = re.compile(r"(\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})?)\s?([$€£¥₹])")
|
|
202
|
+
for el in soup.find_all(string=True):
|
|
203
|
+
txt = str(el)
|
|
204
|
+
m = symbol_pattern_pre.search(txt)
|
|
205
|
+
if not m:
|
|
206
|
+
m = symbol_pattern_post.search(txt)
|
|
207
|
+
if m:
|
|
208
|
+
num = m.group(2) if len(m.groups()) >= 2 else m.group(1)
|
|
209
|
+
return normalize_num(num)
|
|
210
|
+
|
|
211
|
+
# 3) Currency code suffix/prefix e.g. '1000 INR' or 'EUR 1.234,56'
|
|
212
|
+
code_pattern = re.compile(r"(\d[\d.,]*)\s?(USD|EUR|GBP|INR)", re.IGNORECASE)
|
|
213
|
+
for el in soup.find_all(string=code_pattern):
|
|
214
|
+
m = code_pattern.search(str(el))
|
|
215
|
+
if m:
|
|
216
|
+
return normalize_num(m.group(1))
|
|
217
|
+
|
|
218
|
+
# 4) Fallback: any 1-4 digit group with decimals and currency nearby
|
|
219
|
+
generic = re.compile(r"\d+[.,]\d{2}")
|
|
220
|
+
for el in soup.find_all(string=generic):
|
|
221
|
+
txt = str(el)
|
|
222
|
+
# ensure there's a currency symbol or code nearby in the element
|
|
223
|
+
if re.search(r"[$€£¥₹]|USD|EUR|GBP|INR", txt, re.IGNORECASE):
|
|
224
|
+
m = generic.search(txt)
|
|
225
|
+
if m:
|
|
226
|
+
return normalize_num(m.group(0))
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def extract_profile(soup: BeautifulSoup, url: str, hint: PageHint) -> dict[str, Any]:
|
|
230
|
+
if soup.body:
|
|
231
|
+
soup.body.attrs["data-ai-entity"] = "profile"
|
|
232
|
+
data: dict[str, Any] = {}
|
|
233
|
+
name = _first_text(soup, [("h1", {}), ("h2", {})], "name")
|
|
234
|
+
if name:
|
|
235
|
+
data["name"] = name
|
|
236
|
+
job = _meta_content(soup, [("meta", {"name": "job_title"})], "jobTitle")
|
|
237
|
+
if job:
|
|
238
|
+
data["jobTitle"] = job
|
|
239
|
+
data["url"] = url
|
|
240
|
+
data.update(hint.fields)
|
|
241
|
+
return data
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Developer hints — the explicit override path.
|
|
2
|
+
|
|
3
|
+
When heuristics can't tell (or the developer knows better), the host app can
|
|
4
|
+
pass an explicit hint. Hints always win over heuristics and bypass the
|
|
5
|
+
confidence threshold.
|
|
6
|
+
|
|
7
|
+
Hints can be supplied via:
|
|
8
|
+
- Framework adapter API (view attribute / decorator / response header)
|
|
9
|
+
- A context dict passed to optimize_html()
|
|
10
|
+
|
|
11
|
+
All roads converge on a PageHint here.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from autoai_optimize.analyze.classifier import PageType
|
|
20
|
+
|
|
21
|
+
# Canonical header a framework adapter may set on a response.
|
|
22
|
+
HINT_HEADER = "X-AutoAI-Type"
|
|
23
|
+
|
|
24
|
+
# Fields a developer can supply to pre-populate a schema, e.g.
|
|
25
|
+
# hints={"type": "Product", "name": "Widget", "price": "29.99"}
|
|
26
|
+
_HINT_TYPE_KEY = "type"
|
|
27
|
+
_HINT_FIELD_KEY = "fields"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class PageHint:
|
|
32
|
+
"""An explicit developer-supplied page classification / field override."""
|
|
33
|
+
|
|
34
|
+
page_type: PageType | None
|
|
35
|
+
fields: dict[str, Any]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _coerce_type(value: Any) -> PageType | None:
|
|
39
|
+
if value is None:
|
|
40
|
+
return None
|
|
41
|
+
if isinstance(value, PageType):
|
|
42
|
+
return value
|
|
43
|
+
name = str(value).strip().capitalize()
|
|
44
|
+
try:
|
|
45
|
+
return PageType(name)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def parse_hints(raw: dict[str, Any] | None, html_content: str = "") -> PageHint:
|
|
51
|
+
"""Normalize a raw hints dict (from adapter or context) into a PageHint.
|
|
52
|
+
Also parses the HTML string for explicit frontend developer comments (e.g., <!-- @ai-entity:product -->).
|
|
53
|
+
"""
|
|
54
|
+
if not raw:
|
|
55
|
+
raw = {}
|
|
56
|
+
|
|
57
|
+
import re
|
|
58
|
+
if html_content:
|
|
59
|
+
hint_match = re.search(r'<!--\s*@ai-entity:(\w+)\s*-->', html_content)
|
|
60
|
+
if hint_match and _HINT_TYPE_KEY not in raw:
|
|
61
|
+
raw[_HINT_TYPE_KEY] = hint_match.group(1).lower()
|
|
62
|
+
|
|
63
|
+
if not raw:
|
|
64
|
+
return PageHint(page_type=None, fields={})
|
|
65
|
+
raw_type = raw.get(_HINT_TYPE_KEY)
|
|
66
|
+
nested_fields = raw.get(_HINT_FIELD_KEY, {})
|
|
67
|
+
fields: dict[str, Any] = dict(nested_fields) if isinstance(nested_fields, dict) else {}
|
|
68
|
+
# Flat extras (everything that isn't "type"/"fields") become fields.
|
|
69
|
+
for k, v in raw.items():
|
|
70
|
+
if k in (_HINT_TYPE_KEY, _HINT_FIELD_KEY):
|
|
71
|
+
continue
|
|
72
|
+
fields.setdefault(k, v)
|
|
73
|
+
return PageHint(page_type=_coerce_type(raw_type), fields=fields)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from bs4 import BeautifulSoup
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def detect_js_rendered(html: str) -> bool:
|
|
7
|
+
"""Heuristic to detect likely client-side rendered pages.
|
|
8
|
+
|
|
9
|
+
Returns True when the HTML appears to be a thin shell and relies on JS to
|
|
10
|
+
render meaningful content. This is a heuristic (not perfect) and intended
|
|
11
|
+
to warn users to use prerendering or the offload pre-scan pipeline.
|
|
12
|
+
"""
|
|
13
|
+
if not html:
|
|
14
|
+
return False
|
|
15
|
+
soup = BeautifulSoup(html, "html.parser")
|
|
16
|
+
body = soup.body
|
|
17
|
+
# Heuristic rules: very small body text, presence of a root app div, many
|
|
18
|
+
# <script> tags, or presence of known SPA markers.
|
|
19
|
+
text_len = len(body.get_text(strip=True)) if body else 0
|
|
20
|
+
scripts = len(soup.find_all("script"))
|
|
21
|
+
has_root_div = bool(soup.find(id=lambda x: x and str(x).lower() in ("app", "root", "__next", "svelte-app")))
|
|
22
|
+
# If body text is tiny and scripts are many, likely JS heavy.
|
|
23
|
+
if text_len < 50 and scripts >= 2 and has_root_div:
|
|
24
|
+
return True
|
|
25
|
+
# If the only text is a loader / noscript fallback, treat as JS-rendered.
|
|
26
|
+
if body and body.find("noscript") and text_len < 100:
|
|
27
|
+
return True
|
|
28
|
+
return False
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Configuration for autoai-optimize.
|
|
2
|
+
|
|
3
|
+
Zero-config by default: sensible thresholds so the library does the right
|
|
4
|
+
thing out of the box. Override only what you need.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class Config:
|
|
14
|
+
"""Runtime configuration.
|
|
15
|
+
|
|
16
|
+
Attributes:
|
|
17
|
+
enabled: Master switch. When False, optimize_html returns input untouched.
|
|
18
|
+
min_confidence: Minimum detection confidence (0.0–1.0) to emit schema.
|
|
19
|
+
Below this the library stays silent — wrong schema can trigger
|
|
20
|
+
Google penalties, so silence is always safe.
|
|
21
|
+
allow_paths: Optional allow-list of URL path prefixes (e.g. ["/blog/"]).
|
|
22
|
+
When set, only matching paths are processed. Empty = process all.
|
|
23
|
+
deny_paths: Deny-list of URL path prefixes. Checked before allow_paths.
|
|
24
|
+
inject_existing: If True and a page already has JSON-LD of the same
|
|
25
|
+
@type, leave it untouched (idempotent). Recommended True.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
enabled: bool = True
|
|
29
|
+
min_confidence: float = 0.5
|
|
30
|
+
allow_paths: tuple[str, ...] = ()
|
|
31
|
+
deny_paths: tuple[str, ...] = ()
|
|
32
|
+
# When True, requires paths listed in sensitive_paths to be explicitly
|
|
33
|
+
# present in allow_paths before processing. Useful for large sites where
|
|
34
|
+
# certain prefixes (e.g., /admin/, /user/) must never be auto-scanned
|
|
35
|
+
# unless explicitly opted-in.
|
|
36
|
+
require_explicit_opt_in: bool = False
|
|
37
|
+
sensitive_paths: tuple[str, ...] = ("/admin/", "/dashboard/", "/user/")
|
|
38
|
+
inject_existing: bool = True
|
|
39
|
+
api_key: str | None = None
|
|
40
|
+
webhook_url: str = "https://api.autoai-optimize.com/webhook"
|
|
41
|
+
ai_endpoint: str = "/api/ai"
|
|
42
|
+
# Reserved for future per-schema enable flags; kept to avoid config churn.
|
|
43
|
+
_reserved: tuple[str, ...] = field(default=())
|
|
44
|
+
|
|
45
|
+
def path_allowed(self, path: str) -> bool:
|
|
46
|
+
"""Return True if `path` should be processed under this config.
|
|
47
|
+
|
|
48
|
+
Order of checks:
|
|
49
|
+
1. deny_paths (deny overrides everything)
|
|
50
|
+
2. if require_explicit_opt_in and path matches a sensitive prefix -> only
|
|
51
|
+
allowed if present in allow_paths
|
|
52
|
+
3. allow_paths (if empty, allow all non-denied)
|
|
53
|
+
"""
|
|
54
|
+
if any(path.startswith(p) for p in self.deny_paths):
|
|
55
|
+
return False
|
|
56
|
+
|
|
57
|
+
if self.require_explicit_opt_in and any(path.startswith(p) for p in self.sensitive_paths):
|
|
58
|
+
# Only allowed when the path is explicitly whitelisted.
|
|
59
|
+
return any(path.startswith(p) for p in self.allow_paths)
|
|
60
|
+
|
|
61
|
+
if not self.allow_paths:
|
|
62
|
+
return True
|
|
63
|
+
return any(path.startswith(p) for p in self.allow_paths)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
DEFAULT_CONFIG = Config()
|