linkrank 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.
linkrank-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 KlientLab (klientlab.ru)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkrank
3
+ Version: 0.1.0
4
+ Summary: Internal PageRank (Link Score) & keyword-cannibalization auditor — a free, open-source crawler. No paid APIs.
5
+ Author-email: KlientLab <dmitrievdm@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/trendbender/linkrank
8
+ Project-URL: Repository, https://github.com/trendbender/linkrank
9
+ Project-URL: Bug Tracker, https://github.com/trendbender/linkrank/issues
10
+ Project-URL: Made by, https://klientlab.ru/tools/
11
+ Keywords: seo,internal-links,pagerank,link-score,cannibalization,crawler,site-audit,orphan-pages
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: requests>=2.28
24
+ Requires-Dist: selectolax>=0.3
25
+ Requires-Dist: networkx>=3.0
26
+ Requires-Dist: scikit-learn>=1.2
27
+ Dynamic: license-file
28
+
29
+ # linkrank
30
+
31
+ **Internal PageRank (Link Score) & keyword-cannibalization auditor — free & open-source.**
32
+
33
+ `linkrank` crawls a website, builds its **internal link graph**, computes **internal PageRank on a
34
+ 0–100 “Link Score” scale** (the same idea behind Screaming Frog’s *Link Score* and OnCrawl’s *InRank*),
35
+ and flags **keyword cannibalization** and **orphan pages** — all locally, with **no paid APIs**.
36
+
37
+ It answers questions that usually require paid tools:
38
+
39
+ - Which pages does my internal linking actually **concentrate authority on** — and which are starved?
40
+ - Which pages are **orphans** (zero internal inlinks) or buried too deep?
41
+ - Where is my site **cannibalizing itself** (one anchor → many targets, duplicate titles, near-duplicate content)?
42
+
43
+ > Made by [KlientLab](https://klientlab.ru/tools/). MIT licensed.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install linkrank
49
+ ```
50
+
51
+ ## Quick start
52
+
53
+ ```bash
54
+ linkrank https://example.com
55
+ ```
56
+
57
+ Outputs a machine-readable **JSON** and a self-contained **HTML report** into `./reports/`:
58
+
59
+ ```
60
+ === Internal link audit: https://example.com/ ===
61
+ pages=253 internal_links=1653 orphans=39 deep(>=4)=39 kw_clusters=11
62
+
63
+ Top 10 by Link Score:
64
+ 100.0 in= 253 https://example.com/
65
+ 28.2 in= 37 https://example.com/category-a/
66
+ ...
67
+ JSON: reports/internal-link-audit-example-com-2026-07-06.json
68
+ HTML: reports/internal-link-audit-example-com-2026-07-06.html
69
+ ```
70
+
71
+ ## Options
72
+
73
+ ```
74
+ linkrank <start_url> [options]
75
+ --max-pages N cap fully-crawled pages (default 2000)
76
+ --depth N max crawl depth from the start URL (default 10)
77
+ --timeout S per-request timeout, seconds (default 12)
78
+ --workers N concurrent requests (default 10)
79
+ --sim-threshold F content-similarity cutoff for cannibalization (default 0.80)
80
+ --include-subdomains treat subdomains as internal
81
+ --out DIR output directory (default ./reports)
82
+ --json-only skip the HTML report
83
+ ```
84
+
85
+ ## What it reports
86
+
87
+ - **Link Score (0–100)** per page — log-scaled internal PageRank, comparable to Screaming Frog.
88
+ - **Inlinks / outlinks**, click-**depth** from the homepage, **orphan** pages (0 internal inlinks).
89
+ - **Cannibalization**, three signals:
90
+ 1. one **anchor text → multiple target URLs**;
91
+ 2. pages sharing the same **primary keyword** (normalized `<title>`/`<h1>`);
92
+ 3. **near-duplicate content** (TF-IDF cosine ≥ threshold).
93
+
94
+ ## Methodology
95
+
96
+ - **Internal PageRank** — power iteration, damping factor `α = 0.85` (Page & Brin, 1998). Each page
97
+ passes its score divided by its number of followed out-links; `rel="nofollow"` links **evaporate**
98
+ (counted in out-degree but transfer no score); dangling nodes are redistributed uniformly. The raw
99
+ PageRank is reported both directly and as a **0–100 logarithmic Link Score** (min–max on `log`),
100
+ matching the way Screaming Frog *Link Score* and OnCrawl *InRank* present internal authority.
101
+ - **Graph** — nodes are 200-OK internal HTML URLs; a page’s **canonical** target is used as its node
102
+ identity when that target is itself a valid page; redirects are collapsed onto their final URL.
103
+ - **Cannibalization** — anchor-to-many-targets from the crawl, primary-keyword clustering from
104
+ `<title>`/`<h1>`, and TF-IDF cosine similarity of body text.
105
+
106
+ References: Page, Brin, Motwani & Winograd, *The PageRank Citation Ranking* (1998); Screaming Frog
107
+ *Link Score*; OnCrawl *InRank*.
108
+
109
+ ## Requirements
110
+
111
+ Python ≥ 3.10. Dependencies (installed automatically): `requests`, `selectolax`, `networkx`,
112
+ `scikit-learn`.
113
+
114
+ ## Notes & limits
115
+
116
+ - Respects `robots.txt`; skips non-HTML, `mailto:`/`tel:`/anchors and asset URLs.
117
+ - Deterministic output (stable sort) — runs are diffable over time.
118
+ - Enforces a hard page cap and per-request timeout — it will not hang on large/slow sites.
119
+ - Static-HTML crawler: links injected purely client-side by JavaScript may not be seen. Server-rendered
120
+ `<a href>` links (the vast majority) are captured.
121
+
122
+ ## Contributing
123
+
124
+ Issues and PRs welcome. Ideas on the roadmap: JS rendering flag, GSC query×page cannibalization signal,
125
+ interactive graph export.
126
+
127
+ ## License
128
+
129
+ MIT © 2026 [KlientLab](https://klientlab.ru/tools/).
@@ -0,0 +1,101 @@
1
+ # linkrank
2
+
3
+ **Internal PageRank (Link Score) & keyword-cannibalization auditor — free & open-source.**
4
+
5
+ `linkrank` crawls a website, builds its **internal link graph**, computes **internal PageRank on a
6
+ 0–100 “Link Score” scale** (the same idea behind Screaming Frog’s *Link Score* and OnCrawl’s *InRank*),
7
+ and flags **keyword cannibalization** and **orphan pages** — all locally, with **no paid APIs**.
8
+
9
+ It answers questions that usually require paid tools:
10
+
11
+ - Which pages does my internal linking actually **concentrate authority on** — and which are starved?
12
+ - Which pages are **orphans** (zero internal inlinks) or buried too deep?
13
+ - Where is my site **cannibalizing itself** (one anchor → many targets, duplicate titles, near-duplicate content)?
14
+
15
+ > Made by [KlientLab](https://klientlab.ru/tools/). MIT licensed.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pip install linkrank
21
+ ```
22
+
23
+ ## Quick start
24
+
25
+ ```bash
26
+ linkrank https://example.com
27
+ ```
28
+
29
+ Outputs a machine-readable **JSON** and a self-contained **HTML report** into `./reports/`:
30
+
31
+ ```
32
+ === Internal link audit: https://example.com/ ===
33
+ pages=253 internal_links=1653 orphans=39 deep(>=4)=39 kw_clusters=11
34
+
35
+ Top 10 by Link Score:
36
+ 100.0 in= 253 https://example.com/
37
+ 28.2 in= 37 https://example.com/category-a/
38
+ ...
39
+ JSON: reports/internal-link-audit-example-com-2026-07-06.json
40
+ HTML: reports/internal-link-audit-example-com-2026-07-06.html
41
+ ```
42
+
43
+ ## Options
44
+
45
+ ```
46
+ linkrank <start_url> [options]
47
+ --max-pages N cap fully-crawled pages (default 2000)
48
+ --depth N max crawl depth from the start URL (default 10)
49
+ --timeout S per-request timeout, seconds (default 12)
50
+ --workers N concurrent requests (default 10)
51
+ --sim-threshold F content-similarity cutoff for cannibalization (default 0.80)
52
+ --include-subdomains treat subdomains as internal
53
+ --out DIR output directory (default ./reports)
54
+ --json-only skip the HTML report
55
+ ```
56
+
57
+ ## What it reports
58
+
59
+ - **Link Score (0–100)** per page — log-scaled internal PageRank, comparable to Screaming Frog.
60
+ - **Inlinks / outlinks**, click-**depth** from the homepage, **orphan** pages (0 internal inlinks).
61
+ - **Cannibalization**, three signals:
62
+ 1. one **anchor text → multiple target URLs**;
63
+ 2. pages sharing the same **primary keyword** (normalized `<title>`/`<h1>`);
64
+ 3. **near-duplicate content** (TF-IDF cosine ≥ threshold).
65
+
66
+ ## Methodology
67
+
68
+ - **Internal PageRank** — power iteration, damping factor `α = 0.85` (Page & Brin, 1998). Each page
69
+ passes its score divided by its number of followed out-links; `rel="nofollow"` links **evaporate**
70
+ (counted in out-degree but transfer no score); dangling nodes are redistributed uniformly. The raw
71
+ PageRank is reported both directly and as a **0–100 logarithmic Link Score** (min–max on `log`),
72
+ matching the way Screaming Frog *Link Score* and OnCrawl *InRank* present internal authority.
73
+ - **Graph** — nodes are 200-OK internal HTML URLs; a page’s **canonical** target is used as its node
74
+ identity when that target is itself a valid page; redirects are collapsed onto their final URL.
75
+ - **Cannibalization** — anchor-to-many-targets from the crawl, primary-keyword clustering from
76
+ `<title>`/`<h1>`, and TF-IDF cosine similarity of body text.
77
+
78
+ References: Page, Brin, Motwani & Winograd, *The PageRank Citation Ranking* (1998); Screaming Frog
79
+ *Link Score*; OnCrawl *InRank*.
80
+
81
+ ## Requirements
82
+
83
+ Python ≥ 3.10. Dependencies (installed automatically): `requests`, `selectolax`, `networkx`,
84
+ `scikit-learn`.
85
+
86
+ ## Notes & limits
87
+
88
+ - Respects `robots.txt`; skips non-HTML, `mailto:`/`tel:`/anchors and asset URLs.
89
+ - Deterministic output (stable sort) — runs are diffable over time.
90
+ - Enforces a hard page cap and per-request timeout — it will not hang on large/slow sites.
91
+ - Static-HTML crawler: links injected purely client-side by JavaScript may not be seen. Server-rendered
92
+ `<a href>` links (the vast majority) are captured.
93
+
94
+ ## Contributing
95
+
96
+ Issues and PRs welcome. Ideas on the roadmap: JS rendering flag, GSC query×page cannibalization signal,
97
+ interactive graph export.
98
+
99
+ ## License
100
+
101
+ MIT © 2026 [KlientLab](https://klientlab.ru/tools/).
@@ -0,0 +1,10 @@
1
+ """linkrank — internal PageRank (Link Score) & cannibalization auditor.
2
+
3
+ Open-source crawler that builds a site's internal link graph, computes internal
4
+ PageRank on a 0–100 Link Score scale (Screaming Frog / OnCrawl InRank style), and
5
+ detects keyword cannibalization — no paid APIs required.
6
+ """
7
+ from .core import main
8
+
9
+ __version__ = "0.1.0"
10
+ __all__ = ["main", "__version__"]
@@ -0,0 +1,4 @@
1
+ from .core import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -0,0 +1,478 @@
1
+ #!/usr/bin/python3
2
+ """linkrank — internal linking map, internal PageRank (Link Score) & cannibalization.
3
+
4
+ Crawls one site, builds the internal link graph, computes internal PageRank on a 0-100
5
+ logarithmic Link Score scale (Screaming Frog / OnCrawl InRank style), and detects keyword
6
+ cannibalization from (a) content/title similarity and (b) — optionally — GSC query×page data.
7
+
8
+ Methodology refs: Screaming Frog "Link Score"; OnCrawl "InRank"; PageRank (Page & Brin, 1998);
9
+ cannibalization via GSC query+page pairs with severity = (pos gap < 3 AND impression ratio > 0.5).
10
+
11
+ Usage: python3 internal-link-audit.py <start_url> [--max-pages N] [--depth N]
12
+ [--sim-threshold F] [--include-subdomains] [--out DIR] [--json-only]
13
+ """
14
+ import argparse, sys, json, re, time, math, os, html as _html
15
+ from collections import defaultdict, deque, Counter
16
+ from urllib.parse import urljoin, urlparse, urldefrag, urlunparse
17
+ from urllib import robotparser
18
+ from concurrent.futures import ThreadPoolExecutor, as_completed
19
+
20
+ import requests
21
+ from selectolax.parser import HTMLParser
22
+
23
+ UA = "Mozilla/5.0 (compatible; internal-link-audit/1.0; +seo-tools)"
24
+ ASSET_RE = re.compile(r"\.(jpg|jpeg|png|gif|svg|webp|ico|css|js|pdf|docx?|xlsx?|zip|rar|mp4|webm|woff2?|ttf|eot)(\?|$)", re.I)
25
+ STOP = set("и в во не что он на я с со как а то все она так его но да ты к у же вы за бы по только ее мне было вот от меня еще нет о из ему теперь когда даже ну вдруг ли если уже или ни быть был него до вас нибудь опять уж вам ведь там потом себя ничего ей может они тут где есть надо ней для мы тебя их чем была сам чтоб без будто чего раз тоже себе под будет ж кто этот того потому этого какой совсем ним здесь этом один почти мой тем чтобы нее сейчас были куда зачем всех никогда можно при наконец два об другой хоть после над больше тот через эти нас про всего них какая много разве три эту моя впрочем хорошо свою этой перед иногда лучше чуть том нельзя такой им более всегда конечно всю между на по за от до из с у в о к".split())
26
+
27
+
28
+ def normalize(url, strip_query=True):
29
+ url, _ = urldefrag(url or "")
30
+ p = urlparse(url)
31
+ scheme = (p.scheme or "https").lower()
32
+ netloc = p.netloc.lower()
33
+ path = re.sub(r"//+", "/", p.path) or "/"
34
+ query = "" if strip_query else p.query
35
+ return urlunparse((scheme, netloc, path, "", query, ""))
36
+
37
+
38
+ def host_of(url):
39
+ return urlparse(url).netloc.lower()
40
+
41
+
42
+ def same_site(url, root_host, include_sub=False):
43
+ h = host_of(url)
44
+ if not h:
45
+ return False
46
+ if include_sub:
47
+ base = root_host.split(":")[0]
48
+ return h == root_host or h.endswith("." + base)
49
+ return h == root_host
50
+
51
+
52
+ def clean_text(s):
53
+ return re.sub(r"\s+", " ", _html.unescape(s or "")).strip()
54
+
55
+
56
+ class Page:
57
+ __slots__ = ("url", "final", "status", "is_html", "title", "h1", "desc",
58
+ "canonical", "links", "text")
59
+
60
+ def __init__(self, url):
61
+ self.url = url
62
+ self.final = url
63
+ self.status = 0
64
+ self.is_html = False
65
+ self.title = ""
66
+ self.h1 = ""
67
+ self.desc = ""
68
+ self.canonical = ""
69
+ self.links = [] # list of (target_norm, anchor, nofollow)
70
+ self.text = ""
71
+
72
+
73
+ def fetch(url, timeout):
74
+ p = Page(url)
75
+ try:
76
+ r = requests.get(url, headers={"User-Agent": UA}, timeout=timeout, allow_redirects=True)
77
+ p.final = normalize(r.url)
78
+ p.status = r.status_code
79
+ ct = r.headers.get("Content-Type", "")
80
+ p.is_html = "text/html" in ct.lower()
81
+ if not p.is_html or r.status_code != 200:
82
+ return p
83
+ tree = HTMLParser(r.text)
84
+ t = tree.css_first("title")
85
+ p.title = clean_text(t.text()) if t else ""
86
+ h = tree.css_first("h1")
87
+ p.h1 = clean_text(h.text()) if h else ""
88
+ md = tree.css_first('meta[name="description"]')
89
+ p.desc = clean_text(md.attributes.get("content", "")) if md else ""
90
+ can = tree.css_first('link[rel="canonical"]')
91
+ if can:
92
+ p.canonical = normalize(urljoin(p.final, can.attributes.get("href", "")))
93
+ base = p.final
94
+ for a in tree.css("a"):
95
+ href = a.attributes.get("href")
96
+ if not href:
97
+ continue
98
+ href = href.strip()
99
+ if href.startswith(("mailto:", "tel:", "javascript:", "#")):
100
+ continue
101
+ absu = normalize(urljoin(base, href))
102
+ if ASSET_RE.search(absu):
103
+ continue
104
+ rel = (a.attributes.get("rel") or "").lower()
105
+ nofollow = "nofollow" in rel
106
+ anchor = clean_text(a.text())
107
+ p.links.append((absu, anchor, nofollow))
108
+ for node in tree.css("script,style,noscript"):
109
+ node.decompose()
110
+ body = tree.css_first("body")
111
+ p.text = clean_text(body.text(separator=" ")) if body else ""
112
+ except Exception as e:
113
+ p.status = -1
114
+ return p
115
+
116
+
117
+ def load_sitemap(root, timeout):
118
+ urls = set()
119
+ try:
120
+ seeds = [urljoin(root, "/sitemap.xml")]
121
+ seen = set()
122
+ while seeds:
123
+ sm = seeds.pop()
124
+ if sm in seen:
125
+ continue
126
+ seen.add(sm)
127
+ r = requests.get(sm, headers={"User-Agent": UA}, timeout=timeout)
128
+ if r.status_code != 200:
129
+ continue
130
+ locs = re.findall(r"<loc>\s*([^<]+?)\s*</loc>", r.text, re.I)
131
+ for loc in locs:
132
+ loc = normalize(loc.strip())
133
+ if loc.endswith(".xml"):
134
+ seeds.append(loc)
135
+ else:
136
+ urls.add(loc)
137
+ except Exception:
138
+ pass
139
+ return urls
140
+
141
+
142
+ def crawl(start, max_pages, max_depth, timeout, workers, include_sub):
143
+ root = normalize(start)
144
+ root_host = host_of(root)
145
+ rp = robotparser.RobotFileParser()
146
+ try:
147
+ rp.set_url(urljoin(root, "/robots.txt"))
148
+ rp.read()
149
+ except Exception:
150
+ rp = None
151
+
152
+ def allowed(u):
153
+ if rp is None:
154
+ return True
155
+ try:
156
+ return rp.can_fetch(UA, u)
157
+ except Exception:
158
+ return True
159
+
160
+ sitemap_urls = load_sitemap(root, timeout)
161
+ pages = {}
162
+ depth = {root: 0}
163
+ queue = deque([root])
164
+ # seed with sitemap urls at depth 1 (discovered, will be fetched too)
165
+ for u in sitemap_urls:
166
+ if same_site(u, root_host, include_sub) and u not in depth:
167
+ depth[u] = 1
168
+ queue.append(u)
169
+ inflight = set()
170
+
171
+ with ThreadPoolExecutor(max_workers=workers) as ex:
172
+ futures = {}
173
+ def submit(u):
174
+ futures[ex.submit(fetch, u, timeout)] = u
175
+ while (queue or futures) and len(pages) < max_pages:
176
+ while queue and len(futures) < workers * 2 and len(pages) + len(futures) < max_pages:
177
+ u = queue.popleft()
178
+ if u in pages or u in inflight:
179
+ continue
180
+ if not allowed(u):
181
+ continue
182
+ inflight.add(u)
183
+ submit(u)
184
+ if not futures:
185
+ break
186
+ done = next(as_completed(list(futures)))
187
+ u = futures.pop(done)
188
+ p = done.result()
189
+ pages[u] = p
190
+ d = depth.get(u, 0)
191
+ if p.is_html and p.status == 200 and d < max_depth:
192
+ for (t, anc, nf) in p.links:
193
+ if same_site(t, root_host, include_sub) and t not in depth:
194
+ depth[t] = d + 1
195
+ queue.append(t)
196
+ sys.stderr.write(f"\r crawled {len(pages)} queued {len(queue)} ")
197
+ sys.stderr.flush()
198
+ sys.stderr.write("\n")
199
+ return root, root_host, pages, depth, sitemap_urls
200
+
201
+
202
+ def build_graph(pages, root_host, include_sub):
203
+ # node identity: canonical target if it is a valid 200 html page, else final url
204
+ valid = {u for u, p in pages.items() if p.status == 200 and p.is_html}
205
+ # map any fetched url -> node
206
+ def node_of(u):
207
+ p = pages.get(u)
208
+ if p is None:
209
+ return u if u in valid else None
210
+ cand = p.final
211
+ if p.canonical and p.canonical in valid:
212
+ cand = p.canonical
213
+ return cand if cand in valid else (cand if pages.get(cand, p).status == 200 else None)
214
+ nodes = set()
215
+ for u in valid:
216
+ nodes.add(node_of(u) or u)
217
+ # edges (followed) + outlink totals (followed + nofollow) for evaporation
218
+ followed_out = defaultdict(list) # src -> [targets]
219
+ out_total = defaultdict(int) # src -> count incl nofollow
220
+ anchors_to = defaultdict(Counter) # target -> Counter(anchor)
221
+ anchor_targets = defaultdict(set) # anchor -> set(targets)
222
+ edge_count = 0
223
+ for u in valid:
224
+ src = node_of(u) or u
225
+ p = pages[u]
226
+ seen_pair = set()
227
+ for (t, anc, nf) in p.links:
228
+ if not same_site(t, root_host, include_sub):
229
+ continue
230
+ tn = node_of(t)
231
+ if tn is None or tn == src:
232
+ continue
233
+ out_total[src] += 1
234
+ if nf:
235
+ continue
236
+ key = (src, tn)
237
+ if key not in seen_pair:
238
+ followed_out[src].append(tn)
239
+ seen_pair.add(key)
240
+ edge_count += 1
241
+ if anc:
242
+ anchors_to[tn][anc] += 1
243
+ anchor_targets[anc.lower()].add(tn)
244
+ return nodes, followed_out, out_total, anchors_to, anchor_targets, edge_count
245
+
246
+
247
+ def pagerank(nodes, followed_out, out_total, d=0.85, max_iter=100, tol=1e-9):
248
+ nodes = list(nodes)
249
+ N = len(nodes)
250
+ if N == 0:
251
+ return {}
252
+ idx = {u: i for i, u in enumerate(nodes)}
253
+ score = [1.0 / N] * N
254
+ for _ in range(max_iter):
255
+ new = [(1 - d) / N] * N
256
+ leaked = 0.0
257
+ for u in nodes:
258
+ s = score[idx[u]]
259
+ ot = out_total.get(u, 0)
260
+ fo = followed_out.get(u, [])
261
+ if ot == 0:
262
+ leaked += d * s # dangling: leaks entirely
263
+ continue
264
+ share = d * s / ot
265
+ for t in fo:
266
+ new[idx[t]] += share
267
+ leaked += d * s * (ot - len(fo)) / ot # nofollow evaporation
268
+ add = leaked / N
269
+ new = [x + add for x in new]
270
+ err = sum(abs(new[i] - score[i]) for i in range(N))
271
+ score = new
272
+ if err < tol:
273
+ break
274
+ return {u: score[idx[u]] for u in nodes}
275
+
276
+
277
+ def link_score(pr):
278
+ if not pr:
279
+ return {}
280
+ logs = {u: math.log(v + 1e-12) for u, v in pr.items()}
281
+ lo, hi = min(logs.values()), max(logs.values())
282
+ rng = (hi - lo) or 1.0
283
+ return {u: round(100 * (logs[u] - lo) / rng, 1) for u in logs}
284
+
285
+
286
+ def keyword_of(p):
287
+ base = (p.h1 or p.title or "").lower()
288
+ base = re.split(r"[|—\-–:]", base)[0] # drop brand tail
289
+ toks = [w for w in re.findall(r"[а-яёa-z0-9]+", base) if w not in STOP and len(w) > 2]
290
+ return " ".join(toks[:6]).strip()
291
+
292
+
293
+ def cannibalization_content(pages, valid_nodes, sim_threshold):
294
+ docs, urls = [], []
295
+ for u in valid_nodes:
296
+ p = pages.get(u)
297
+ if not p:
298
+ continue
299
+ txt = " ".join([p.title, p.h1, p.desc, p.text[:4000]])
300
+ if txt.strip():
301
+ docs.append(txt.lower())
302
+ urls.append(u)
303
+ clusters = []
304
+ # 1) same normalized primary keyword
305
+ bykw = defaultdict(list)
306
+ for u in urls:
307
+ kw = keyword_of(pages[u])
308
+ if kw:
309
+ bykw[kw].append(u)
310
+ kw_clusters = [{"type": "same_keyword", "key": k, "pages": v}
311
+ for k, v in bykw.items() if len(v) > 1]
312
+ # 2) TF-IDF cosine near-duplicates
313
+ sim_pairs = []
314
+ try:
315
+ from sklearn.feature_extraction.text import TfidfVectorizer
316
+ from sklearn.metrics.pairwise import cosine_similarity
317
+ if len(docs) >= 2:
318
+ vec = TfidfVectorizer(max_features=5000)
319
+ X = vec.fit_transform(docs)
320
+ S = cosine_similarity(X)
321
+ for i in range(len(urls)):
322
+ for j in range(i + 1, len(urls)):
323
+ if S[i, j] >= sim_threshold:
324
+ sim_pairs.append((urls[i], urls[j], round(float(S[i, j]), 3)))
325
+ sim_pairs.sort(key=lambda t: -t[2])
326
+ except Exception as e:
327
+ sim_pairs = [("(sklearn error)", str(e), 0)]
328
+ return kw_clusters, sim_pairs[:100]
329
+
330
+
331
+ def build_report_html(root, stats, top_ls, orphans, deep, anchor_multi, kw_clusters, sim_pairs, ts):
332
+ def esc(s):
333
+ return _html.escape(str(s))
334
+ rows_ls = "".join(
335
+ f"<tr><td>{esc(u)}</td><td class='n'>{ls}</td><td class='n'>{inl}</td><td class='n'>{outl}</td><td class='n'>{dep}</td></tr>"
336
+ for (u, ls, inl, outl, dep) in top_ls)
337
+ rows_orph = "".join(f"<tr><td>{esc(u)}</td></tr>" for u in orphans[:200]) or "<tr><td>—</td></tr>"
338
+ rows_deep = "".join(f"<tr><td>{esc(u)}</td><td class='n'>{dep}</td></tr>" for u, dep in deep[:100]) or "<tr><td>—</td></tr>"
339
+ rows_anch = "".join(
340
+ f"<tr><td>{esc(a)}</td><td class='n'>{len(ts_)}</td><td>{esc(' , '.join(list(ts_)[:4]))}</td></tr>"
341
+ for a, ts_ in anchor_multi[:60]) or "<tr><td>—</td></tr>"
342
+ rows_kw = "".join(
343
+ f"<tr><td>{esc(c['key'])}</td><td class='n'>{len(c['pages'])}</td><td>{esc(' | '.join(c['pages'][:6]))}</td></tr>"
344
+ for c in sorted(kw_clusters, key=lambda c: -len(c['pages']))[:60]) or "<tr><td>—</td></tr>"
345
+ rows_sim = "".join(
346
+ f"<tr><td>{esc(a)}</td><td>{esc(b)}</td><td class='n'>{s}</td></tr>"
347
+ for (a, b, s) in sim_pairs[:60]) or "<tr><td>—</td></tr>"
348
+ return f"""<!DOCTYPE html><html lang="ru"><head><meta charset="UTF-8">
349
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
350
+ <title>Internal link audit — {esc(root)}</title>
351
+ <style>
352
+ body{{background:#fff;color:#1a1a1a;font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif;line-height:1.5;margin:0}}
353
+ .wrap{{max-width:1040px;margin:0 auto;padding:32px 24px 70px}}
354
+ h1{{font-size:22px}} h2{{font-size:18px;margin:30px 0 10px;border-bottom:2px solid #eee;padding-bottom:6px}}
355
+ .sub{{color:#666;font-size:13px;margin-bottom:18px}}
356
+ .grid{{display:flex;flex-wrap:wrap;gap:12px;margin:14px 0}}
357
+ .card{{flex:1 1 150px;background:#fafbfc;border:1px solid #ececec;border-radius:9px;padding:12px 14px}}
358
+ .card b{{display:block;font-size:13px;color:#2f6fed}} .card .big{{font-size:24px;font-weight:700}}
359
+ table{{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}}
360
+ th,td{{border:1px solid #e3e3e3;padding:6px 9px;text-align:left;vertical-align:top;word-break:break-all}}
361
+ th{{background:#f4f6f9}} td.n{{text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}}
362
+ </style></head><body><div class="wrap">
363
+ <h1>Аудит внутренней перелинковки — {esc(root)}</h1>
364
+ <div class="sub">Внутренний PageRank (Link Score 0–100, стиль Screaming Frog) · каннибализация · сироты · {esc(ts)}</div>
365
+ <div class="grid">
366
+ <div class="card"><b>Страниц (200/HTML)</b><span class="big">{stats['nodes']}</span></div>
367
+ <div class="card"><b>Внутренних ссылок</b><span class="big">{stats['edges']}</span></div>
368
+ <div class="card"><b>Сироты (0 вх. ссылок)</b><span class="big">{stats['orphans']}</span></div>
369
+ <div class="card"><b>Глубина ≥4</b><span class="big">{stats['deep']}</span></div>
370
+ <div class="card"><b>Кластеры каннибализации</b><span class="big">{stats['kw_clusters']}</span></div>
371
+ </div>
372
+ <h2>Топ страниц по Link Score (внутренний PageRank)</h2>
373
+ <table><tr><th>URL</th><th>Link Score</th><th>вх.</th><th>исх.</th><th>глубина</th></tr>{rows_ls}</table>
374
+ <h2>Каннибализация №1 — один анкор ведёт на разные страницы</h2>
375
+ <table><tr><th>Анкор</th><th>целей</th><th>примеры целей</th></tr>{rows_anch}</table>
376
+ <h2>Каннибализация №2 — страницы с одинаковым целевым ключом (title/H1)</h2>
377
+ <table><tr><th>Ключ</th><th>страниц</th><th>URL</th></tr>{rows_kw}</table>
378
+ <h2>Каннибализация №3 — почти дублирующийся контент (TF-IDF косинус)</h2>
379
+ <table><tr><th>Страница A</th><th>Страница B</th><th>похожесть</th></tr>{rows_sim}</table>
380
+ <h2>Страницы-сироты (нет внутренних входящих ссылок)</h2>
381
+ <table><tr><th>URL</th></tr>{rows_orph}</table>
382
+ <h2>Глубоко закопанные страницы (клик-глубина ≥4)</h2>
383
+ <table><tr><th>URL</th><th>глубина</th></tr>{rows_deep}</table>
384
+ </div></body></html>"""
385
+
386
+
387
+ def main():
388
+ ap = argparse.ArgumentParser(prog="linkrank", description="Internal PageRank (Link Score) & keyword-cannibalization auditor")
389
+ ap.add_argument("start_url")
390
+ ap.add_argument("--max-pages", type=int, default=2000)
391
+ ap.add_argument("--depth", type=int, default=10)
392
+ ap.add_argument("--timeout", type=float, default=12)
393
+ ap.add_argument("--workers", type=int, default=10)
394
+ ap.add_argument("--sim-threshold", type=float, default=0.80)
395
+ ap.add_argument("--include-subdomains", action="store_true")
396
+ ap.add_argument("--out", default="./reports")
397
+ ap.add_argument("--json-only", action="store_true")
398
+ args = ap.parse_args()
399
+
400
+ ts = time.strftime("%Y-%m-%d %H:%M")
401
+ sys.stderr.write(f"Crawling {args.start_url} (max-pages={args.max_pages})...\n")
402
+ root, root_host, pages, depth, sitemap_urls = crawl(
403
+ args.start_url, args.max_pages, args.depth, args.timeout, args.workers, args.include_subdomains)
404
+
405
+ nodes, followed_out, out_total, anchors_to, anchor_targets, edge_count = build_graph(pages, root_host, args.include_subdomains)
406
+ pr = pagerank(nodes, followed_out, out_total)
407
+ ls = link_score(pr)
408
+
409
+ inlinks = Counter()
410
+ for src, tgts in followed_out.items():
411
+ for t in tgts:
412
+ inlinks[t] += 1
413
+ outlinks = {u: len(followed_out.get(u, [])) for u in nodes}
414
+
415
+ # depth via BFS over followed edges from root
416
+ node_depth = {root: 0}
417
+ dq = deque([root])
418
+ adj = followed_out
419
+ while dq:
420
+ u = dq.popleft()
421
+ for t in adj.get(u, []):
422
+ if t not in node_depth:
423
+ node_depth[t] = node_depth[u] + 1
424
+ dq.append(t)
425
+
426
+ orphans = sorted([u for u in nodes if inlinks.get(u, 0) == 0 and u != root])
427
+ deep = sorted([(u, node_depth.get(u, 99)) for u in nodes if node_depth.get(u, 99) >= 4], key=lambda t: -t[1])
428
+
429
+ anchor_multi = sorted([(a, ts_) for a, ts_ in anchor_targets.items() if len(ts_) > 1],
430
+ key=lambda t: -len(t[1]))
431
+ kw_clusters, sim_pairs = cannibalization_content(pages, nodes, args.sim_threshold)
432
+
433
+ top_ls = sorted(nodes, key=lambda u: -ls.get(u, 0))[:40]
434
+ top_ls_rows = [(u, ls.get(u, 0), inlinks.get(u, 0), outlinks.get(u, 0), node_depth.get(u, "—")) for u in top_ls]
435
+
436
+ stats = {"nodes": len(nodes), "edges": edge_count, "orphans": len(orphans),
437
+ "deep": len(deep), "kw_clusters": len(kw_clusters)}
438
+
439
+ os.makedirs(args.out, exist_ok=True)
440
+ slug = re.sub(r"[^a-z0-9]+", "-", host_of(root).lower()).strip("-")
441
+ stamp = time.strftime("%Y-%m-%d")
442
+ data = {
443
+ "root": root, "generated": ts, "stats": stats,
444
+ "pages": [{"url": u, "link_score": ls.get(u, 0), "pagerank": pr.get(u, 0),
445
+ "inlinks": inlinks.get(u, 0), "outlinks": outlinks.get(u, 0),
446
+ "depth": node_depth.get(u, None),
447
+ "title": pages[u].title if u in pages else "",
448
+ "top_anchors": anchors_to.get(u, Counter()).most_common(5)}
449
+ for u in sorted(nodes, key=lambda u: -ls.get(u, 0))],
450
+ "orphans": orphans,
451
+ "deep_pages": [{"url": u, "depth": d} for u, d in deep],
452
+ "cannibalization": {
453
+ "anchor_to_many": [{"anchor": a, "targets": sorted(ts_)} for a, ts_ in anchor_multi[:200]],
454
+ "same_keyword_clusters": kw_clusters,
455
+ "content_similar_pairs": [{"a": a, "b": b, "sim": s} for a, b, s in sim_pairs],
456
+ },
457
+ }
458
+ json_path = os.path.join(args.out, f"linkrank-{slug}-{stamp}.json")
459
+ with open(json_path, "w", encoding="utf-8") as f:
460
+ json.dump(data, f, ensure_ascii=False, indent=2)
461
+
462
+ print(f"\n=== Internal link audit: {root} ===")
463
+ print(f"pages={stats['nodes']} internal_links={stats['edges']} orphans={stats['orphans']} deep(>=4)={stats['deep']} kw_clusters={stats['kw_clusters']}")
464
+ print("\nTop 10 by Link Score:")
465
+ for u in top_ls[:10]:
466
+ print(f" {ls.get(u,0):5.1f} in={inlinks.get(u,0):>4} {u}")
467
+ print(f"\nJSON: {json_path}")
468
+
469
+ if not args.json_only:
470
+ html = build_report_html(root, stats, top_ls_rows, orphans, deep, anchor_multi, kw_clusters, sim_pairs, ts)
471
+ html_path = os.path.join(args.out, f"linkrank-{slug}-{stamp}.html")
472
+ with open(html_path, "w", encoding="utf-8") as f:
473
+ f.write(html)
474
+ print(f"HTML: {html_path}")
475
+
476
+
477
+ if __name__ == "__main__":
478
+ main()
@@ -0,0 +1,129 @@
1
+ Metadata-Version: 2.4
2
+ Name: linkrank
3
+ Version: 0.1.0
4
+ Summary: Internal PageRank (Link Score) & keyword-cannibalization auditor — a free, open-source crawler. No paid APIs.
5
+ Author-email: KlientLab <dmitrievdm@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/trendbender/linkrank
8
+ Project-URL: Repository, https://github.com/trendbender/linkrank
9
+ Project-URL: Bug Tracker, https://github.com/trendbender/linkrank/issues
10
+ Project-URL: Made by, https://klientlab.ru/tools/
11
+ Keywords: seo,internal-links,pagerank,link-score,cannibalization,crawler,site-audit,orphan-pages
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Topic :: Internet :: WWW/HTTP :: Indexing/Search
19
+ Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
20
+ Requires-Python: >=3.10
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: requests>=2.28
24
+ Requires-Dist: selectolax>=0.3
25
+ Requires-Dist: networkx>=3.0
26
+ Requires-Dist: scikit-learn>=1.2
27
+ Dynamic: license-file
28
+
29
+ # linkrank
30
+
31
+ **Internal PageRank (Link Score) & keyword-cannibalization auditor — free & open-source.**
32
+
33
+ `linkrank` crawls a website, builds its **internal link graph**, computes **internal PageRank on a
34
+ 0–100 “Link Score” scale** (the same idea behind Screaming Frog’s *Link Score* and OnCrawl’s *InRank*),
35
+ and flags **keyword cannibalization** and **orphan pages** — all locally, with **no paid APIs**.
36
+
37
+ It answers questions that usually require paid tools:
38
+
39
+ - Which pages does my internal linking actually **concentrate authority on** — and which are starved?
40
+ - Which pages are **orphans** (zero internal inlinks) or buried too deep?
41
+ - Where is my site **cannibalizing itself** (one anchor → many targets, duplicate titles, near-duplicate content)?
42
+
43
+ > Made by [KlientLab](https://klientlab.ru/tools/). MIT licensed.
44
+
45
+ ## Install
46
+
47
+ ```bash
48
+ pip install linkrank
49
+ ```
50
+
51
+ ## Quick start
52
+
53
+ ```bash
54
+ linkrank https://example.com
55
+ ```
56
+
57
+ Outputs a machine-readable **JSON** and a self-contained **HTML report** into `./reports/`:
58
+
59
+ ```
60
+ === Internal link audit: https://example.com/ ===
61
+ pages=253 internal_links=1653 orphans=39 deep(>=4)=39 kw_clusters=11
62
+
63
+ Top 10 by Link Score:
64
+ 100.0 in= 253 https://example.com/
65
+ 28.2 in= 37 https://example.com/category-a/
66
+ ...
67
+ JSON: reports/internal-link-audit-example-com-2026-07-06.json
68
+ HTML: reports/internal-link-audit-example-com-2026-07-06.html
69
+ ```
70
+
71
+ ## Options
72
+
73
+ ```
74
+ linkrank <start_url> [options]
75
+ --max-pages N cap fully-crawled pages (default 2000)
76
+ --depth N max crawl depth from the start URL (default 10)
77
+ --timeout S per-request timeout, seconds (default 12)
78
+ --workers N concurrent requests (default 10)
79
+ --sim-threshold F content-similarity cutoff for cannibalization (default 0.80)
80
+ --include-subdomains treat subdomains as internal
81
+ --out DIR output directory (default ./reports)
82
+ --json-only skip the HTML report
83
+ ```
84
+
85
+ ## What it reports
86
+
87
+ - **Link Score (0–100)** per page — log-scaled internal PageRank, comparable to Screaming Frog.
88
+ - **Inlinks / outlinks**, click-**depth** from the homepage, **orphan** pages (0 internal inlinks).
89
+ - **Cannibalization**, three signals:
90
+ 1. one **anchor text → multiple target URLs**;
91
+ 2. pages sharing the same **primary keyword** (normalized `<title>`/`<h1>`);
92
+ 3. **near-duplicate content** (TF-IDF cosine ≥ threshold).
93
+
94
+ ## Methodology
95
+
96
+ - **Internal PageRank** — power iteration, damping factor `α = 0.85` (Page & Brin, 1998). Each page
97
+ passes its score divided by its number of followed out-links; `rel="nofollow"` links **evaporate**
98
+ (counted in out-degree but transfer no score); dangling nodes are redistributed uniformly. The raw
99
+ PageRank is reported both directly and as a **0–100 logarithmic Link Score** (min–max on `log`),
100
+ matching the way Screaming Frog *Link Score* and OnCrawl *InRank* present internal authority.
101
+ - **Graph** — nodes are 200-OK internal HTML URLs; a page’s **canonical** target is used as its node
102
+ identity when that target is itself a valid page; redirects are collapsed onto their final URL.
103
+ - **Cannibalization** — anchor-to-many-targets from the crawl, primary-keyword clustering from
104
+ `<title>`/`<h1>`, and TF-IDF cosine similarity of body text.
105
+
106
+ References: Page, Brin, Motwani & Winograd, *The PageRank Citation Ranking* (1998); Screaming Frog
107
+ *Link Score*; OnCrawl *InRank*.
108
+
109
+ ## Requirements
110
+
111
+ Python ≥ 3.10. Dependencies (installed automatically): `requests`, `selectolax`, `networkx`,
112
+ `scikit-learn`.
113
+
114
+ ## Notes & limits
115
+
116
+ - Respects `robots.txt`; skips non-HTML, `mailto:`/`tel:`/anchors and asset URLs.
117
+ - Deterministic output (stable sort) — runs are diffable over time.
118
+ - Enforces a hard page cap and per-request timeout — it will not hang on large/slow sites.
119
+ - Static-HTML crawler: links injected purely client-side by JavaScript may not be seen. Server-rendered
120
+ `<a href>` links (the vast majority) are captured.
121
+
122
+ ## Contributing
123
+
124
+ Issues and PRs welcome. Ideas on the roadmap: JS rendering flag, GSC query×page cannibalization signal,
125
+ interactive graph export.
126
+
127
+ ## License
128
+
129
+ MIT © 2026 [KlientLab](https://klientlab.ru/tools/).
@@ -0,0 +1,12 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ linkrank/__init__.py
5
+ linkrank/__main__.py
6
+ linkrank/core.py
7
+ linkrank.egg-info/PKG-INFO
8
+ linkrank.egg-info/SOURCES.txt
9
+ linkrank.egg-info/dependency_links.txt
10
+ linkrank.egg-info/entry_points.txt
11
+ linkrank.egg-info/requires.txt
12
+ linkrank.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ linkrank = linkrank.core:main
@@ -0,0 +1,4 @@
1
+ requests>=2.28
2
+ selectolax>=0.3
3
+ networkx>=3.0
4
+ scikit-learn>=1.2
@@ -0,0 +1 @@
1
+ linkrank
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "linkrank"
7
+ version = "0.1.0"
8
+ description = "Internal PageRank (Link Score) & keyword-cannibalization auditor — a free, open-source crawler. No paid APIs."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "KlientLab", email = "dmitrievdm@gmail.com" }]
13
+ keywords = ["seo", "internal-links", "pagerank", "link-score", "cannibalization", "crawler", "site-audit", "orphan-pages"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Topic :: Internet :: WWW/HTTP :: Indexing/Search",
22
+ "Topic :: Internet :: WWW/HTTP :: Site Management",
23
+ ]
24
+ dependencies = [
25
+ "requests>=2.28",
26
+ "selectolax>=0.3",
27
+ "networkx>=3.0",
28
+ "scikit-learn>=1.2",
29
+ ]
30
+
31
+ [project.urls]
32
+ Homepage = "https://github.com/trendbender/linkrank"
33
+ Repository = "https://github.com/trendbender/linkrank"
34
+ "Bug Tracker" = "https://github.com/trendbender/linkrank/issues"
35
+ "Made by" = "https://klientlab.ru/tools/"
36
+
37
+ [project.scripts]
38
+ linkrank = "linkrank.core:main"
39
+
40
+ [tool.setuptools]
41
+ packages = ["linkrank"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+