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.
@@ -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()