sameness 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.
- sameness-0.1.0/LICENSE +21 -0
- sameness-0.1.0/PKG-INFO +151 -0
- sameness-0.1.0/README.md +130 -0
- sameness-0.1.0/pyproject.toml +37 -0
- sameness-0.1.0/sameness/__init__.py +18 -0
- sameness-0.1.0/sameness/cli.py +77 -0
- sameness-0.1.0/sameness/compare.py +213 -0
- sameness-0.1.0/sameness/extract.py +175 -0
- sameness-0.1.0/sameness/fetch.py +107 -0
- sameness-0.1.0/sameness.egg-info/PKG-INFO +151 -0
- sameness-0.1.0/sameness.egg-info/SOURCES.txt +15 -0
- sameness-0.1.0/sameness.egg-info/dependency_links.txt +1 -0
- sameness-0.1.0/sameness.egg-info/entry_points.txt +2 -0
- sameness-0.1.0/sameness.egg-info/requires.txt +3 -0
- sameness-0.1.0/sameness.egg-info/top_level.txt +1 -0
- sameness-0.1.0/setup.cfg +4 -0
- sameness-0.1.0/tests/test_sameness.py +92 -0
sameness-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tsuruta Lab
|
|
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.
|
sameness-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sameness
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Do the pages of one site look like one template? Compare content skeletons, shells, fonts, palettes and copy across pages. No dependencies.
|
|
5
|
+
Author: Tsuruta Lab
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tsurutanmen/sameness
|
|
8
|
+
Project-URL: Issues, https://github.com/tsurutanmen/sameness/issues
|
|
9
|
+
Keywords: design,ai slop,template,website audit,html,consistency
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# sameness
|
|
23
|
+
|
|
24
|
+
Do the pages of one site look like one template?
|
|
25
|
+
|
|
26
|
+
Detectors for the AI look score one page at a time: the purple gradient, Inter, three feature cards.
|
|
27
|
+
But when people are asked what gives a machine-made site away, the two answers at the top are not
|
|
28
|
+
features. In a Reddit corpus of 3,033 on-topic comments, "screams AI" (6.4%) and "they all look the
|
|
29
|
+
same" (6.1%) each beat every specific feature; purple, gradients, animations and glass together add
|
|
30
|
+
up to about 7%. Sameness is a property of a *set* of pages. `sameness` compares the pages of one
|
|
31
|
+
site with each other.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install git+https://github.com/tsurutanmen/sameness
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
No dependencies. Python 3.9 or later.
|
|
38
|
+
|
|
39
|
+
## Thirty seconds
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
$ sameness tests/fixtures/clone_*.html
|
|
43
|
+
|
|
44
|
+
4 pages
|
|
45
|
+
|
|
46
|
+
sameness 1.00 mean similarity of the content skeleton across page pairs (1 = one template)
|
|
47
|
+
shells 1 distinct header/nav/footer structures (1 = one site)
|
|
48
|
+
palette 1.00 mean overlap of hex colours between pages (1 = one palette)
|
|
49
|
+
|
|
50
|
+
near-clones (content skeleton >= 90% similar):
|
|
51
|
+
clone_0.html ~ clone_1.html ~ clone_2.html ~ clone_3.html
|
|
52
|
+
|
|
53
|
+
same meta description on several pages:
|
|
54
|
+
4 page(s) "One platform to elevate your workflow."
|
|
55
|
+
|
|
56
|
+
headings that appear on several pages (outside header/nav/footer):
|
|
57
|
+
4x Ready?
|
|
58
|
+
|
|
59
|
+
page words emoji grad sym repeated blocks
|
|
60
|
+
clone_0.html 36 0 0 3x3 cardx3
|
|
61
|
+
...
|
|
62
|
+
note: pages share one content skeleton; a reader will see one template with the words swapped
|
|
63
|
+
note: 4 pages reuse a meta description; search results will show the same sentence for each
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or crawl a live site, same host only, linked stylesheets included:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
sameness --site https://example.com --max 20
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## What it measures
|
|
73
|
+
|
|
74
|
+
| line | meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `sameness` | Mean similarity of the *content* skeleton (the tag sequence of the page with header, nav and footer removed) over all page pairs. 1.00 means every page is the same template. |
|
|
77
|
+
| `near-clones` | Groups of pages whose content skeletons are at least 90% similar. Pages of one kind (articles, product pages) are expected to share a skeleton; the question is whether pages of different kinds do too. |
|
|
78
|
+
| `shells` | Number of distinct header/nav/footer structures. A site has one. When it has four, the pages do not read as one place, which is the opposite failure and just as visible. |
|
|
79
|
+
| `font sets`, `palette` | Fonts declared and hex colours used, per page and their overlap across pages. |
|
|
80
|
+
| `same meta description`, `same <title>` | Copy reused across pages, usually a default that was never replaced. |
|
|
81
|
+
| `sym` | Perfectly symmetric blocks on a page: `3x3` means three lists of exactly three items. |
|
|
82
|
+
| `--pairs` | The full pairwise table: skeleton similarity, shared headings, shared repeated block classes. |
|
|
83
|
+
|
|
84
|
+
Everything comes from the markup and the stylesheet text. Nothing is rendered, so layout that only
|
|
85
|
+
exists after JavaScript runs is not seen.
|
|
86
|
+
|
|
87
|
+
## Two real sites
|
|
88
|
+
|
|
89
|
+
A 15-page site of ours that had been built in four sittings. No page-level detector complained; the
|
|
90
|
+
Tell Score was A or B on every page. `sameness` reported:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
sameness 0.26
|
|
94
|
+
shells 8 distinct header/nav/footer structures (1 = one site)
|
|
95
|
+
palette 0.26
|
|
96
|
+
font sets: 5
|
|
97
|
+
note: 8 different shells on one site: the pages do not read as one place
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
That was the finding a reader had already made ("it looks like four different sites"), now as a
|
|
101
|
+
number. The other direction, a small research site with one shell:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
sameness 0.51
|
|
105
|
+
shells 1
|
|
106
|
+
palette 1.00
|
|
107
|
+
near-clones: the six research notes share one article skeleton
|
|
108
|
+
same meta description on several pages: 2 page(s) "aaaaaaaaaaaaaa"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The near-clones are articles and are supposed to look alike. The placeholder description was real
|
|
112
|
+
and had been missed.
|
|
113
|
+
|
|
114
|
+
## Python
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import sameness
|
|
118
|
+
|
|
119
|
+
pages = [sameness.extract(html, name=url) for url, html in docs]
|
|
120
|
+
rep = sameness.compare(pages)
|
|
121
|
+
print(rep)
|
|
122
|
+
rep.to_dict() # everything, JSON-serialisable
|
|
123
|
+
|
|
124
|
+
for p in rep.pairs: # pairwise numbers
|
|
125
|
+
print(p.a, p.b, p.skeleton, p.headings, p.classes)
|
|
126
|
+
|
|
127
|
+
for url, html, css in sameness.crawl("https://example.com", max_pages=20):
|
|
128
|
+
...
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Claude Code skill
|
|
132
|
+
|
|
133
|
+
`skill/sameness/SKILL.md` tells Claude Code to run this after building or editing more than one page
|
|
134
|
+
of a site, and how to read the result. Install by copying the folder:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
cp -r skill/sameness ~/.claude/skills/sameness
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Related tools
|
|
141
|
+
|
|
142
|
+
Per-page detectors, which this complements rather than replaces:
|
|
143
|
+
[ai-design-tells](https://github.com/hankimis/ai-design-tells) (Tell Score, 27 tells),
|
|
144
|
+
[avoid-ai-design](https://github.com/funboy322/avoid-ai-design) (a rewrite skill),
|
|
145
|
+
[vibecoded-audit](https://github.com/KreshBack/vibecoded-audit) (49 tells, crawls but scores per page).
|
|
146
|
+
The Reddit corpus is from
|
|
147
|
+
[vibecoded-design-tells](https://github.com/JCarterJohnson/vibecoded-design-tells).
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT.
|
sameness-0.1.0/README.md
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# sameness
|
|
2
|
+
|
|
3
|
+
Do the pages of one site look like one template?
|
|
4
|
+
|
|
5
|
+
Detectors for the AI look score one page at a time: the purple gradient, Inter, three feature cards.
|
|
6
|
+
But when people are asked what gives a machine-made site away, the two answers at the top are not
|
|
7
|
+
features. In a Reddit corpus of 3,033 on-topic comments, "screams AI" (6.4%) and "they all look the
|
|
8
|
+
same" (6.1%) each beat every specific feature; purple, gradients, animations and glass together add
|
|
9
|
+
up to about 7%. Sameness is a property of a *set* of pages. `sameness` compares the pages of one
|
|
10
|
+
site with each other.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
pip install git+https://github.com/tsurutanmen/sameness
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
No dependencies. Python 3.9 or later.
|
|
17
|
+
|
|
18
|
+
## Thirty seconds
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
$ sameness tests/fixtures/clone_*.html
|
|
22
|
+
|
|
23
|
+
4 pages
|
|
24
|
+
|
|
25
|
+
sameness 1.00 mean similarity of the content skeleton across page pairs (1 = one template)
|
|
26
|
+
shells 1 distinct header/nav/footer structures (1 = one site)
|
|
27
|
+
palette 1.00 mean overlap of hex colours between pages (1 = one palette)
|
|
28
|
+
|
|
29
|
+
near-clones (content skeleton >= 90% similar):
|
|
30
|
+
clone_0.html ~ clone_1.html ~ clone_2.html ~ clone_3.html
|
|
31
|
+
|
|
32
|
+
same meta description on several pages:
|
|
33
|
+
4 page(s) "One platform to elevate your workflow."
|
|
34
|
+
|
|
35
|
+
headings that appear on several pages (outside header/nav/footer):
|
|
36
|
+
4x Ready?
|
|
37
|
+
|
|
38
|
+
page words emoji grad sym repeated blocks
|
|
39
|
+
clone_0.html 36 0 0 3x3 cardx3
|
|
40
|
+
...
|
|
41
|
+
note: pages share one content skeleton; a reader will see one template with the words swapped
|
|
42
|
+
note: 4 pages reuse a meta description; search results will show the same sentence for each
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or crawl a live site, same host only, linked stylesheets included:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
sameness --site https://example.com --max 20
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What it measures
|
|
52
|
+
|
|
53
|
+
| line | meaning |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `sameness` | Mean similarity of the *content* skeleton (the tag sequence of the page with header, nav and footer removed) over all page pairs. 1.00 means every page is the same template. |
|
|
56
|
+
| `near-clones` | Groups of pages whose content skeletons are at least 90% similar. Pages of one kind (articles, product pages) are expected to share a skeleton; the question is whether pages of different kinds do too. |
|
|
57
|
+
| `shells` | Number of distinct header/nav/footer structures. A site has one. When it has four, the pages do not read as one place, which is the opposite failure and just as visible. |
|
|
58
|
+
| `font sets`, `palette` | Fonts declared and hex colours used, per page and their overlap across pages. |
|
|
59
|
+
| `same meta description`, `same <title>` | Copy reused across pages, usually a default that was never replaced. |
|
|
60
|
+
| `sym` | Perfectly symmetric blocks on a page: `3x3` means three lists of exactly three items. |
|
|
61
|
+
| `--pairs` | The full pairwise table: skeleton similarity, shared headings, shared repeated block classes. |
|
|
62
|
+
|
|
63
|
+
Everything comes from the markup and the stylesheet text. Nothing is rendered, so layout that only
|
|
64
|
+
exists after JavaScript runs is not seen.
|
|
65
|
+
|
|
66
|
+
## Two real sites
|
|
67
|
+
|
|
68
|
+
A 15-page site of ours that had been built in four sittings. No page-level detector complained; the
|
|
69
|
+
Tell Score was A or B on every page. `sameness` reported:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
sameness 0.26
|
|
73
|
+
shells 8 distinct header/nav/footer structures (1 = one site)
|
|
74
|
+
palette 0.26
|
|
75
|
+
font sets: 5
|
|
76
|
+
note: 8 different shells on one site: the pages do not read as one place
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
That was the finding a reader had already made ("it looks like four different sites"), now as a
|
|
80
|
+
number. The other direction, a small research site with one shell:
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
sameness 0.51
|
|
84
|
+
shells 1
|
|
85
|
+
palette 1.00
|
|
86
|
+
near-clones: the six research notes share one article skeleton
|
|
87
|
+
same meta description on several pages: 2 page(s) "aaaaaaaaaaaaaa"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
The near-clones are articles and are supposed to look alike. The placeholder description was real
|
|
91
|
+
and had been missed.
|
|
92
|
+
|
|
93
|
+
## Python
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
import sameness
|
|
97
|
+
|
|
98
|
+
pages = [sameness.extract(html, name=url) for url, html in docs]
|
|
99
|
+
rep = sameness.compare(pages)
|
|
100
|
+
print(rep)
|
|
101
|
+
rep.to_dict() # everything, JSON-serialisable
|
|
102
|
+
|
|
103
|
+
for p in rep.pairs: # pairwise numbers
|
|
104
|
+
print(p.a, p.b, p.skeleton, p.headings, p.classes)
|
|
105
|
+
|
|
106
|
+
for url, html, css in sameness.crawl("https://example.com", max_pages=20):
|
|
107
|
+
...
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Claude Code skill
|
|
111
|
+
|
|
112
|
+
`skill/sameness/SKILL.md` tells Claude Code to run this after building or editing more than one page
|
|
113
|
+
of a site, and how to read the result. Install by copying the folder:
|
|
114
|
+
|
|
115
|
+
```
|
|
116
|
+
cp -r skill/sameness ~/.claude/skills/sameness
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Related tools
|
|
120
|
+
|
|
121
|
+
Per-page detectors, which this complements rather than replaces:
|
|
122
|
+
[ai-design-tells](https://github.com/hankimis/ai-design-tells) (Tell Score, 27 tells),
|
|
123
|
+
[avoid-ai-design](https://github.com/funboy322/avoid-ai-design) (a rewrite skill),
|
|
124
|
+
[vibecoded-audit](https://github.com/KreshBack/vibecoded-audit) (49 tells, crawls but scores per page).
|
|
125
|
+
The Reddit corpus is from
|
|
126
|
+
[vibecoded-design-tells](https://github.com/JCarterJohnson/vibecoded-design-tells).
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
MIT.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sameness"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Do the pages of one site look like one template? Compare content skeletons, shells, fonts, palettes and copy across pages. No dependencies."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Tsuruta Lab" }]
|
|
13
|
+
keywords = ["design", "ai slop", "template", "website audit", "html", "consistency"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Internet :: WWW/HTTP :: Site Management",
|
|
20
|
+
]
|
|
21
|
+
dependencies = []
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
test = ["pytest>=7"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://github.com/tsurutanmen/sameness"
|
|
28
|
+
Issues = "https://github.com/tsurutanmen/sameness/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
sameness = "sameness.cli:main"
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.packages.find]
|
|
34
|
+
include = ["sameness*"]
|
|
35
|
+
|
|
36
|
+
[tool.pytest.ini_options]
|
|
37
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""sameness: do the pages of one site look like one template?
|
|
2
|
+
|
|
3
|
+
Per-page detectors catch the purple gradient and the Inter font. The two
|
|
4
|
+
things people actually say about machine-made sites are "it screams AI" and
|
|
5
|
+
"they all look the same", and those are properties of a *set* of pages.
|
|
6
|
+
This package compares pages of one site with each other.
|
|
7
|
+
|
|
8
|
+
>>> import sameness
|
|
9
|
+
>>> pages = [sameness.extract(html, name) for name, html in docs]
|
|
10
|
+
>>> print(sameness.compare(pages))
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .extract import extract, Page
|
|
14
|
+
from .compare import compare, Report, skeleton_similarity
|
|
15
|
+
from .fetch import crawl
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
__all__ = ["extract", "Page", "compare", "Report", "skeleton_similarity", "crawl"]
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""sameness PAGE.html PAGE.html ... | sameness --site https://example.com --max 20"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import glob
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import urllib.parse
|
|
11
|
+
|
|
12
|
+
from .extract import extract
|
|
13
|
+
from .compare import compare
|
|
14
|
+
from .fetch import crawl, read_file, load_local_css
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _short(url: str) -> str:
|
|
18
|
+
u = urllib.parse.urlparse(url)
|
|
19
|
+
path = u.path or "/"
|
|
20
|
+
if u.query:
|
|
21
|
+
path += "?" + u.query[:80]
|
|
22
|
+
return path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main(argv=None) -> int:
|
|
26
|
+
ap = argparse.ArgumentParser(
|
|
27
|
+
prog="sameness",
|
|
28
|
+
description="Do the pages of one site look like one template? Compare content skeletons, shells, fonts, palettes and copy across pages.")
|
|
29
|
+
ap.add_argument("pages", nargs="*", help="HTML files (globs allowed)")
|
|
30
|
+
ap.add_argument("--site", help="crawl this URL, same host only")
|
|
31
|
+
ap.add_argument("--max", type=int, default=20, help="pages to crawl (default 20)")
|
|
32
|
+
ap.add_argument("--no-css", action="store_true", help="do not fetch linked stylesheets")
|
|
33
|
+
ap.add_argument("--json", help="write the report as JSON here")
|
|
34
|
+
ap.add_argument("--pairs", action="store_true", help="print the pairwise similarity table")
|
|
35
|
+
a = ap.parse_args(argv)
|
|
36
|
+
try: # Windows consoles default to a legacy code page
|
|
37
|
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
|
38
|
+
except (AttributeError, ValueError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
pages = []
|
|
42
|
+
if a.site:
|
|
43
|
+
for url, html, css in crawl(a.site, max_pages=a.max, fetch_css=not a.no_css):
|
|
44
|
+
pages.append(extract(html, name=_short(url), extra_css=css))
|
|
45
|
+
files = []
|
|
46
|
+
for pat in a.pages:
|
|
47
|
+
files.extend(sorted(glob.glob(pat)) or [pat])
|
|
48
|
+
for f in files:
|
|
49
|
+
if not os.path.isfile(f):
|
|
50
|
+
print(f" skip {f}: not a file", file=sys.stderr)
|
|
51
|
+
continue
|
|
52
|
+
name, html = read_file(f)
|
|
53
|
+
p = extract(html, name=name)
|
|
54
|
+
if not a.no_css and p.stylesheet_hrefs:
|
|
55
|
+
css = load_local_css(f, p.stylesheet_hrefs)
|
|
56
|
+
if css:
|
|
57
|
+
p = extract(html, name=name, extra_css=css)
|
|
58
|
+
pages.append(p)
|
|
59
|
+
if not pages:
|
|
60
|
+
ap.error("give HTML files or --site URL")
|
|
61
|
+
|
|
62
|
+
rep = compare(pages)
|
|
63
|
+
print(rep)
|
|
64
|
+
if a.pairs:
|
|
65
|
+
print()
|
|
66
|
+
print(f"{'a':24s} {'b':24s} {'skeleton':>8s} {'headings':>8s} {'blocks':>7s}")
|
|
67
|
+
for p in sorted(rep.pairs, key=lambda p: -p.skeleton):
|
|
68
|
+
print(f"{p.a[:24]:24s} {p.b[:24]:24s} {p.skeleton:8.2f} {p.headings:8.2f} {p.classes:7.2f}")
|
|
69
|
+
if a.json:
|
|
70
|
+
with open(a.json, "w", encoding="utf-8") as f:
|
|
71
|
+
json.dump(rep.to_dict(), f, ensure_ascii=False, indent=1)
|
|
72
|
+
print(f"\nwrote {a.json}")
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == "__main__":
|
|
77
|
+
sys.exit(main())
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Compare pages of one site: do they look like one template, and is the shell one shell?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import difflib
|
|
6
|
+
import math
|
|
7
|
+
import re
|
|
8
|
+
from collections import Counter
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Dict, List, Sequence, Tuple
|
|
11
|
+
|
|
12
|
+
from .extract import Page
|
|
13
|
+
|
|
14
|
+
CLONE_THRESHOLD = 0.9
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def skeleton_similarity(a: Sequence[str], b: Sequence[str]) -> float:
|
|
18
|
+
"""Sequence similarity of two tag skeletons, 0..1."""
|
|
19
|
+
if not a and not b:
|
|
20
|
+
return 1.0
|
|
21
|
+
return difflib.SequenceMatcher(None, list(a), list(b), autojunk=False).ratio()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def jaccard(a, b) -> float:
|
|
25
|
+
a, b = set(a), set(b)
|
|
26
|
+
if not a and not b:
|
|
27
|
+
return 1.0
|
|
28
|
+
return len(a & b) / len(a | b)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def symmetry(list_sizes: Sequence[int], class_counts: Dict[str, int]) -> Dict[str, object]:
|
|
32
|
+
"""Perfectly symmetric blocks: N cards each with the same number of bullets, 3x3 and so on."""
|
|
33
|
+
cnt = Counter(list_sizes)
|
|
34
|
+
equal_runs = [(k, v) for k, v in cnt.items() if v >= 3 and k >= 2] # v lists of exactly k items
|
|
35
|
+
repeated = {k: v for k, v in class_counts.items() if v >= 3 and 3 <= v <= 12}
|
|
36
|
+
return {"equal_list_runs": sorted(equal_runs), "repeated_blocks": repeated}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Pair:
|
|
41
|
+
a: str
|
|
42
|
+
b: str
|
|
43
|
+
skeleton: float
|
|
44
|
+
headings: float
|
|
45
|
+
classes: float
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Report:
|
|
50
|
+
pages: List[str]
|
|
51
|
+
pairs: List[Pair] = field(default_factory=list)
|
|
52
|
+
clone_groups: List[List[str]] = field(default_factory=list)
|
|
53
|
+
sameness: float = 0.0 # mean pairwise content-skeleton similarity
|
|
54
|
+
shell_count: int = 0 # distinct header/nav/footer skeletons
|
|
55
|
+
shells: Dict[str, List[str]] = field(default_factory=dict)
|
|
56
|
+
font_sets: Dict[str, List[str]] = field(default_factory=dict)
|
|
57
|
+
palette_overlap: float = 1.0 # mean pairwise jaccard of hex colours
|
|
58
|
+
duplicate_descriptions: List[Tuple[str, List[str]]] = field(default_factory=list)
|
|
59
|
+
duplicate_titles: List[Tuple[str, List[str]]] = field(default_factory=list)
|
|
60
|
+
shared_headings: List[Tuple[str, int]] = field(default_factory=list)
|
|
61
|
+
per_page: Dict[str, Dict[str, object]] = field(default_factory=dict)
|
|
62
|
+
notes: List[str] = field(default_factory=list)
|
|
63
|
+
|
|
64
|
+
def to_dict(self) -> dict:
|
|
65
|
+
d = self.__dict__.copy()
|
|
66
|
+
d["pairs"] = [p.__dict__ for p in self.pairs]
|
|
67
|
+
return d
|
|
68
|
+
|
|
69
|
+
def __str__(self) -> str:
|
|
70
|
+
n = len(self.pages)
|
|
71
|
+
L = [f"{n} pages", ""]
|
|
72
|
+
L.append(f"sameness {self.sameness:.2f} mean similarity of the content skeleton across page pairs (1 = one template)")
|
|
73
|
+
L.append(f"shells {self.shell_count} distinct header/nav/footer structures (1 = one site)")
|
|
74
|
+
L.append(f"palette {self.palette_overlap:.2f} mean overlap of hex colours between pages (1 = one palette)")
|
|
75
|
+
if self.clone_groups:
|
|
76
|
+
L.append("")
|
|
77
|
+
L.append(f"near-clones (content skeleton >= {CLONE_THRESHOLD:.0%} similar):")
|
|
78
|
+
for g in self.clone_groups:
|
|
79
|
+
L.append(" " + " ~ ".join(g))
|
|
80
|
+
if self.shell_count > 1:
|
|
81
|
+
L.append("")
|
|
82
|
+
L.append("shells:")
|
|
83
|
+
for key, names in self.shells.items():
|
|
84
|
+
L.append(f" {len(names):3d} page(s) {', '.join(names[:6])}{' ...' if len(names) > 6 else ''}")
|
|
85
|
+
if len(self.font_sets) > 1:
|
|
86
|
+
L.append("")
|
|
87
|
+
L.append("font sets:")
|
|
88
|
+
for key, names in self.font_sets.items():
|
|
89
|
+
L.append(f" {key or '(none declared)':40s} {', '.join(names[:5])}{' ...' if len(names) > 5 else ''}")
|
|
90
|
+
if self.duplicate_descriptions:
|
|
91
|
+
L.append("")
|
|
92
|
+
L.append("same meta description on several pages:")
|
|
93
|
+
for desc, names in self.duplicate_descriptions:
|
|
94
|
+
L.append(f" {len(names):3d} page(s) \"{desc[:70]}\"")
|
|
95
|
+
if self.duplicate_titles:
|
|
96
|
+
L.append("")
|
|
97
|
+
L.append("same <title> on several pages:")
|
|
98
|
+
for t, names in self.duplicate_titles:
|
|
99
|
+
L.append(f" {len(names):3d} page(s) \"{t[:70]}\"")
|
|
100
|
+
if self.shared_headings:
|
|
101
|
+
L.append("")
|
|
102
|
+
L.append("headings that appear on several pages (outside header/nav/footer):")
|
|
103
|
+
for h, c in self.shared_headings[:12]:
|
|
104
|
+
L.append(f" {c:3d}x {h[:70]}")
|
|
105
|
+
L.append("")
|
|
106
|
+
w = min(48, max(len(n) for n in self.pages) + 1)
|
|
107
|
+
L.append(f"{'page':{w}s} {'words':>6s} {'emoji':>5s} {'grad':>4s} {'sym':>5s} repeated blocks")
|
|
108
|
+
for name in self.pages:
|
|
109
|
+
pp = self.per_page[name]
|
|
110
|
+
sym = pp["symmetry"]
|
|
111
|
+
blocks = ", ".join(f"{k[:24]}x{v}" for k, v in list(sym["repeated_blocks"].items())[:3])
|
|
112
|
+
runs = " ".join(f"{v}x{k}" for k, v in sym["equal_list_runs"][:2])
|
|
113
|
+
L.append(f"{name[:w]:{w}s} {pp['words']:6d} {pp['emoji']:5d} {pp['gradients']:4d} {runs:>5s} {blocks}")
|
|
114
|
+
if self.notes:
|
|
115
|
+
L.append("")
|
|
116
|
+
for w in self.notes:
|
|
117
|
+
L.append(f"note: {w}")
|
|
118
|
+
return "\n".join(L)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _group_duplicates(items: List[Tuple[str, str]]) -> List[Tuple[str, List[str]]]:
|
|
122
|
+
by: Dict[str, List[str]] = {}
|
|
123
|
+
for name, val in items:
|
|
124
|
+
if val:
|
|
125
|
+
by.setdefault(val, []).append(name)
|
|
126
|
+
return sorted([(v, n) for v, n in by.items() if len(n) > 1], key=lambda x: -len(x[1]))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def compare(pages: Sequence[Page]) -> Report:
|
|
130
|
+
names = [p.name for p in pages]
|
|
131
|
+
rep = Report(pages=names)
|
|
132
|
+
if len(pages) < 2:
|
|
133
|
+
rep.notes.append("need at least two pages to measure sameness")
|
|
134
|
+
for p in pages:
|
|
135
|
+
rep.per_page[p.name] = _per_page(p)
|
|
136
|
+
return rep
|
|
137
|
+
|
|
138
|
+
sims, pal = [], []
|
|
139
|
+
for i in range(len(pages)):
|
|
140
|
+
for j in range(i + 1, len(pages)):
|
|
141
|
+
a, b = pages[i], pages[j]
|
|
142
|
+
s = skeleton_similarity(a.skeleton, b.skeleton)
|
|
143
|
+
h = jaccard([t for _, t in a.headings], [t for _, t in b.headings])
|
|
144
|
+
c = jaccard(a.repeated_classes().keys(), b.repeated_classes().keys())
|
|
145
|
+
rep.pairs.append(Pair(a.name, b.name, s, h, c))
|
|
146
|
+
sims.append(s)
|
|
147
|
+
pal.append(jaccard(a.colors, b.colors) if (a.colors or b.colors) else 1.0)
|
|
148
|
+
rep.sameness = float(sum(sims) / len(sims))
|
|
149
|
+
rep.palette_overlap = float(sum(pal) / len(pal))
|
|
150
|
+
|
|
151
|
+
# clone groups: union-find over pairs above threshold
|
|
152
|
+
parent = {n: n for n in names}
|
|
153
|
+
|
|
154
|
+
def find(x):
|
|
155
|
+
while parent[x] != x:
|
|
156
|
+
parent[x] = parent[parent[x]]
|
|
157
|
+
x = parent[x]
|
|
158
|
+
return x
|
|
159
|
+
|
|
160
|
+
for p in rep.pairs:
|
|
161
|
+
if p.skeleton >= CLONE_THRESHOLD:
|
|
162
|
+
parent[find(p.a)] = find(p.b)
|
|
163
|
+
groups: Dict[str, List[str]] = {}
|
|
164
|
+
for n in names:
|
|
165
|
+
groups.setdefault(find(n), []).append(n)
|
|
166
|
+
rep.clone_groups = sorted([g for g in groups.values() if len(g) > 1], key=lambda g: -len(g))
|
|
167
|
+
|
|
168
|
+
# shells
|
|
169
|
+
shells: Dict[str, List[str]] = {}
|
|
170
|
+
for p in pages:
|
|
171
|
+
shells.setdefault(" ".join(p.shell), []).append(p.name)
|
|
172
|
+
rep.shells = dict(sorted(shells.items(), key=lambda kv: -len(kv[1])))
|
|
173
|
+
rep.shell_count = len(shells)
|
|
174
|
+
|
|
175
|
+
fonts: Dict[str, List[str]] = {}
|
|
176
|
+
for p in pages:
|
|
177
|
+
fonts.setdefault(", ".join(p.fonts), []).append(p.name)
|
|
178
|
+
rep.font_sets = dict(sorted(fonts.items(), key=lambda kv: -len(kv[1])))
|
|
179
|
+
|
|
180
|
+
rep.duplicate_descriptions = _group_duplicates([(p.name, p.description) for p in pages])
|
|
181
|
+
rep.duplicate_titles = _group_duplicates([(p.name, p.title) for p in pages])
|
|
182
|
+
hc = Counter()
|
|
183
|
+
for p in pages:
|
|
184
|
+
for _, t in set(p.headings):
|
|
185
|
+
hc[t] += 1
|
|
186
|
+
rep.shared_headings = [(t, c) for t, c in hc.most_common() if c > 1]
|
|
187
|
+
|
|
188
|
+
for p in pages:
|
|
189
|
+
rep.per_page[p.name] = _per_page(p)
|
|
190
|
+
|
|
191
|
+
# notes
|
|
192
|
+
if rep.sameness >= 0.85:
|
|
193
|
+
rep.notes.append("pages share one content skeleton; a reader will see one template with the words swapped")
|
|
194
|
+
if rep.clone_groups:
|
|
195
|
+
rep.notes.append("near-clones: pages of one kind (articles, product pages) are expected to share a skeleton; "
|
|
196
|
+
"the question is whether pages of different kinds do too")
|
|
197
|
+
if rep.shell_count > 1:
|
|
198
|
+
rep.notes.append(f"{rep.shell_count} different shells on one site: the pages do not read as one place")
|
|
199
|
+
if len(rep.font_sets) > 1:
|
|
200
|
+
rep.notes.append(f"{len(rep.font_sets)} different font sets; one is usual")
|
|
201
|
+
if rep.duplicate_descriptions:
|
|
202
|
+
n = sum(len(v) for _, v in rep.duplicate_descriptions)
|
|
203
|
+
rep.notes.append(f"{n} pages reuse a meta description; search results will show the same sentence for each")
|
|
204
|
+
return rep
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _per_page(p: Page) -> Dict[str, object]:
|
|
208
|
+
return {
|
|
209
|
+
"title": p.title, "words": p.word_count, "emoji": p.emoji, "gradients": p.gradients,
|
|
210
|
+
"headings": len(p.headings), "skeleton_len": len(p.skeleton),
|
|
211
|
+
"symmetry": symmetry(p.list_sizes, p.class_counts),
|
|
212
|
+
"fonts": p.fonts, "n_colors": len(p.colors),
|
|
213
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Turn one HTML document into the features the comparison needs.
|
|
2
|
+
|
|
3
|
+
No rendering, no dependencies. Everything comes from the markup and the
|
|
4
|
+
inline or linked stylesheet text the caller hands over.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from html.parser import HTMLParser
|
|
12
|
+
from typing import Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
SKELETON_TAGS = {
|
|
15
|
+
"header", "nav", "main", "section", "article", "aside", "footer",
|
|
16
|
+
"h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "table", "form", "img",
|
|
17
|
+
"button", "figure", "blockquote", "pre",
|
|
18
|
+
}
|
|
19
|
+
SHELL_TAGS = {"header", "nav", "footer"}
|
|
20
|
+
HEADING_TAGS = {"h1", "h2", "h3", "h4", "h5", "h6"}
|
|
21
|
+
SKIP_TEXT = {"script", "style", "noscript", "template", "svg"}
|
|
22
|
+
|
|
23
|
+
EMOJI = re.compile(
|
|
24
|
+
"[\U0001F000-\U0001FAFF\U00002600-\U000027BF\U00002B00-\U00002BFF\U0001F1E6-\U0001F1FF\U0000FE0F]"
|
|
25
|
+
)
|
|
26
|
+
HEX = re.compile(r"#(?:[0-9a-fA-F]{3}){1,2}\b")
|
|
27
|
+
FONT = re.compile(r"font-family\s*:\s*([^;}]+)", re.I)
|
|
28
|
+
RADIUS = re.compile(r"border-radius\s*:\s*([0-9.]+)(px|rem|em|%)", re.I)
|
|
29
|
+
GRID = re.compile(r"grid-template-columns\s*:\s*([^;}]+)", re.I)
|
|
30
|
+
GRADIENT = re.compile(r"(linear|radial|conic)-gradient", re.I)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Page:
|
|
35
|
+
name: str
|
|
36
|
+
title: str = ""
|
|
37
|
+
description: str = ""
|
|
38
|
+
lang: str = ""
|
|
39
|
+
skeleton: List[str] = field(default_factory=list) # tag sequence, content region
|
|
40
|
+
shell: List[str] = field(default_factory=list) # tag sequence inside header/nav/footer
|
|
41
|
+
headings: List[Tuple[int, str]] = field(default_factory=list)
|
|
42
|
+
list_sizes: List[int] = field(default_factory=list) # items per ul/ol in content
|
|
43
|
+
class_counts: Dict[str, int] = field(default_factory=dict) # class value -> occurrences
|
|
44
|
+
text: str = ""
|
|
45
|
+
word_count: int = 0
|
|
46
|
+
emoji: int = 0
|
|
47
|
+
css_text: str = ""
|
|
48
|
+
fonts: List[str] = field(default_factory=list)
|
|
49
|
+
colors: List[str] = field(default_factory=list)
|
|
50
|
+
radii: List[str] = field(default_factory=list)
|
|
51
|
+
grids: List[str] = field(default_factory=list)
|
|
52
|
+
gradients: int = 0
|
|
53
|
+
stylesheet_hrefs: List[str] = field(default_factory=list)
|
|
54
|
+
n_tags: int = 0
|
|
55
|
+
|
|
56
|
+
def repeated_classes(self, min_count: int = 3) -> Dict[str, int]:
|
|
57
|
+
return {k: v for k, v in self.class_counts.items() if v >= min_count}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _Parser(HTMLParser):
|
|
61
|
+
def __init__(self):
|
|
62
|
+
super().__init__(convert_charrefs=True)
|
|
63
|
+
self.page = Page(name="")
|
|
64
|
+
self._stack: List[str] = []
|
|
65
|
+
self._skip = 0
|
|
66
|
+
self._in_title = False
|
|
67
|
+
self._heading: Optional[Tuple[int, List[str]]] = None
|
|
68
|
+
self._list_stack: List[int] = []
|
|
69
|
+
self._text: List[str] = []
|
|
70
|
+
self._style: List[str] = []
|
|
71
|
+
self._in_style = False
|
|
72
|
+
|
|
73
|
+
# --- helpers
|
|
74
|
+
def _in_shell(self) -> bool:
|
|
75
|
+
return any(t in SHELL_TAGS for t in self._stack)
|
|
76
|
+
|
|
77
|
+
def handle_starttag(self, tag, attrs):
|
|
78
|
+
a = dict(attrs)
|
|
79
|
+
self.page.n_tags += 1
|
|
80
|
+
if tag == "html" and a.get("lang"):
|
|
81
|
+
self.page.lang = a["lang"]
|
|
82
|
+
if tag == "meta" and (a.get("name") or "").lower() == "description":
|
|
83
|
+
self.page.description = (a.get("content") or "").strip()
|
|
84
|
+
if tag == "link" and "stylesheet" in (a.get("rel") or "") and a.get("href"):
|
|
85
|
+
self.page.stylesheet_hrefs.append(a["href"])
|
|
86
|
+
if tag == "title":
|
|
87
|
+
self._in_title = True
|
|
88
|
+
if tag == "style":
|
|
89
|
+
self._in_style = True
|
|
90
|
+
if tag in SKIP_TEXT:
|
|
91
|
+
self._skip += 1
|
|
92
|
+
if a.get("style"):
|
|
93
|
+
self._style.append(a["style"])
|
|
94
|
+
cls = (a.get("class") or "").strip()
|
|
95
|
+
if cls and not self._in_shell():
|
|
96
|
+
self.page.class_counts[cls] = self.page.class_counts.get(cls, 0) + 1
|
|
97
|
+
if tag in SKELETON_TAGS:
|
|
98
|
+
(self.page.shell if self._in_shell() or tag in SHELL_TAGS else self.page.skeleton).append(tag)
|
|
99
|
+
if tag in HEADING_TAGS and not self._in_shell():
|
|
100
|
+
self._heading = (int(tag[1]), [])
|
|
101
|
+
if tag in ("ul", "ol") and not self._in_shell():
|
|
102
|
+
self._list_stack.append(0)
|
|
103
|
+
if tag == "li" and self._list_stack:
|
|
104
|
+
self._list_stack[-1] += 1
|
|
105
|
+
self._stack.append(tag)
|
|
106
|
+
|
|
107
|
+
def handle_endtag(self, tag):
|
|
108
|
+
if tag == "title":
|
|
109
|
+
self._in_title = False
|
|
110
|
+
if tag == "style":
|
|
111
|
+
self._in_style = False
|
|
112
|
+
if tag in SKIP_TEXT and self._skip:
|
|
113
|
+
self._skip -= 1
|
|
114
|
+
if tag in HEADING_TAGS and self._heading is not None:
|
|
115
|
+
lvl, parts = self._heading
|
|
116
|
+
txt = re.sub(r"\s+", " ", "".join(parts)).strip()
|
|
117
|
+
if txt:
|
|
118
|
+
self.page.headings.append((lvl, txt))
|
|
119
|
+
self._heading = None
|
|
120
|
+
if tag in ("ul", "ol") and self._list_stack:
|
|
121
|
+
n = self._list_stack.pop()
|
|
122
|
+
if n:
|
|
123
|
+
self.page.list_sizes.append(n)
|
|
124
|
+
# pop to the matching open tag if present
|
|
125
|
+
if tag in self._stack:
|
|
126
|
+
while self._stack and self._stack.pop() != tag:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
def handle_data(self, data):
|
|
130
|
+
if self._in_title:
|
|
131
|
+
self.page.title += data
|
|
132
|
+
if self._in_style:
|
|
133
|
+
self._style.append(data)
|
|
134
|
+
return
|
|
135
|
+
if self._skip:
|
|
136
|
+
return
|
|
137
|
+
if self._heading is not None:
|
|
138
|
+
self._heading[1].append(data)
|
|
139
|
+
if not self._in_shell():
|
|
140
|
+
self._text.append(data)
|
|
141
|
+
|
|
142
|
+
def finish(self, name: str, extra_css: str = "") -> Page:
|
|
143
|
+
p = self.page
|
|
144
|
+
p.name = name
|
|
145
|
+
p.title = re.sub(r"\s+", " ", p.title).strip()
|
|
146
|
+
p.text = re.sub(r"\s+", " ", " ".join(self._text)).strip()
|
|
147
|
+
p.word_count = _count_words(p.text)
|
|
148
|
+
p.emoji = len(EMOJI.findall(p.text))
|
|
149
|
+
css = "\n".join(self._style) + "\n" + extra_css
|
|
150
|
+
p.css_text = css
|
|
151
|
+
p.fonts = sorted({_first_font(m) for m in FONT.findall(css)} - {""})
|
|
152
|
+
p.colors = sorted({c.lower() for c in HEX.findall(css)})
|
|
153
|
+
p.radii = sorted({f"{v}{u}" for v, u in RADIUS.findall(css)})
|
|
154
|
+
p.grids = sorted({re.sub(r"\s+", " ", g).strip()[:60] for g in GRID.findall(css)})
|
|
155
|
+
p.gradients = len(GRADIENT.findall(css))
|
|
156
|
+
return p
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _first_font(decl: str) -> str:
|
|
160
|
+
first = decl.split(",")[0].strip().strip("'\"")
|
|
161
|
+
return first if first and not first.startswith("var(") else ""
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _count_words(text: str) -> int:
|
|
165
|
+
"""Words for space-separated scripts; characters for CJK, where spaces are rare."""
|
|
166
|
+
cjk = len(re.findall(r"[-ヿ㐀-鿿]", text))
|
|
167
|
+
latin = len(re.findall(r"[A-Za-z0-9]+", text))
|
|
168
|
+
return cjk + latin
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def extract(html: str, name: str = "", extra_css: str = "") -> Page:
|
|
172
|
+
p = _Parser()
|
|
173
|
+
p.feed(html)
|
|
174
|
+
p.close()
|
|
175
|
+
return p.finish(name, extra_css)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Read pages from files or from a site. Standard library only."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.parse
|
|
9
|
+
import urllib.request
|
|
10
|
+
from html.parser import HTMLParser
|
|
11
|
+
from typing import Dict, List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
UA = "sameness/0.1 (+https://github.com/tsurutanmen/sameness)"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_file(path: str) -> Tuple[str, str]:
|
|
17
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
18
|
+
return os.path.basename(path), f.read()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get(url: str, timeout: float = 15.0) -> Optional[str]:
|
|
22
|
+
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "text/html,text/css;q=0.9,*/*;q=0.5"})
|
|
23
|
+
try:
|
|
24
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
25
|
+
ctype = r.headers.get("Content-Type", "")
|
|
26
|
+
raw = r.read()
|
|
27
|
+
except Exception as exc: # noqa: BLE001
|
|
28
|
+
print(f" skip {url}: {exc}", file=sys.stderr)
|
|
29
|
+
return None
|
|
30
|
+
if "html" not in ctype and "css" not in ctype and not url.endswith(".css"):
|
|
31
|
+
return None
|
|
32
|
+
m = re.search(r"charset=([\w-]+)", ctype)
|
|
33
|
+
enc = m.group(1) if m else "utf-8"
|
|
34
|
+
try:
|
|
35
|
+
return raw.decode(enc, errors="replace")
|
|
36
|
+
except LookupError:
|
|
37
|
+
return raw.decode("utf-8", errors="replace")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _Links(HTMLParser):
|
|
41
|
+
def __init__(self):
|
|
42
|
+
super().__init__()
|
|
43
|
+
self.hrefs: List[str] = []
|
|
44
|
+
self.css: List[str] = []
|
|
45
|
+
|
|
46
|
+
def handle_starttag(self, tag, attrs):
|
|
47
|
+
a = dict(attrs)
|
|
48
|
+
if tag == "a" and a.get("href"):
|
|
49
|
+
self.hrefs.append(a["href"])
|
|
50
|
+
if tag == "link" and "stylesheet" in (a.get("rel") or "") and a.get("href"):
|
|
51
|
+
self.css.append(a["href"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _same_site(u: str, base: str) -> bool:
|
|
55
|
+
a, b = urllib.parse.urlparse(u), urllib.parse.urlparse(base)
|
|
56
|
+
return a.netloc == b.netloc and a.scheme in ("http", "https")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _clean(u: str) -> str:
|
|
60
|
+
u, _ = urllib.parse.urldefrag(u)
|
|
61
|
+
return u
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def crawl(start: str, max_pages: int = 20, fetch_css: bool = True) -> List[Tuple[str, str, str]]:
|
|
65
|
+
"""Breadth-first over same-host links. Returns (url, html, css_text) triples."""
|
|
66
|
+
seen, queue, out = set(), [_clean(start)], []
|
|
67
|
+
css_cache: Dict[str, str] = {}
|
|
68
|
+
while queue and len(out) < max_pages:
|
|
69
|
+
url = queue.pop(0)
|
|
70
|
+
if url in seen:
|
|
71
|
+
continue
|
|
72
|
+
seen.add(url)
|
|
73
|
+
html = get(url)
|
|
74
|
+
if html is None:
|
|
75
|
+
continue
|
|
76
|
+
lp = _Links()
|
|
77
|
+
try:
|
|
78
|
+
lp.feed(html)
|
|
79
|
+
except Exception: # noqa: BLE001
|
|
80
|
+
pass
|
|
81
|
+
css_text = ""
|
|
82
|
+
if fetch_css:
|
|
83
|
+
for href in lp.css[:6]:
|
|
84
|
+
cu = urllib.parse.urljoin(url, href)
|
|
85
|
+
if cu not in css_cache:
|
|
86
|
+
css_cache[cu] = get(cu) or ""
|
|
87
|
+
css_text += "\n" + css_cache[cu]
|
|
88
|
+
out.append((url, html, css_text))
|
|
89
|
+
for href in lp.hrefs:
|
|
90
|
+
nu = _clean(urllib.parse.urljoin(url, href))
|
|
91
|
+
if _same_site(nu, start) and nu not in seen and not re.search(r"\.(png|jpe?g|gif|svg|pdf|zip|css|js|ico|webp|mp4)$", nu, re.I):
|
|
92
|
+
queue.append(nu)
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def load_local_css(html_path: str, hrefs: List[str]) -> str:
|
|
97
|
+
"""Resolve relative stylesheet hrefs next to a local HTML file."""
|
|
98
|
+
base = os.path.dirname(os.path.abspath(html_path))
|
|
99
|
+
parts = []
|
|
100
|
+
for h in hrefs:
|
|
101
|
+
if h.startswith(("http://", "https://", "//")):
|
|
102
|
+
continue
|
|
103
|
+
p = os.path.normpath(os.path.join(base, h.split("?")[0].lstrip("/")))
|
|
104
|
+
if os.path.exists(p):
|
|
105
|
+
with open(p, "r", encoding="utf-8", errors="replace") as f:
|
|
106
|
+
parts.append(f.read())
|
|
107
|
+
return "\n".join(parts)
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sameness
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Do the pages of one site look like one template? Compare content skeletons, shells, fonts, palettes and copy across pages. No dependencies.
|
|
5
|
+
Author: Tsuruta Lab
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/tsurutanmen/sameness
|
|
8
|
+
Project-URL: Issues, https://github.com/tsurutanmen/sameness/issues
|
|
9
|
+
Keywords: design,ai slop,template,website audit,html,consistency
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# sameness
|
|
23
|
+
|
|
24
|
+
Do the pages of one site look like one template?
|
|
25
|
+
|
|
26
|
+
Detectors for the AI look score one page at a time: the purple gradient, Inter, three feature cards.
|
|
27
|
+
But when people are asked what gives a machine-made site away, the two answers at the top are not
|
|
28
|
+
features. In a Reddit corpus of 3,033 on-topic comments, "screams AI" (6.4%) and "they all look the
|
|
29
|
+
same" (6.1%) each beat every specific feature; purple, gradients, animations and glass together add
|
|
30
|
+
up to about 7%. Sameness is a property of a *set* of pages. `sameness` compares the pages of one
|
|
31
|
+
site with each other.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install git+https://github.com/tsurutanmen/sameness
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
No dependencies. Python 3.9 or later.
|
|
38
|
+
|
|
39
|
+
## Thirty seconds
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
$ sameness tests/fixtures/clone_*.html
|
|
43
|
+
|
|
44
|
+
4 pages
|
|
45
|
+
|
|
46
|
+
sameness 1.00 mean similarity of the content skeleton across page pairs (1 = one template)
|
|
47
|
+
shells 1 distinct header/nav/footer structures (1 = one site)
|
|
48
|
+
palette 1.00 mean overlap of hex colours between pages (1 = one palette)
|
|
49
|
+
|
|
50
|
+
near-clones (content skeleton >= 90% similar):
|
|
51
|
+
clone_0.html ~ clone_1.html ~ clone_2.html ~ clone_3.html
|
|
52
|
+
|
|
53
|
+
same meta description on several pages:
|
|
54
|
+
4 page(s) "One platform to elevate your workflow."
|
|
55
|
+
|
|
56
|
+
headings that appear on several pages (outside header/nav/footer):
|
|
57
|
+
4x Ready?
|
|
58
|
+
|
|
59
|
+
page words emoji grad sym repeated blocks
|
|
60
|
+
clone_0.html 36 0 0 3x3 cardx3
|
|
61
|
+
...
|
|
62
|
+
note: pages share one content skeleton; a reader will see one template with the words swapped
|
|
63
|
+
note: 4 pages reuse a meta description; search results will show the same sentence for each
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or crawl a live site, same host only, linked stylesheets included:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
sameness --site https://example.com --max 20
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## What it measures
|
|
73
|
+
|
|
74
|
+
| line | meaning |
|
|
75
|
+
|---|---|
|
|
76
|
+
| `sameness` | Mean similarity of the *content* skeleton (the tag sequence of the page with header, nav and footer removed) over all page pairs. 1.00 means every page is the same template. |
|
|
77
|
+
| `near-clones` | Groups of pages whose content skeletons are at least 90% similar. Pages of one kind (articles, product pages) are expected to share a skeleton; the question is whether pages of different kinds do too. |
|
|
78
|
+
| `shells` | Number of distinct header/nav/footer structures. A site has one. When it has four, the pages do not read as one place, which is the opposite failure and just as visible. |
|
|
79
|
+
| `font sets`, `palette` | Fonts declared and hex colours used, per page and their overlap across pages. |
|
|
80
|
+
| `same meta description`, `same <title>` | Copy reused across pages, usually a default that was never replaced. |
|
|
81
|
+
| `sym` | Perfectly symmetric blocks on a page: `3x3` means three lists of exactly three items. |
|
|
82
|
+
| `--pairs` | The full pairwise table: skeleton similarity, shared headings, shared repeated block classes. |
|
|
83
|
+
|
|
84
|
+
Everything comes from the markup and the stylesheet text. Nothing is rendered, so layout that only
|
|
85
|
+
exists after JavaScript runs is not seen.
|
|
86
|
+
|
|
87
|
+
## Two real sites
|
|
88
|
+
|
|
89
|
+
A 15-page site of ours that had been built in four sittings. No page-level detector complained; the
|
|
90
|
+
Tell Score was A or B on every page. `sameness` reported:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
sameness 0.26
|
|
94
|
+
shells 8 distinct header/nav/footer structures (1 = one site)
|
|
95
|
+
palette 0.26
|
|
96
|
+
font sets: 5
|
|
97
|
+
note: 8 different shells on one site: the pages do not read as one place
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
That was the finding a reader had already made ("it looks like four different sites"), now as a
|
|
101
|
+
number. The other direction, a small research site with one shell:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
sameness 0.51
|
|
105
|
+
shells 1
|
|
106
|
+
palette 1.00
|
|
107
|
+
near-clones: the six research notes share one article skeleton
|
|
108
|
+
same meta description on several pages: 2 page(s) "aaaaaaaaaaaaaa"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The near-clones are articles and are supposed to look alike. The placeholder description was real
|
|
112
|
+
and had been missed.
|
|
113
|
+
|
|
114
|
+
## Python
|
|
115
|
+
|
|
116
|
+
```python
|
|
117
|
+
import sameness
|
|
118
|
+
|
|
119
|
+
pages = [sameness.extract(html, name=url) for url, html in docs]
|
|
120
|
+
rep = sameness.compare(pages)
|
|
121
|
+
print(rep)
|
|
122
|
+
rep.to_dict() # everything, JSON-serialisable
|
|
123
|
+
|
|
124
|
+
for p in rep.pairs: # pairwise numbers
|
|
125
|
+
print(p.a, p.b, p.skeleton, p.headings, p.classes)
|
|
126
|
+
|
|
127
|
+
for url, html, css in sameness.crawl("https://example.com", max_pages=20):
|
|
128
|
+
...
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Claude Code skill
|
|
132
|
+
|
|
133
|
+
`skill/sameness/SKILL.md` tells Claude Code to run this after building or editing more than one page
|
|
134
|
+
of a site, and how to read the result. Install by copying the folder:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
cp -r skill/sameness ~/.claude/skills/sameness
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
## Related tools
|
|
141
|
+
|
|
142
|
+
Per-page detectors, which this complements rather than replaces:
|
|
143
|
+
[ai-design-tells](https://github.com/hankimis/ai-design-tells) (Tell Score, 27 tells),
|
|
144
|
+
[avoid-ai-design](https://github.com/funboy322/avoid-ai-design) (a rewrite skill),
|
|
145
|
+
[vibecoded-audit](https://github.com/KreshBack/vibecoded-audit) (49 tells, crawls but scores per page).
|
|
146
|
+
The Reddit corpus is from
|
|
147
|
+
[vibecoded-design-tells](https://github.com/JCarterJohnson/vibecoded-design-tells).
|
|
148
|
+
|
|
149
|
+
## License
|
|
150
|
+
|
|
151
|
+
MIT.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
sameness/__init__.py
|
|
5
|
+
sameness/cli.py
|
|
6
|
+
sameness/compare.py
|
|
7
|
+
sameness/extract.py
|
|
8
|
+
sameness/fetch.py
|
|
9
|
+
sameness.egg-info/PKG-INFO
|
|
10
|
+
sameness.egg-info/SOURCES.txt
|
|
11
|
+
sameness.egg-info/dependency_links.txt
|
|
12
|
+
sameness.egg-info/entry_points.txt
|
|
13
|
+
sameness.egg-info/requires.txt
|
|
14
|
+
sameness.egg-info/top_level.txt
|
|
15
|
+
tests/test_sameness.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sameness
|
sameness-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import glob
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
import sameness
|
|
9
|
+
from sameness.extract import extract
|
|
10
|
+
from sameness.compare import compare
|
|
11
|
+
|
|
12
|
+
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
13
|
+
FIX = os.path.join(HERE, "fixtures")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(scope="module", autouse=True)
|
|
17
|
+
def fixtures():
|
|
18
|
+
if not os.path.isdir(FIX):
|
|
19
|
+
subprocess.check_call([sys.executable, os.path.join(HERE, "make_fixtures.py")])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load(pattern):
|
|
23
|
+
pages = []
|
|
24
|
+
for f in sorted(glob.glob(os.path.join(FIX, pattern))):
|
|
25
|
+
with open(f, encoding="utf-8") as fh:
|
|
26
|
+
pages.append(extract(fh.read(), name=os.path.basename(f)))
|
|
27
|
+
return pages
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_extract_basics():
|
|
31
|
+
p = load("japanese.html")[0]
|
|
32
|
+
assert p.title == "学習サイト" and p.description == "説明" and p.lang == "ja"
|
|
33
|
+
assert p.headings == [(1, "今日の学習 🐢")]
|
|
34
|
+
assert p.list_sizes == [2]
|
|
35
|
+
assert p.emoji == 1
|
|
36
|
+
assert p.word_count > 5 # CJK counted by character
|
|
37
|
+
assert "nav" in p.shell and "h1" in p.skeleton and "h1" not in p.shell
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_clones_are_one_template():
|
|
41
|
+
rep = compare(load("clone_*.html"))
|
|
42
|
+
assert rep.sameness > 0.95
|
|
43
|
+
assert rep.clone_groups and len(rep.clone_groups[0]) == 4
|
|
44
|
+
assert rep.shell_count == 1
|
|
45
|
+
assert rep.palette_overlap == 1.0
|
|
46
|
+
assert rep.duplicate_descriptions and len(rep.duplicate_descriptions[0][1]) == 4
|
|
47
|
+
assert any("one template" in n for n in rep.notes)
|
|
48
|
+
sym = rep.per_page["clone_0.html"]["symmetry"]
|
|
49
|
+
assert (3, 3) in sym["equal_list_runs"] # three lists of exactly three items
|
|
50
|
+
assert sym["repeated_blocks"].get("card") == 3
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_diverse_pages_are_not_clones():
|
|
54
|
+
rep = compare(load("diverse_*.html"))
|
|
55
|
+
assert rep.sameness < 0.6
|
|
56
|
+
assert not rep.clone_groups
|
|
57
|
+
assert rep.shell_count == 1
|
|
58
|
+
assert not rep.duplicate_descriptions
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_forked_shells_are_counted():
|
|
62
|
+
rep = compare(load("fork_*.html"))
|
|
63
|
+
assert rep.shell_count == 2
|
|
64
|
+
assert len(rep.font_sets) == 2
|
|
65
|
+
assert rep.palette_overlap < 0.5
|
|
66
|
+
assert rep.pairs[0].skeleton > 0.95 # same content skeleton under two shells
|
|
67
|
+
assert any("different shells" in n for n in rep.notes)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_headings_shared_across_pages():
|
|
71
|
+
rep = compare(load("clone_*.html"))
|
|
72
|
+
shared = dict(rep.shared_headings)
|
|
73
|
+
assert shared.get("Ready?") == 4
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_single_page_is_refused_gently():
|
|
77
|
+
rep = compare(load("japanese.html"))
|
|
78
|
+
assert any("at least two" in n for n in rep.notes)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_skeleton_similarity_bounds():
|
|
82
|
+
assert sameness.skeleton_similarity([], []) == 1.0
|
|
83
|
+
assert sameness.skeleton_similarity(["h1", "p"], ["table"]) == 0.0
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_cli_and_json(tmp_path, capsys):
|
|
87
|
+
from sameness.cli import main
|
|
88
|
+
out = tmp_path / "r.json"
|
|
89
|
+
rc = main([os.path.join(FIX, "clone_*.html"), "--json", str(out), "--pairs"])
|
|
90
|
+
assert rc == 0
|
|
91
|
+
text = capsys.readouterr().out
|
|
92
|
+
assert "sameness" in text and "near-clones" in text and out.exists()
|