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 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.
@@ -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,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,2 @@
1
+ [console_scripts]
2
+ sameness = sameness.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest>=7
@@ -0,0 +1 @@
1
+ sameness
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()