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 +21 -0
- linkrank-0.1.0/PKG-INFO +129 -0
- linkrank-0.1.0/README.md +101 -0
- linkrank-0.1.0/linkrank/__init__.py +10 -0
- linkrank-0.1.0/linkrank/__main__.py +4 -0
- linkrank-0.1.0/linkrank/core.py +478 -0
- linkrank-0.1.0/linkrank.egg-info/PKG-INFO +129 -0
- linkrank-0.1.0/linkrank.egg-info/SOURCES.txt +12 -0
- linkrank-0.1.0/linkrank.egg-info/dependency_links.txt +1 -0
- linkrank-0.1.0/linkrank.egg-info/entry_points.txt +2 -0
- linkrank-0.1.0/linkrank.egg-info/requires.txt +4 -0
- linkrank-0.1.0/linkrank.egg-info/top_level.txt +1 -0
- linkrank-0.1.0/pyproject.toml +41 -0
- linkrank-0.1.0/setup.cfg +4 -0
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.
|
linkrank-0.1.0/PKG-INFO
ADDED
|
@@ -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/).
|
linkrank-0.1.0/README.md
ADDED
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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"]
|
linkrank-0.1.0/setup.cfg
ADDED