web-intel-mcp 0.1.0__tar.gz

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,70 @@
1
+ License text copyright (c) 2020 MariaDB Corporation Ab, All Rights Reserved.
2
+ "Business Source License" is a trademark of MariaDB Corporation Ab.
3
+
4
+ Parameters
5
+
6
+ Licensor: ArceeDev (KeyFlint, https://keyflint.dev, legal@keyflint.dev)
7
+ Licensed Work: Web Intel MCP (web-intel-mcp), version 0.1.0 or later.
8
+ The Licensed Work is (c) 2026 ArceeDev.
9
+ Additional Use Grant: You may make production use of the Licensed Work:
10
+ (a) with a valid KeyFlint license for the Licensed Work
11
+ obtained from the Licensor (https://mcp.keyflint.dev),
12
+ with tool calls metered through the Licensor's
13
+ KeyFlint gateway; or
14
+ (b) for personal, educational, research or evaluation
15
+ purposes that are not commercial.
16
+ This grant does not cover removing, disabling or
17
+ bypassing the license verification or credit metering,
18
+ nor offering the Licensed Work, or a modified version of
19
+ it, to third parties on a hosted, embedded or resold
20
+ basis.
21
+ Change Date: 2030-11-05
22
+ Change License: Apache License, Version 2.0
23
+
24
+ For information about alternative licensing arrangements for the Licensed Work,
25
+ please contact the Licensor at legal@keyflint.dev or https://keyflint.dev.
26
+
27
+ Notice
28
+
29
+ Business Source License 1.1
30
+
31
+ Terms
32
+
33
+ The Licensor hereby grants you the right to copy, modify, create derivative
34
+ works, redistribute, and make non-production use of the Licensed Work. The
35
+ Licensor may make an Additional Use Grant, above, permitting limited production use.
36
+
37
+ Effective on the Change Date, or the fourth anniversary of the first publicly
38
+ available distribution of a specific version of the Licensed Work under this
39
+ License, whichever comes first, the Licensor hereby grants you rights under
40
+ the terms of the Change License, and the rights granted in the paragraph
41
+ above terminate.
42
+
43
+ If your use of the Licensed Work does not comply with the requirements
44
+ currently in effect as described in this License, you must purchase a
45
+ commercial license from the Licensor, its affiliated entities, or authorized
46
+ resellers, or you must refrain from using the Licensed Work.
47
+
48
+ All copies of the original and modified Licensed Work, and derivative works
49
+ of the Licensed Work, are subject to this License. This License applies
50
+ separately for each version of the Licensed Work and the Change Date may vary
51
+ for each version of the Licensed Work released by Licensor.
52
+
53
+ You must conspicuously display this License on each original or modified copy
54
+ of the Licensed Work. If you receive the Licensed Work in original or
55
+ modified form from a third party, the terms and conditions set forth in this
56
+ License apply to your use of that work.
57
+
58
+ Any use of the Licensed Work in violation of this License will automatically
59
+ terminate your rights under this License for the current and all other
60
+ versions of the Licensed Work.
61
+
62
+ This License does not grant you any right in any trademark or logo of
63
+ Licensor or its affiliates (provided that you may use a trademark or logo of
64
+ Licensor as expressly required by this License).
65
+
66
+ TO THE EXTENT PERMITTED BY APPLICABLE LAW, THE LICENSED WORK IS PROVIDED ON
67
+ AN "AS IS" BASIS. LICENSOR HEREBY DISCLAIMS ALL WARRANTIES AND CONDITIONS,
68
+ EXPRESS OR IMPLIED, INCLUDING (WITHOUT LIMITATION) WARRANTIES OF
69
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND
70
+ TITLE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.4
2
+ Name: web-intel-mcp
3
+ Version: 0.1.0
4
+ Summary: Metered MCP server: web pages to clean Markdown, metadata, links, SEO audits and tech stack. Billed per call with KeyFlint credits.
5
+ Author: ArceeDev
6
+ License-Expression: BUSL-1.1
7
+ Project-URL: Homepage, https://github.com/ArceeDev/web-intel-mcp
8
+ Project-URL: Source, https://github.com/ArceeDev/web-intel-mcp
9
+ Keywords: mcp,seo,markdown,scraping,agents
10
+ Requires-Python: >=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: mcp>=2.0
14
+ Requires-Dist: keyflint-verify[fast]>=1.2.0
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: beautifulsoup4>=4.12
17
+ Requires-Dist: markdownify>=0.13
18
+ Provides-Extra: test
19
+ Requires-Dist: pytest>=8; extra == "test"
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
21
+ Dynamic: license-file
22
+
23
+ # Web Intel MCP
24
+
25
+ Agent-ready web intelligence as a pay-per-call MCP server. Licensing and credits are handled by
26
+ [KeyFlint](https://keyflint.dev) (offline Ed25519 license check + credit ledger), used here only as the billing backend. Failed fetches are refunded
27
+ automatically; invalid input is rejected before any debit.
28
+
29
+ | Tool | Credits | Returns |
30
+ |---|---|---|
31
+ | `read_url` | 1 | Main content as clean Markdown (nav, footer, cookie banners removed) |
32
+ | `page_metadata` | 1 | Title, description, canonical, lang, robots, Open Graph, JSON-LD types, H1-H3, image alt stats |
33
+ | `extract_links` | 1 | Deduplicated internal / external links with anchor text |
34
+ | `tech_stack` | 2 | Frameworks, CMS, analytics, payments, CDN/hosting, security headers |
35
+ | `seo_audit` | 3 | 0-100 score, A-F grade, 16 weighted checks, top 5 fixes |
36
+ | `compare_seo` | 3 x URLs | Ranks 2-5 pages (you vs competitors); only fetched URLs are billed |
37
+ | `pricing`, `credits_balance` | free | Price list, remaining credits |
38
+
39
+ Prices are overridable: `WEB_INTEL_PRICES="seo_audit=5,read_url=2"`.
40
+
41
+ ## Install
42
+
43
+ All you need is your KeyFlint license (`LIC-...`). The gateway URL, its public key and the checkout link have defaults.
44
+
45
+ **Claude Desktop, one click:** download `web-intel-mcp-X.Y.Z.mcpb` from the [latest release](https://github.com/ArceeDev/web-intel-mcp/releases/latest), double-click it and paste your license. No terminal, no JSON.
46
+
47
+ **Claude Code:**
48
+
49
+ ```bash
50
+ claude mcp add web-intel -e KEYFLINT_LICENSE=LIC-... -- uvx web-intel-mcp
51
+ ```
52
+
53
+ **Cursor, Windsurf, Claude Desktop (JSON):**
54
+
55
+ ```json
56
+ {
57
+ "mcpServers": {
58
+ "web-intel": {
59
+ "command": "uvx",
60
+ "args": ["web-intel-mcp"],
61
+ "env": { "KEYFLINT_LICENSE": "LIC-..." }
62
+ }
63
+ }
64
+ }
65
+ ```
66
+
67
+ Optional: `KEYFLINT_URL`, `KEYFLINT_PUBLIC_KEYS` (fetched from the gateway and cached in `~/.keyflint/keyring.json` when unset), `KEYFLINT_CHECKOUT_URL`.
68
+
69
+ ## Hosted mode (sellers)
70
+
71
+ Paid logic stays on your server. One process bills many buyers; each
72
+ request carries the buyer's license.
73
+
74
+ ```bash
75
+ docker build -t web-intel .
76
+ docker run -p 8080:8080 \
77
+ web-intel
78
+ ```
79
+
80
+ Buyers connect to `https://<host>/mcp` (Streamable HTTP, stateless) with
81
+ `Authorization: Bearer LIC-...` (or `X-License-Key`). Missing or forged licenses get `-32060`.
82
+
83
+ ## Billing flow per call
84
+
85
+ 1. URL validated (http/https only). Bad input: error, nothing charged.
86
+ 2. Offline Ed25519 license check (`keyflint-verify`), cached 60 s.
87
+ 3. `POST /api/v1/quota/deduct` with a fresh idempotency key. Out of credits: `-32062` with the checkout link.
88
+ 4. Fetch + analysis. If anything fails: `POST /api/v1/quota/refund` (same key), the client gets `isError`.
89
+
90
+ ## Safety
91
+
92
+ Requests to private, loopback and link-local addresses are refused (checked on every redirect hop),
93
+ responses are capped at 3 MB (`WEB_INTEL_MAX_BYTES`), 15 s timeout (`WEB_INTEL_TIMEOUT`), max 5 redirects.
94
+ `WEB_INTEL_ALLOW_PRIVATE=1` disables the address check (tests only).
95
+
96
+ ## Tests
97
+
98
+ End-to-end with a fake KeyFlint quota gateway, locally signed licenses and a local test site
99
+ (no network needed):
100
+
101
+ ```bash
102
+ pip install -e ".[test]"
103
+ python -m pytest -q
104
+ ```
105
+
106
+ ## License
107
+
108
+ Business Source License 1.1. Source available; production use needs a KeyFlint license (or must be non-commercial). Each version becomes Apache 2.0 four years after release. See [LICENSE](LICENSE).
@@ -0,0 +1,86 @@
1
+ # Web Intel MCP
2
+
3
+ Agent-ready web intelligence as a pay-per-call MCP server. Licensing and credits are handled by
4
+ [KeyFlint](https://keyflint.dev) (offline Ed25519 license check + credit ledger), used here only as the billing backend. Failed fetches are refunded
5
+ automatically; invalid input is rejected before any debit.
6
+
7
+ | Tool | Credits | Returns |
8
+ |---|---|---|
9
+ | `read_url` | 1 | Main content as clean Markdown (nav, footer, cookie banners removed) |
10
+ | `page_metadata` | 1 | Title, description, canonical, lang, robots, Open Graph, JSON-LD types, H1-H3, image alt stats |
11
+ | `extract_links` | 1 | Deduplicated internal / external links with anchor text |
12
+ | `tech_stack` | 2 | Frameworks, CMS, analytics, payments, CDN/hosting, security headers |
13
+ | `seo_audit` | 3 | 0-100 score, A-F grade, 16 weighted checks, top 5 fixes |
14
+ | `compare_seo` | 3 x URLs | Ranks 2-5 pages (you vs competitors); only fetched URLs are billed |
15
+ | `pricing`, `credits_balance` | free | Price list, remaining credits |
16
+
17
+ Prices are overridable: `WEB_INTEL_PRICES="seo_audit=5,read_url=2"`.
18
+
19
+ ## Install
20
+
21
+ All you need is your KeyFlint license (`LIC-...`). The gateway URL, its public key and the checkout link have defaults.
22
+
23
+ **Claude Desktop, one click:** download `web-intel-mcp-X.Y.Z.mcpb` from the [latest release](https://github.com/ArceeDev/web-intel-mcp/releases/latest), double-click it and paste your license. No terminal, no JSON.
24
+
25
+ **Claude Code:**
26
+
27
+ ```bash
28
+ claude mcp add web-intel -e KEYFLINT_LICENSE=LIC-... -- uvx web-intel-mcp
29
+ ```
30
+
31
+ **Cursor, Windsurf, Claude Desktop (JSON):**
32
+
33
+ ```json
34
+ {
35
+ "mcpServers": {
36
+ "web-intel": {
37
+ "command": "uvx",
38
+ "args": ["web-intel-mcp"],
39
+ "env": { "KEYFLINT_LICENSE": "LIC-..." }
40
+ }
41
+ }
42
+ }
43
+ ```
44
+
45
+ Optional: `KEYFLINT_URL`, `KEYFLINT_PUBLIC_KEYS` (fetched from the gateway and cached in `~/.keyflint/keyring.json` when unset), `KEYFLINT_CHECKOUT_URL`.
46
+
47
+ ## Hosted mode (sellers)
48
+
49
+ Paid logic stays on your server. One process bills many buyers; each
50
+ request carries the buyer's license.
51
+
52
+ ```bash
53
+ docker build -t web-intel .
54
+ docker run -p 8080:8080 \
55
+ web-intel
56
+ ```
57
+
58
+ Buyers connect to `https://<host>/mcp` (Streamable HTTP, stateless) with
59
+ `Authorization: Bearer LIC-...` (or `X-License-Key`). Missing or forged licenses get `-32060`.
60
+
61
+ ## Billing flow per call
62
+
63
+ 1. URL validated (http/https only). Bad input: error, nothing charged.
64
+ 2. Offline Ed25519 license check (`keyflint-verify`), cached 60 s.
65
+ 3. `POST /api/v1/quota/deduct` with a fresh idempotency key. Out of credits: `-32062` with the checkout link.
66
+ 4. Fetch + analysis. If anything fails: `POST /api/v1/quota/refund` (same key), the client gets `isError`.
67
+
68
+ ## Safety
69
+
70
+ Requests to private, loopback and link-local addresses are refused (checked on every redirect hop),
71
+ responses are capped at 3 MB (`WEB_INTEL_MAX_BYTES`), 15 s timeout (`WEB_INTEL_TIMEOUT`), max 5 redirects.
72
+ `WEB_INTEL_ALLOW_PRIVATE=1` disables the address check (tests only).
73
+
74
+ ## Tests
75
+
76
+ End-to-end with a fake KeyFlint quota gateway, locally signed licenses and a local test site
77
+ (no network needed):
78
+
79
+ ```bash
80
+ pip install -e ".[test]"
81
+ python -m pytest -q
82
+ ```
83
+
84
+ ## License
85
+
86
+ Business Source License 1.1. Source available; production use needs a KeyFlint license (or must be non-commercial). Each version becomes Apache 2.0 four years after release. See [LICENSE](LICENSE).
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "web-intel-mcp"
7
+ version = "0.1.0"
8
+ description = "Metered MCP server: web pages to clean Markdown, metadata, links, SEO audits and tech stack. Billed per call with KeyFlint credits."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "BUSL-1.1"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "ArceeDev" }]
14
+ keywords = ["mcp", "seo", "markdown", "scraping", "agents"]
15
+ dependencies = [
16
+ "mcp>=2.0",
17
+ "keyflint-verify[fast]>=1.2.0",
18
+ "httpx>=0.27",
19
+ "beautifulsoup4>=4.12",
20
+ "markdownify>=0.13",
21
+ ]
22
+
23
+ [project.optional-dependencies]
24
+ test = ["pytest>=8", "pytest-asyncio>=0.23"]
25
+
26
+ [project.scripts]
27
+ web-intel-mcp = "web_intel_mcp.server:main"
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/ArceeDev/web-intel-mcp"
31
+ Source = "https://github.com/ArceeDev/web-intel-mcp"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.pytest.ini_options]
37
+ asyncio_mode = "auto"
38
+ asyncio_default_fixture_loop_scope = "function"
39
+ pythonpath = ["src", "tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Web Intel: a pay-per-call MCP server for web content, SEO and tech-stack intelligence."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
@@ -0,0 +1,186 @@
1
+ """Pure HTML analysis (no network): reader markdown, metadata, links, SEO audit, tech stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from typing import Any, Optional
8
+ from urllib.parse import urljoin, urlsplit
9
+
10
+ from bs4 import BeautifulSoup, Tag
11
+ from markdownify import markdownify
12
+
13
+ from .fetch import Page
14
+
15
+ _NOISE = ["script", "style", "noscript", "template", "svg", "iframe", "form", "nav", "header", "footer", "aside"]
16
+ _NOISE_HINT = re.compile(r"(cookie|consent|banner|newsletter|subscribe|share|social|sidebar|breadcrumb|menu|popup|modal|advert|promo)", re.I)
17
+
18
+
19
+ def soup_of(page: Page) -> BeautifulSoup:
20
+ return BeautifulSoup(page.text, "html.parser")
21
+
22
+
23
+ def _meta(soup: BeautifulSoup, *names: str) -> Optional[str]:
24
+ for n in names:
25
+ tag = soup.find("meta", attrs={"name": n}) or soup.find("meta", attrs={"property": n})
26
+ if isinstance(tag, Tag) and tag.get("content"):
27
+ return str(tag["content"]).strip()
28
+ return None
29
+
30
+
31
+ def _main_node(soup: BeautifulSoup) -> Tag:
32
+ for sel in ("main", "article", "[role=main]", "#content", ".content", ".post", ".entry-content"):
33
+ node = soup.select_one(sel)
34
+ if node and len(node.get_text(strip=True)) > 200:
35
+ return node
36
+ return soup.body or soup
37
+
38
+
39
+ def to_markdown(page: Page, max_chars: int = 20000) -> dict[str, Any]:
40
+ soup = soup_of(page)
41
+ title = (soup.title.get_text(strip=True) if soup.title else None) or _meta(soup, "og:title")
42
+ for t in soup(_NOISE):
43
+ t.decompose()
44
+ for t in soup.find_all(attrs={"class": _NOISE_HINT}) + soup.find_all(attrs={"id": _NOISE_HINT}):
45
+ if isinstance(t, Tag) and t.name not in ("body", "main", "article", "html"):
46
+ t.decompose()
47
+ node = _main_node(soup)
48
+ for a in node.find_all("a", href=True):
49
+ a["href"] = urljoin(page.final_url, str(a["href"]))
50
+ for img in node.find_all("img", src=True):
51
+ img["src"] = urljoin(page.final_url, str(img["src"]))
52
+ md = markdownify(str(node), heading_style="ATX", bullets="-", strip=["span"])
53
+ md = re.sub(r"\n{3,}", "\n\n", md).strip()
54
+ words = len(md.split())
55
+ truncated = len(md) > max_chars
56
+ return {"url": page.final_url, "title": title, "word_count": words,
57
+ "truncated": truncated or page.truncated, "markdown": md[:max_chars]}
58
+
59
+
60
+ def _jsonld_types(soup: BeautifulSoup) -> list[str]:
61
+ out: list[str] = []
62
+ for s in soup.find_all("script", attrs={"type": "application/ld+json"}):
63
+ try:
64
+ data = json.loads(s.string or "")
65
+ except (ValueError, TypeError):
66
+ continue
67
+ stack = data if isinstance(data, list) else [data]
68
+ while stack:
69
+ item = stack.pop()
70
+ if isinstance(item, dict):
71
+ t = item.get("@type")
72
+ out.extend(t if isinstance(t, list) else [t] if t else [])
73
+ stack.extend(item.get("@graph") or [])
74
+ return sorted({str(t) for t in out})
75
+
76
+
77
+ def metadata(page: Page) -> dict[str, Any]:
78
+ soup = soup_of(page)
79
+ canon = soup.find("link", rel="canonical")
80
+ html = soup.find("html")
81
+ headings = {f"h{i}": [h.get_text(" ", strip=True)[:160] for h in soup.find_all(f"h{i}")][:20] for i in (1, 2, 3)}
82
+ imgs = soup.find_all("img")
83
+ return {
84
+ "url": page.final_url, "status": page.status, "elapsed_ms": page.elapsed_ms, "redirects": page.redirects,
85
+ "title": soup.title.get_text(strip=True) if soup.title else None,
86
+ "description": _meta(soup, "description"),
87
+ "canonical": canon.get("href") if isinstance(canon, Tag) else None,
88
+ "lang": html.get("lang") if isinstance(html, Tag) else None,
89
+ "robots": _meta(soup, "robots"),
90
+ "open_graph": {k: _meta(soup, f"og:{k}") for k in ("title", "description", "image", "type", "url")},
91
+ "twitter_card": _meta(soup, "twitter:card"),
92
+ "jsonld_types": _jsonld_types(soup),
93
+ "headings": headings,
94
+ "images": {"total": len(imgs), "missing_alt": sum(1 for i in imgs if not (i.get("alt") or "").strip())},
95
+ "word_count": len(soup.get_text(" ", strip=True).split()),
96
+ }
97
+
98
+
99
+ def links(page: Page, limit: int = 200) -> dict[str, Any]:
100
+ soup = soup_of(page)
101
+ host = urlsplit(page.final_url).hostname
102
+ internal: dict[str, str] = {}
103
+ external: dict[str, str] = {}
104
+ for a in soup.find_all("a", href=True):
105
+ href = str(a["href"]).strip()
106
+ if href.startswith(("#", "mailto:", "tel:", "javascript:")):
107
+ continue
108
+ absu = urljoin(page.final_url, href).split("#")[0]
109
+ bucket = internal if urlsplit(absu).hostname == host else external
110
+ bucket.setdefault(absu, a.get_text(" ", strip=True)[:120])
111
+ fmt = lambda d: [{"url": u, "text": t} for u, t in list(d.items())[:limit]] # noqa: E731
112
+ return {"url": page.final_url, "internal_count": len(internal), "external_count": len(external),
113
+ "internal": fmt(internal), "external": fmt(external)}
114
+
115
+
116
+ def seo_audit(page: Page) -> dict[str, Any]:
117
+ m = metadata(page)
118
+ checks: list[dict[str, Any]] = []
119
+
120
+ def add(cid: str, ok: bool, weight: int, fix: str, value: Any = None) -> None:
121
+ checks.append({"id": cid, "pass": ok, "weight": weight, "value": value, "fix": None if ok else fix})
122
+
123
+ title, desc = m["title"] or "", m["description"] or ""
124
+ add("status_200", m["status"] == 200, 10, "Serve the page with HTTP 200.", m["status"])
125
+ add("https", page.final_url.startswith("https://"), 8, "Serve over HTTPS and redirect HTTP to it.")
126
+ add("title_present", bool(title), 10, "Add a <title>.", title)
127
+ add("title_length", 30 <= len(title) <= 60, 5, "Keep the title between 30 and 60 characters.", len(title))
128
+ add("description_present", bool(desc), 8, "Add <meta name=description>.", desc[:160] or None)
129
+ add("description_length", 70 <= len(desc) <= 160, 4, "Keep the description between 70 and 160 characters.", len(desc))
130
+ add("single_h1", len(m["headings"]["h1"]) == 1, 7, "Use exactly one <h1>.", len(m["headings"]["h1"]))
131
+ add("canonical", bool(m["canonical"]), 5, "Add <link rel=canonical>.", m["canonical"])
132
+ add("lang", bool(m["lang"]), 3, "Set <html lang>.", m["lang"])
133
+ add("indexable", "noindex" not in (m["robots"] or "").lower()
134
+ and "noindex" not in page.headers.get("x-robots-tag", "").lower(), 10, "Remove noindex.", m["robots"])
135
+ add("open_graph", bool(m["open_graph"]["title"] and m["open_graph"]["image"]), 5,
136
+ "Add og:title and og:image for link previews.")
137
+ add("structured_data", bool(m["jsonld_types"]), 6, "Add JSON-LD (Organization, Product, Article, FAQPage...).",
138
+ m["jsonld_types"])
139
+ add("img_alt", m["images"]["missing_alt"] == 0, 4, "Add alt text to every meaningful image.", m["images"])
140
+ add("content_depth", m["word_count"] >= 300, 6, "Thin content: aim for 300+ words of real text.", m["word_count"])
141
+ add("viewport", bool(re.search(r'<meta[^>]+name=["\']viewport', page.text, re.I)), 5, "Add a viewport meta tag.")
142
+ add("fast_response", m["elapsed_ms"] < 1500, 4, "Server response is slow (>1.5 s).", m["elapsed_ms"])
143
+ total = sum(c["weight"] for c in checks)
144
+ score = round(100 * sum(c["weight"] for c in checks if c["pass"]) / total)
145
+ fixes = sorted((c for c in checks if not c["pass"]), key=lambda c: -c["weight"])
146
+ grade = next(g for g, floor in (("A", 90), ("B", 80), ("C", 70), ("D", 60), ("F", 0)) if score >= floor)
147
+ return {"url": page.final_url, "score": score, "grade": grade, "top_fixes": [c["fix"] for c in fixes[:5]], "checks": checks}
148
+
149
+
150
+ _SIGNATURES: list[tuple[str, str, str]] = [
151
+ # (name, where: html|header, regex)
152
+ ("Next.js", "html", r"/_next/|__NEXT_DATA__"), ("Nuxt", "html", r"/_nuxt/|__NUXT__"),
153
+ ("React", "html", r"data-reactroot|react-dom"), ("Vue", "html", r"data-v-[0-9a-f]{6}|vue(\.min)?\.js"),
154
+ ("Svelte", "html", r"svelte-[a-z0-9]{5,}"), ("Astro", "html", r"astro-island|/_astro/"),
155
+ ("Angular", "html", r"ng-version="), ("Gatsby", "html", r"___gatsby"), ("Remix", "html", r"__remixContext"),
156
+ ("WordPress", "html", r"wp-content|wp-includes"), ("Shopify", "html", r"cdn\.shopify\.com|Shopify\.theme"),
157
+ ("Webflow", "html", r"webflow\.(js|css)|data-wf-page"), ("Wix", "html", r"static\.wixstatic\.com"),
158
+ ("Squarespace", "html", r"squarespace\.com"), ("Framer", "html", r"framerusercontent\.com"),
159
+ ("Ghost", "html", r'content="Ghost'), ("Tailwind CSS", "html", r'class="[^"]*\b(?:flex|grid) [^"]*\b(?:px|py|mt|mb)-\d'),
160
+ ("Bootstrap", "html", r"bootstrap(\.min)?\.(css|js)"), ("jQuery", "html", r"jquery(\.min)?\.js"),
161
+ ("Google Analytics", "html", r"googletagmanager\.com/gtag|google-analytics\.com"),
162
+ ("Google Tag Manager", "html", r"googletagmanager\.com/gtm\.js"), ("Plausible", "html", r"plausible\.io/js"),
163
+ ("PostHog", "html", r"posthog"), ("Segment", "html", r"cdn\.segment\.com"), ("Hotjar", "html", r"hotjar\.com"),
164
+ ("Intercom", "html", r"widget\.intercom\.io"), ("Crisp", "html", r"client\.crisp\.chat"),
165
+ ("Stripe", "html", r"js\.stripe\.com"), ("Lemon Squeezy", "html", r"lemonsqueezy"),
166
+ ("Cloudflare", "header", r"^server: cloudflare|^cf-ray:"), ("Vercel", "header", r"^server: vercel|^x-vercel-id:"),
167
+ ("Netlify", "header", r"^server: netlify|^x-nf-request-id:"), ("Fly.io", "header", r"^fly-request-id:"),
168
+ ("nginx", "header", r"^server: nginx"), ("Apache", "header", r"^server: apache"),
169
+ ("AWS CloudFront", "header", r"^x-amz-cf-id:|^via: .*cloudfront"), ("PHP", "header", r"^x-powered-by: php"),
170
+ ("Express", "header", r"^x-powered-by: express"),
171
+ ]
172
+
173
+
174
+ def tech_stack(page: Page) -> dict[str, Any]:
175
+ hdr = "\n".join(f"{k}: {v}" for k, v in page.headers.items()).lower()
176
+ found = []
177
+ for name, where, rx in _SIGNATURES:
178
+ hay = page.text if where == "html" else hdr
179
+ if re.search(rx, hay, re.I | re.M):
180
+ found.append({"name": name, "evidence": where})
181
+ gen = re.search(r'<meta[^>]+name=["\']generator["\'][^>]+content=["\']([^"\']+)', page.text, re.I)
182
+ sec = {h: page.headers.get(h) is not None for h in
183
+ ("strict-transport-security", "content-security-policy", "x-frame-options", "x-content-type-options",
184
+ "referrer-policy")}
185
+ return {"url": page.final_url, "technologies": found, "generator": gen.group(1) if gen else None,
186
+ "security_headers": sec, "server": page.headers.get("server")}