ranq 0.3.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.
- ranq/__init__.py +4 -0
- ranq/analysis/__init__.py +0 -0
- ranq/analysis/aeo.py +250 -0
- ranq/analysis/competitors.py +88 -0
- ranq/analysis/framework.py +62 -0
- ranq/analysis/keywords.py +539 -0
- ranq/analysis/pagetype.py +188 -0
- ranq/analysis/performance.py +154 -0
- ranq/analysis/structured_data.py +258 -0
- ranq/analysis/suggest.py +69 -0
- ranq/audit.py +89 -0
- ranq/checks/__init__.py +12 -0
- ranq/checks/aeo.py +179 -0
- ranq/checks/base.py +124 -0
- ranq/checks/crawl.py +385 -0
- ranq/checks/geo.py +285 -0
- ranq/checks/keywords.py +34 -0
- ranq/checks/onpage.py +431 -0
- ranq/checks/performance.py +114 -0
- ranq/checks/structured_data.py +239 -0
- ranq/checks/technical.py +757 -0
- ranq/cli.py +1208 -0
- ranq/config.py +170 -0
- ranq/crawler/__init__.py +0 -0
- ranq/crawler/crawler.py +305 -0
- ranq/crawler/parser.py +261 -0
- ranq/crawler/renderer.py +52 -0
- ranq/crawler/sitemaps.py +131 -0
- ranq/enrichers.py +67 -0
- ranq/fix/__init__.py +0 -0
- ranq/fix/bundle.py +199 -0
- ranq/generators/__init__.py +0 -0
- ranq/generators/framework.py +277 -0
- ranq/generators/headers.py +140 -0
- ranq/generators/llms.py +150 -0
- ranq/generators/markdown.py +216 -0
- ranq/generators/meta.py +131 -0
- ranq/generators/redirects.py +233 -0
- ranq/generators/robots.py +61 -0
- ranq/generators/schema.py +469 -0
- ranq/generators/sitemap.py +80 -0
- ranq/geo/__init__.py +0 -0
- ranq/geo/ai_bots.py +202 -0
- ranq/geo/analysis.py +79 -0
- ranq/geo/citation.py +125 -0
- ranq/geo/commoncrawl.py +61 -0
- ranq/geo/entity.py +363 -0
- ranq/geo/extras.py +85 -0
- ranq/geo/visibility.py +230 -0
- ranq/http.py +202 -0
- ranq/indexing/__init__.py +0 -0
- ranq/indexing/bing.py +61 -0
- ranq/indexing/diagnose.py +231 -0
- ranq/indexing/engines.py +85 -0
- ranq/indexing/gsc.py +177 -0
- ranq/indexing/indexnow.py +118 -0
- ranq/models.py +275 -0
- ranq/offpage/__init__.py +0 -0
- ranq/offpage/broken_links.py +85 -0
- ranq/offpage/checklist.py +129 -0
- ranq/offpage/mentions.py +140 -0
- ranq/py.typed +0 -0
- ranq/report/__init__.py +0 -0
- ranq/report/builder.py +223 -0
- ranq/report/compare.py +113 -0
- ranq/report/limitations.py +49 -0
- ranq/report/render.py +71 -0
- ranq/report/templates/report.html.j2 +273 -0
- ranq/report/templates/report.md.j2 +177 -0
- ranq/robots.py +189 -0
- ranq/sources.py +47 -0
- ranq/storage.py +210 -0
- ranq/urls.py +81 -0
- ranq/wizard.py +121 -0
- ranq-0.3.0.dist-info/METADATA +322 -0
- ranq-0.3.0.dist-info/RECORD +79 -0
- ranq-0.3.0.dist-info/WHEEL +4 -0
- ranq-0.3.0.dist-info/entry_points.txt +2 -0
- ranq-0.3.0.dist-info/licenses/LICENSE +21 -0
ranq/__init__.py
ADDED
|
File without changes
|
ranq/analysis/aeo.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Answer Engine Optimization: question coverage, concise answer blocks, FAQ sections, snippet
|
|
2
|
+
formatting and E-E-A-T signals. Everything is derived from the page's own text."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
from urllib.parse import urlsplit
|
|
10
|
+
|
|
11
|
+
from ranq.analysis import pagetype as pt
|
|
12
|
+
from ranq.analysis.keywords import KeywordReport
|
|
13
|
+
from ranq.models import PageData
|
|
14
|
+
|
|
15
|
+
QUESTION_START = re.compile(
|
|
16
|
+
r"^(what|how|why|when|where|who|whom|which|can|could|does|do|did|is|are|was|should|will|"
|
|
17
|
+
r"would|has|have)\b",
|
|
18
|
+
re.I,
|
|
19
|
+
)
|
|
20
|
+
FAQ_HEADING = re.compile(
|
|
21
|
+
r"\b(faq|faqs|frequently asked|common questions|questions and answers)\b", re.I
|
|
22
|
+
)
|
|
23
|
+
DEFINITION = re.compile(r"\b(is|are|refers to|means)\s+(a|an|the)\b", re.I)
|
|
24
|
+
IDEAL = (40, 60)
|
|
25
|
+
|
|
26
|
+
TEMPLATES = {
|
|
27
|
+
"informational": ["What is {kw}?", "How does {kw} work?", "Why is {kw} important?"],
|
|
28
|
+
"commercial": [
|
|
29
|
+
"What is the best {kw}?",
|
|
30
|
+
"How do I choose a {kw}?",
|
|
31
|
+
"{Kw} vs alternatives: which is better?",
|
|
32
|
+
],
|
|
33
|
+
"transactional": ["How much does {kw} cost?", "Where can I get {kw}?", "Is {kw} worth it?"],
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Calls to action phrased as questions ("Ready to build something?"). They speak to the
|
|
38
|
+
# visitor rather than ask anything a searcher types, so they are not answer opportunities.
|
|
39
|
+
CTA_QUESTION = re.compile(
|
|
40
|
+
r"^(?:ready|want|need|looking for|interested|curious|still|got|questions|"
|
|
41
|
+
r"have (?:a|an|any) (?:project|idea|question)s?|have (?:any )?questions|"
|
|
42
|
+
r"(?:how )?can we help|shall we|let'?s)\b",
|
|
43
|
+
re.I,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_question(text: str) -> bool:
|
|
48
|
+
text = text.strip()
|
|
49
|
+
if CTA_QUESTION.match(text) or len(text.split()) < 2:
|
|
50
|
+
return False
|
|
51
|
+
return text.endswith("?") or bool(QUESTION_START.match(text))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(slots=True)
|
|
55
|
+
class QA:
|
|
56
|
+
question: str
|
|
57
|
+
answer: str
|
|
58
|
+
words: int
|
|
59
|
+
list_items: int = 0 # answered with a list (snippet-friendly for steps / rankings)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def ideal(self) -> bool:
|
|
63
|
+
return IDEAL[0] <= self.words <= IDEAL[1] or self.list_items >= 3
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(slots=True)
|
|
67
|
+
class PageAEO:
|
|
68
|
+
url: str
|
|
69
|
+
questions: list[str] = field(default_factory=list) # existing question headings
|
|
70
|
+
suggested_questions: list[str] = field(default_factory=list) # template-based
|
|
71
|
+
qa: list[QA] = field(default_factory=list)
|
|
72
|
+
drafts: dict[str, str] = field(default_factory=dict) # question -> trimmed draft from page text
|
|
73
|
+
has_faq: bool = False
|
|
74
|
+
has_definition: bool = False
|
|
75
|
+
has_steps: bool = False
|
|
76
|
+
has_table: bool = False
|
|
77
|
+
is_article: bool = False
|
|
78
|
+
eeat: dict[str, bool] = field(default_factory=dict)
|
|
79
|
+
author: str | None = None
|
|
80
|
+
published: str | None = None
|
|
81
|
+
modified: str | None = None
|
|
82
|
+
citations: int = 0
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def answer_blocks(self) -> int:
|
|
86
|
+
return sum(1 for q in self.qa if q.ideal)
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
return {
|
|
90
|
+
"url": self.url,
|
|
91
|
+
"questions": self.questions + [f"(suggested) {q}" for q in self.suggested_questions],
|
|
92
|
+
"question_headings": len(self.questions),
|
|
93
|
+
"answer_blocks": self.answer_blocks,
|
|
94
|
+
"answers": [
|
|
95
|
+
{
|
|
96
|
+
"question": q.question,
|
|
97
|
+
"words": q.words,
|
|
98
|
+
"list_items": q.list_items,
|
|
99
|
+
"ideal": q.ideal,
|
|
100
|
+
}
|
|
101
|
+
for q in self.qa
|
|
102
|
+
],
|
|
103
|
+
"drafts": self.drafts,
|
|
104
|
+
"has_faq": self.has_faq,
|
|
105
|
+
"has_definition": self.has_definition,
|
|
106
|
+
"has_steps": self.has_steps,
|
|
107
|
+
"has_table": self.has_table,
|
|
108
|
+
"is_article": self.is_article,
|
|
109
|
+
"eeat": self.eeat,
|
|
110
|
+
"author": self.author,
|
|
111
|
+
"published": self.published,
|
|
112
|
+
"modified": self.modified,
|
|
113
|
+
"citations": self.citations,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(slots=True)
|
|
118
|
+
class SiteEEAT:
|
|
119
|
+
about: str | None = None
|
|
120
|
+
contact: str | None = None
|
|
121
|
+
privacy: str | None = None
|
|
122
|
+
terms: str | None = None
|
|
123
|
+
|
|
124
|
+
def to_dict(self) -> dict[str, Any]:
|
|
125
|
+
return {
|
|
126
|
+
"about": self.about,
|
|
127
|
+
"contact": self.contact,
|
|
128
|
+
"privacy": self.privacy,
|
|
129
|
+
"terms": self.terms,
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(slots=True)
|
|
134
|
+
class AEOReport:
|
|
135
|
+
pages: list[PageAEO] = field(default_factory=list)
|
|
136
|
+
site: SiteEEAT = field(default_factory=SiteEEAT)
|
|
137
|
+
|
|
138
|
+
def to_dict(self) -> dict[str, Any]:
|
|
139
|
+
return {
|
|
140
|
+
"pages": [p.to_dict() for p in self.pages],
|
|
141
|
+
"site": self.site.to_dict(),
|
|
142
|
+
"note": "Suggested questions are templates built from extracted keywords; answer "
|
|
143
|
+
"drafts are trimmed from your own page text. Edit both before publishing.",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _sections(page: PageData) -> list[tuple[str, list[tuple[str, str]]]]:
|
|
148
|
+
sections: list[tuple[str, list[tuple[str, str]]]] = []
|
|
149
|
+
for tag, text in page.outline:
|
|
150
|
+
if tag == "h1": # the H1 is the page topic; its 'answer' is the whole page
|
|
151
|
+
sections.append(("", []))
|
|
152
|
+
elif tag in ("h2", "h3", "h4", "h5", "h6", "summary", "dt"):
|
|
153
|
+
sections.append((text, []))
|
|
154
|
+
elif sections:
|
|
155
|
+
sections[-1][1].append((tag, text))
|
|
156
|
+
return sections
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def draft_answer(text: str, max_words: int = IDEAL[1]) -> str:
|
|
160
|
+
"""Take whole sentences from the page text up to ~60 words. Never invents content."""
|
|
161
|
+
out: list[str] = []
|
|
162
|
+
count = 0
|
|
163
|
+
for sentence in pt.sentences(text):
|
|
164
|
+
n = len(sentence.split())
|
|
165
|
+
if out and count + n > max_words:
|
|
166
|
+
break
|
|
167
|
+
out.append(sentence)
|
|
168
|
+
count += n
|
|
169
|
+
draft = " ".join(out)
|
|
170
|
+
words = draft.split()
|
|
171
|
+
return " ".join(words[:max_words]) + ("…" if len(words) > max_words else "")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def analyze_page(page: PageData, primary: str | None, intent: str) -> PageAEO:
|
|
175
|
+
result = PageAEO(url=page.url, is_article=pt.is_article(page))
|
|
176
|
+
for heading, body in _sections(page):
|
|
177
|
+
if not heading:
|
|
178
|
+
continue
|
|
179
|
+
if not is_question(heading):
|
|
180
|
+
if FAQ_HEADING.search(heading):
|
|
181
|
+
result.has_faq = True
|
|
182
|
+
continue
|
|
183
|
+
result.questions.append(heading)
|
|
184
|
+
paragraphs = [text for tag, text in body if tag in ("p", "dd")]
|
|
185
|
+
items = [text for tag, text in body if tag == "li"]
|
|
186
|
+
if not paragraphs and not items:
|
|
187
|
+
continue
|
|
188
|
+
if not paragraphs or (body and body[0][0] == "li"):
|
|
189
|
+
qa = QA(heading, " / ".join(items), sum(len(i.split()) for i in items), len(items))
|
|
190
|
+
else:
|
|
191
|
+
qa = QA(heading, paragraphs[0], len(paragraphs[0].split()), len(items))
|
|
192
|
+
result.qa.append(qa)
|
|
193
|
+
if not qa.ideal and paragraphs:
|
|
194
|
+
draft = draft_answer(" ".join(paragraphs))
|
|
195
|
+
if len(draft.split()) >= 15 and draft != qa.answer:
|
|
196
|
+
result.drafts[heading] = draft
|
|
197
|
+
if heading.lower().startswith("how") and any(tag == "li" for tag, _ in body):
|
|
198
|
+
result.has_steps = True
|
|
199
|
+
types = pt.schema_types(page)
|
|
200
|
+
result.has_faq = result.has_faq or "FAQPage" in types or page.has_details
|
|
201
|
+
result.has_table = page.tables_count > 0
|
|
202
|
+
intro = " ".join(text for tag, text in page.outline[:6] if tag == "p")
|
|
203
|
+
result.has_definition = bool(DEFINITION.search(intro))
|
|
204
|
+
if primary and intent in ("informational", "commercial", "transactional"):
|
|
205
|
+
existing = " ".join(result.questions).lower()
|
|
206
|
+
for template in TEMPLATES[intent]:
|
|
207
|
+
q = template.format(kw=primary, Kw=primary[:1].upper() + primary[1:])
|
|
208
|
+
if primary.lower() not in existing or q.lower().split()[0] not in existing:
|
|
209
|
+
result.suggested_questions.append(q)
|
|
210
|
+
result.author = pt.author_name(page)
|
|
211
|
+
result.published = pt.published_date(page)
|
|
212
|
+
result.modified = pt.modified_date(page)
|
|
213
|
+
result.citations = len(pt.citations(page))
|
|
214
|
+
result.eeat = {
|
|
215
|
+
"author": bool(result.author),
|
|
216
|
+
"author_page": bool(pt.author_url(page)),
|
|
217
|
+
"date": bool(result.published or result.modified),
|
|
218
|
+
"citations": result.citations > 0,
|
|
219
|
+
}
|
|
220
|
+
return result
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
SITE_PAGES = {
|
|
224
|
+
"about": re.compile(r"/(about|about-us|company|who-we-are|team)(/|$|\.html)", re.I),
|
|
225
|
+
"contact": re.compile(r"/(contact|contact-us|support|get-in-touch)(/|$|\.html)", re.I),
|
|
226
|
+
"privacy": re.compile(r"/(privacy|privacy-policy|datenschutz)(/|$|\.html)", re.I),
|
|
227
|
+
"terms": re.compile(r"/(terms|terms-of-service|tos|legal|imprint|impressum)(/|$|\.html)", re.I),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def site_eeat(pages: list[PageData]) -> SiteEEAT:
|
|
232
|
+
site = SiteEEAT()
|
|
233
|
+
known = {link.url for p in pages for link in p.internal_links} | {p.url for p in pages}
|
|
234
|
+
for key, pattern in SITE_PAGES.items():
|
|
235
|
+
match = next((u for u in sorted(known) if pattern.search(urlsplit(u).path)), None)
|
|
236
|
+
setattr(site, key, match)
|
|
237
|
+
return site
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def analyze(pages: list[PageData], keywords: KeywordReport | None) -> AEOReport:
|
|
241
|
+
kw = {p.url: p for p in keywords.pages} if keywords else {}
|
|
242
|
+
report = AEOReport(site=site_eeat(pages))
|
|
243
|
+
for page in pages:
|
|
244
|
+
if not page.is_indexable:
|
|
245
|
+
continue
|
|
246
|
+
info = kw.get(page.url)
|
|
247
|
+
report.pages.append(
|
|
248
|
+
analyze_page(page, info.primary if info else None, info.intent if info else "unknown")
|
|
249
|
+
)
|
|
250
|
+
return report
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Content gap against user-supplied competitor URLs (fetched politely, robots.txt respected)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ranq.analysis.keywords import STOPWORDS, page_terms
|
|
10
|
+
from ranq.crawler.parser import parse_html
|
|
11
|
+
from ranq.http import PoliteClient, RobotsDisallowed
|
|
12
|
+
from ranq.models import PageData
|
|
13
|
+
from ranq.urls import normalize
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class GapReport:
|
|
18
|
+
competitors: dict[str, str] = field(default_factory=dict) # url -> status/error
|
|
19
|
+
gaps: list[dict[str, Any]] = field(default_factory=list)
|
|
20
|
+
|
|
21
|
+
def to_dict(self) -> dict[str, Any]:
|
|
22
|
+
return {
|
|
23
|
+
"competitors": self.competitors,
|
|
24
|
+
"gaps": self.gaps,
|
|
25
|
+
"note": "Terms that appear prominently on competitor pages you supplied but nowhere "
|
|
26
|
+
"on your crawled pages. Relevance is for you to judge; no volumes implied.",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def fetch_page(client: PoliteClient, url: str) -> tuple[PageData | None, str]:
|
|
31
|
+
target = normalize(url)
|
|
32
|
+
if target is None:
|
|
33
|
+
return None, "invalid URL"
|
|
34
|
+
try:
|
|
35
|
+
result = await client.fetch(target)
|
|
36
|
+
except RobotsDisallowed:
|
|
37
|
+
return None, "blocked by robots.txt (respected)"
|
|
38
|
+
if result.error or result.status != 200:
|
|
39
|
+
return None, result.error or f"HTTP {result.status}"
|
|
40
|
+
page = PageData(
|
|
41
|
+
url=target,
|
|
42
|
+
final_url=result.final_url,
|
|
43
|
+
status=200,
|
|
44
|
+
headers=result.headers,
|
|
45
|
+
content_type=result.headers.get("content-type", ""),
|
|
46
|
+
)
|
|
47
|
+
if not page.is_html:
|
|
48
|
+
return None, "not HTML"
|
|
49
|
+
return parse_html(page, result.text, target), "ok"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def content_gap(
|
|
53
|
+
client: PoliteClient, own_pages: list[PageData], competitor_urls: list[str], top: int = 80
|
|
54
|
+
) -> GapReport:
|
|
55
|
+
report = GapReport()
|
|
56
|
+
own: Counter[str] = Counter()
|
|
57
|
+
for p in own_pages:
|
|
58
|
+
own.update(page_terms(p))
|
|
59
|
+
weights: dict[str, float] = defaultdict(float)
|
|
60
|
+
seen_in: Counter[str] = Counter()
|
|
61
|
+
for url in competitor_urls:
|
|
62
|
+
page, status = await fetch_page(client, url)
|
|
63
|
+
report.competitors[url] = status
|
|
64
|
+
if page is None:
|
|
65
|
+
continue
|
|
66
|
+
terms = page_terms(page)
|
|
67
|
+
total = sum(terms.values()) or 1
|
|
68
|
+
for term, count in terms.items():
|
|
69
|
+
if term in own or count < 2:
|
|
70
|
+
continue
|
|
71
|
+
words = term.split()
|
|
72
|
+
if len(words) == 1 and (len(term) < 4 or term in STOPWORDS):
|
|
73
|
+
continue
|
|
74
|
+
weights[term] += count / total * 1000 * (1 + 0.5 * (len(words) - 1))
|
|
75
|
+
seen_in[term] += 1
|
|
76
|
+
ranked = sorted(weights, key=lambda t: (-seen_in[t], -weights[t]))
|
|
77
|
+
# Drop sub-phrases already covered by a higher-ranked longer phrase.
|
|
78
|
+
chosen: list[str] = []
|
|
79
|
+
for term in ranked:
|
|
80
|
+
if any(term in longer and term != longer for longer in chosen):
|
|
81
|
+
continue
|
|
82
|
+
chosen.append(term)
|
|
83
|
+
if len(chosen) >= top:
|
|
84
|
+
break
|
|
85
|
+
report.gaps = [
|
|
86
|
+
{"term": t, "competitors": seen_in[t], "score": round(weights[t], 2)} for t in chosen
|
|
87
|
+
]
|
|
88
|
+
return report
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Detect the site's framework or platform from its HTML and headers, so fixes can say where
|
|
2
|
+
each file goes (app/robots.ts in Next.js, static/ in Hugo, an SEO plugin in WordPress...)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from ranq.models import PageData
|
|
10
|
+
|
|
11
|
+
GENERATOR_RE = re.compile(r"<meta[^>]+name=[\"']generator[\"'][^>]+content=[\"']([^\"']+)", re.I)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(slots=True)
|
|
15
|
+
class Framework:
|
|
16
|
+
key: str # stable id used by the fix generator, e.g. "nextjs-app"
|
|
17
|
+
name: str # display name, e.g. "Next.js (App Router)"
|
|
18
|
+
evidence: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# (key, name, needles in HTML, generator prefix) in priority order: the more specific first.
|
|
22
|
+
SIGNATURES: list[tuple[str, str, tuple[str, ...], str | None]] = [
|
|
23
|
+
("nextjs-app", "Next.js (App Router)", ("self.__next_f",), None),
|
|
24
|
+
("nextjs-pages", "Next.js (Pages Router)", ("__NEXT_DATA__",), None),
|
|
25
|
+
("nextjs-app", "Next.js", ("/_next/static/",), None),
|
|
26
|
+
("nuxt", "Nuxt", ("__NUXT__", "/_nuxt/"), "Nuxt"),
|
|
27
|
+
("astro", "Astro", ("astro-island", "/_astro/"), "Astro"),
|
|
28
|
+
("sveltekit", "SvelteKit", ("__sveltekit", "data-sveltekit"), None),
|
|
29
|
+
("remix", "Remix / React Router", ("__remixContext", "__reactRouterContext"), None),
|
|
30
|
+
("gatsby", "Gatsby", ("___gatsby",), "Gatsby"),
|
|
31
|
+
("docusaurus", "Docusaurus", ("__docusaurus",), "Docusaurus"),
|
|
32
|
+
("wordpress", "WordPress", ("/wp-content/", "/wp-includes/"), "WordPress"),
|
|
33
|
+
("shopify", "Shopify", ("cdn.shopify.com",), None),
|
|
34
|
+
("wix", "Wix", ("static.wixstatic.com",), "Wix"),
|
|
35
|
+
("squarespace", "Squarespace", ("static1.squarespace.com",), None),
|
|
36
|
+
("webflow", "Webflow", ("data-wf-site",), "Webflow"),
|
|
37
|
+
("framer", "Framer", ("framerusercontent.com",), "Framer"),
|
|
38
|
+
("ghost", "Ghost", (), "Ghost"),
|
|
39
|
+
("hugo", "Hugo", (), "Hugo"),
|
|
40
|
+
("jekyll", "Jekyll", (), "Jekyll"),
|
|
41
|
+
("eleventy", "Eleventy", (), "Eleventy"),
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def detect(page: PageData | None) -> Framework | None:
|
|
46
|
+
if page is None or not page.html:
|
|
47
|
+
return None
|
|
48
|
+
html = page.html
|
|
49
|
+
match = GENERATOR_RE.search(html[:20000])
|
|
50
|
+
generator = match.group(1) if match else ""
|
|
51
|
+
vary = page.headers.get("vary", "").lower()
|
|
52
|
+
for key, name, needles, gen in SIGNATURES:
|
|
53
|
+
if gen and generator.lower().startswith(gen.lower()):
|
|
54
|
+
return Framework(key, name, f'<meta name="generator" content="{generator}">')
|
|
55
|
+
hit = next((n for n in needles if n in html), None)
|
|
56
|
+
if hit:
|
|
57
|
+
if key == "nextjs-app" and name == "Next.js" and "next-router-state-tree" not in vary:
|
|
58
|
+
return Framework("nextjs", "Next.js", f"'{hit}' in the HTML")
|
|
59
|
+
return Framework(key, name, f"'{hit}' in the HTML")
|
|
60
|
+
if "rsc" in vary and "next-router" in vary:
|
|
61
|
+
return Framework("nextjs-app", "Next.js (App Router)", f"Vary: {vary}")
|
|
62
|
+
return None
|