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.
Files changed (79) hide show
  1. ranq/__init__.py +4 -0
  2. ranq/analysis/__init__.py +0 -0
  3. ranq/analysis/aeo.py +250 -0
  4. ranq/analysis/competitors.py +88 -0
  5. ranq/analysis/framework.py +62 -0
  6. ranq/analysis/keywords.py +539 -0
  7. ranq/analysis/pagetype.py +188 -0
  8. ranq/analysis/performance.py +154 -0
  9. ranq/analysis/structured_data.py +258 -0
  10. ranq/analysis/suggest.py +69 -0
  11. ranq/audit.py +89 -0
  12. ranq/checks/__init__.py +12 -0
  13. ranq/checks/aeo.py +179 -0
  14. ranq/checks/base.py +124 -0
  15. ranq/checks/crawl.py +385 -0
  16. ranq/checks/geo.py +285 -0
  17. ranq/checks/keywords.py +34 -0
  18. ranq/checks/onpage.py +431 -0
  19. ranq/checks/performance.py +114 -0
  20. ranq/checks/structured_data.py +239 -0
  21. ranq/checks/technical.py +757 -0
  22. ranq/cli.py +1208 -0
  23. ranq/config.py +170 -0
  24. ranq/crawler/__init__.py +0 -0
  25. ranq/crawler/crawler.py +305 -0
  26. ranq/crawler/parser.py +261 -0
  27. ranq/crawler/renderer.py +52 -0
  28. ranq/crawler/sitemaps.py +131 -0
  29. ranq/enrichers.py +67 -0
  30. ranq/fix/__init__.py +0 -0
  31. ranq/fix/bundle.py +199 -0
  32. ranq/generators/__init__.py +0 -0
  33. ranq/generators/framework.py +277 -0
  34. ranq/generators/headers.py +140 -0
  35. ranq/generators/llms.py +150 -0
  36. ranq/generators/markdown.py +216 -0
  37. ranq/generators/meta.py +131 -0
  38. ranq/generators/redirects.py +233 -0
  39. ranq/generators/robots.py +61 -0
  40. ranq/generators/schema.py +469 -0
  41. ranq/generators/sitemap.py +80 -0
  42. ranq/geo/__init__.py +0 -0
  43. ranq/geo/ai_bots.py +202 -0
  44. ranq/geo/analysis.py +79 -0
  45. ranq/geo/citation.py +125 -0
  46. ranq/geo/commoncrawl.py +61 -0
  47. ranq/geo/entity.py +363 -0
  48. ranq/geo/extras.py +85 -0
  49. ranq/geo/visibility.py +230 -0
  50. ranq/http.py +202 -0
  51. ranq/indexing/__init__.py +0 -0
  52. ranq/indexing/bing.py +61 -0
  53. ranq/indexing/diagnose.py +231 -0
  54. ranq/indexing/engines.py +85 -0
  55. ranq/indexing/gsc.py +177 -0
  56. ranq/indexing/indexnow.py +118 -0
  57. ranq/models.py +275 -0
  58. ranq/offpage/__init__.py +0 -0
  59. ranq/offpage/broken_links.py +85 -0
  60. ranq/offpage/checklist.py +129 -0
  61. ranq/offpage/mentions.py +140 -0
  62. ranq/py.typed +0 -0
  63. ranq/report/__init__.py +0 -0
  64. ranq/report/builder.py +223 -0
  65. ranq/report/compare.py +113 -0
  66. ranq/report/limitations.py +49 -0
  67. ranq/report/render.py +71 -0
  68. ranq/report/templates/report.html.j2 +273 -0
  69. ranq/report/templates/report.md.j2 +177 -0
  70. ranq/robots.py +189 -0
  71. ranq/sources.py +47 -0
  72. ranq/storage.py +210 -0
  73. ranq/urls.py +81 -0
  74. ranq/wizard.py +121 -0
  75. ranq-0.3.0.dist-info/METADATA +322 -0
  76. ranq-0.3.0.dist-info/RECORD +79 -0
  77. ranq-0.3.0.dist-info/WHEEL +4 -0
  78. ranq-0.3.0.dist-info/entry_points.txt +2 -0
  79. ranq-0.3.0.dist-info/licenses/LICENSE +21 -0
ranq/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ """Ranq: free, open-source SEO / AEO / GEO auditing and optimization CLI."""
2
+
3
+ __version__ = "0.3.0"
4
+ USER_AGENT = f"Ranq/{__version__} (+https://github.com/Dreadonyx/ranq)"
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