perfectclone 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- perfectclone/__init__.py +2 -0
- perfectclone/__main__.py +3 -0
- perfectclone/cli.py +112 -0
- perfectclone/crawl.py +161 -0
- perfectclone/edit.py +109 -0
- perfectclone/flight.py +113 -0
- perfectclone/mirror.py +168 -0
- perfectclone/serve.py +102 -0
- perfectclone/trackers.py +93 -0
- perfectclone/util.py +25 -0
- perfectclone-0.1.0.dist-info/METADATA +192 -0
- perfectclone-0.1.0.dist-info/RECORD +16 -0
- perfectclone-0.1.0.dist-info/WHEEL +5 -0
- perfectclone-0.1.0.dist-info/entry_points.txt +2 -0
- perfectclone-0.1.0.dist-info/licenses/LICENSE +21 -0
- perfectclone-0.1.0.dist-info/top_level.txt +1 -0
perfectclone/__init__.py
ADDED
perfectclone/__main__.py
ADDED
perfectclone/cli.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""perfectclone command line."""
|
|
2
|
+
import argparse
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .edit import localize, rehost, replace
|
|
10
|
+
from .serve import serve
|
|
11
|
+
from .trackers import strip
|
|
12
|
+
from .util import site_dir
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def cmd_clone(a):
|
|
16
|
+
# Imported here so the editing commands don't pay for loading Playwright.
|
|
17
|
+
from .crawl import crawl
|
|
18
|
+
from .mirror import mirror
|
|
19
|
+
|
|
20
|
+
out = Path(a.out)
|
|
21
|
+
site = out / "site"
|
|
22
|
+
print(f"Crawling {a.url}")
|
|
23
|
+
pages, assets, external, skipped = crawl(
|
|
24
|
+
a.url, max_pages=a.max_pages, respect_robots=not a.ignore_robots,
|
|
25
|
+
extract_dir=out / "extract" if a.extract else None,
|
|
26
|
+
)
|
|
27
|
+
if not pages:
|
|
28
|
+
sys.exit("No pages could be loaded." + (f" First problem: {skipped[0]}" if skipped else ""))
|
|
29
|
+
print(f"Mirroring into {site}")
|
|
30
|
+
saved, fetched, failures = mirror(pages, assets, site)
|
|
31
|
+
localize(site, a.url)
|
|
32
|
+
if a.strip_trackers:
|
|
33
|
+
print("Removing trackers")
|
|
34
|
+
strip(site)
|
|
35
|
+
manifest = {
|
|
36
|
+
"source": a.url, "pages": pages, "assets_fetched": fetched, "failures": failures,
|
|
37
|
+
"skipped": skipped, "external_hosts": external,
|
|
38
|
+
}
|
|
39
|
+
(out / "manifest.json").write_text(json.dumps(manifest, indent=2))
|
|
40
|
+
print(f"\nDone: {saved} pages, {fetched} assets → {site}")
|
|
41
|
+
if failures:
|
|
42
|
+
print(f" {len(failures)} URLs failed to download (see {out / 'manifest.json'})")
|
|
43
|
+
if external:
|
|
44
|
+
print(f" The original also used these hosts (embeds, CDNs, analytics): {', '.join(external)}")
|
|
45
|
+
print(f"\nPreview it: perfectclone serve {out}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_serve(a):
|
|
49
|
+
serve(a.site, a.port)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cmd_replace(a):
|
|
53
|
+
replace(site_dir(a.site), a.old, a.new, variants=not a.exact_case, whole_word=not a.substring)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def cmd_rehost(a):
|
|
57
|
+
rehost(site_dir(a.site), a.old_origin, a.new_origin)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def cmd_strip(a):
|
|
61
|
+
strip(site_dir(a.site))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def main(argv=None):
|
|
65
|
+
p = argparse.ArgumentParser(
|
|
66
|
+
prog="perfectclone", description="Clone any website 1:1 with a real browser, then edit it safely.")
|
|
67
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
68
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
69
|
+
|
|
70
|
+
c = sub.add_parser("clone", help="crawl a site and save a working local copy")
|
|
71
|
+
c.add_argument("url")
|
|
72
|
+
c.add_argument("-o", "--out", default=None, help="output folder (default: the site's hostname)")
|
|
73
|
+
c.add_argument("--max-pages", type=int, default=200)
|
|
74
|
+
c.add_argument("--extract", action="store_true", help="also save each page's text, metadata and a full-page screenshot")
|
|
75
|
+
c.add_argument("--keep-trackers", dest="strip_trackers", action="store_false", help="keep the original analytics/ad pixels")
|
|
76
|
+
c.add_argument("--ignore-robots", action="store_true", help="crawl pages robots.txt disallows")
|
|
77
|
+
c.set_defaults(fn=cmd_clone)
|
|
78
|
+
|
|
79
|
+
s = sub.add_parser("serve", help="serve a clone locally")
|
|
80
|
+
s.add_argument("site")
|
|
81
|
+
s.add_argument("-p", "--port", type=int, default=8000)
|
|
82
|
+
s.set_defaults(fn=cmd_serve)
|
|
83
|
+
|
|
84
|
+
r = sub.add_parser("replace", help="replace text across the clone (skips URLs, Next.js-safe)")
|
|
85
|
+
r.add_argument("site")
|
|
86
|
+
r.add_argument("old")
|
|
87
|
+
r.add_argument("new")
|
|
88
|
+
r.add_argument("--exact-case", action="store_true", help="don't also replace UPPER/lower/Capitalized variants")
|
|
89
|
+
r.add_argument("--substring", action="store_true", help="match inside words too")
|
|
90
|
+
r.set_defaults(fn=cmd_replace)
|
|
91
|
+
|
|
92
|
+
h = sub.add_parser("rehost", help="point canonical/og/sitemap URLs at a new origin")
|
|
93
|
+
h.add_argument("site")
|
|
94
|
+
h.add_argument("old_origin")
|
|
95
|
+
h.add_argument("new_origin")
|
|
96
|
+
h.set_defaults(fn=cmd_rehost)
|
|
97
|
+
|
|
98
|
+
t = sub.add_parser("strip-trackers", help="remove analytics and ad pixels")
|
|
99
|
+
t.add_argument("site")
|
|
100
|
+
t.set_defaults(fn=cmd_strip)
|
|
101
|
+
|
|
102
|
+
a = p.parse_args(argv)
|
|
103
|
+
if a.cmd == "clone":
|
|
104
|
+
if "://" not in a.url:
|
|
105
|
+
a.url = "https://" + a.url
|
|
106
|
+
if not a.out:
|
|
107
|
+
a.out = urlparse(a.url).netloc.removeprefix("www.") or "clone"
|
|
108
|
+
a.fn(a)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
perfectclone/crawl.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Find a site's pages, and every asset they load, by rendering them in Chromium."""
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from collections import deque
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib import robotparser
|
|
9
|
+
from urllib.parse import urldefrag, urljoin, urlparse
|
|
10
|
+
|
|
11
|
+
from playwright.sync_api import sync_playwright
|
|
12
|
+
|
|
13
|
+
from .util import UA, host_of
|
|
14
|
+
|
|
15
|
+
NOT_PAGE = re.compile(
|
|
16
|
+
r"\.(png|jpe?g|gif|svg|webp|avif|ico|pdf|zip|mp4|webm|mp3|woff2?|ttf|otf|css|js|mjs|json|xml|txt|rss)$", re.I)
|
|
17
|
+
|
|
18
|
+
EXTRACT_JS = """
|
|
19
|
+
() => {
|
|
20
|
+
const abs = u => { try { return new URL(u, location.href).href } catch { return u } };
|
|
21
|
+
const meta = {};
|
|
22
|
+
document.querySelectorAll('meta').forEach(m => {
|
|
23
|
+
const k = m.getAttribute('name') || m.getAttribute('property') || m.getAttribute('http-equiv');
|
|
24
|
+
if (k) meta[k] = m.getAttribute('content');
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
title: document.title,
|
|
28
|
+
lang: document.documentElement.lang,
|
|
29
|
+
canonical: document.querySelector('link[rel=canonical]')?.href || null,
|
|
30
|
+
meta,
|
|
31
|
+
headings: [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(h => ({tag: h.tagName, text: h.innerText.trim()})),
|
|
32
|
+
links: [...document.querySelectorAll('a[href]')].map(a => ({text: a.innerText.trim(), href: abs(a.getAttribute('href'))})),
|
|
33
|
+
images: [...document.querySelectorAll('img')].map(i => ({src: i.currentSrc || abs(i.getAttribute('src') || ''), alt: i.alt})),
|
|
34
|
+
forms: [...document.querySelectorAll('form')].map(f => ({action: f.action, method: f.method,
|
|
35
|
+
fields: [...f.querySelectorAll('input,select,textarea')].map(i => ({tag: i.tagName, type: i.type, name: i.name}))})),
|
|
36
|
+
jsonld: [...document.querySelectorAll('script[type="application/ld+json"]')].map(s => s.textContent),
|
|
37
|
+
text: document.body ? document.body.innerText : '',
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
# Scrolling triggers lazy-loaded images, chunks and fonts so they show up in the response log.
|
|
43
|
+
SCROLL_JS = """async () => {
|
|
44
|
+
for (let y = 0; y < document.body.scrollHeight; y += 600) { window.scrollTo(0, y); await new Promise(r => setTimeout(r, 120)); }
|
|
45
|
+
window.scrollTo(0, 0);
|
|
46
|
+
}"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def page_key(url, host):
|
|
50
|
+
"""Normalized URL if it's a same-site page worth visiting, else None."""
|
|
51
|
+
url = urldefrag(url)[0]
|
|
52
|
+
p = urlparse(url)
|
|
53
|
+
if p.scheme not in ("http", "https") or host_of(url) != host or NOT_PAGE.search(p.path):
|
|
54
|
+
return None
|
|
55
|
+
return p._replace(path=p.path.rstrip("/") or "/", query="").geturl()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def slug(url):
|
|
59
|
+
return re.sub(r"[^a-zA-Z0-9]+", "_", urlparse(url).path.strip("/"))[:100] or "index"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def launch(pw, log=print):
|
|
63
|
+
try:
|
|
64
|
+
return pw.chromium.launch()
|
|
65
|
+
except Exception as e:
|
|
66
|
+
if "Executable doesn't exist" not in str(e):
|
|
67
|
+
raise
|
|
68
|
+
log("First run: downloading Chromium for Playwright (one time, about 150 MB)...")
|
|
69
|
+
subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=True)
|
|
70
|
+
return pw.chromium.launch()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def crawl(start, max_pages=200, respect_robots=True, extract_dir=None, log=print):
|
|
74
|
+
"""Visit same-site pages breadth-first. Returns (pages, asset_urls, external_hosts, skipped)."""
|
|
75
|
+
host = host_of(start)
|
|
76
|
+
first = page_key(start, host)
|
|
77
|
+
queue, seen = deque([first]), {first}
|
|
78
|
+
pages, assets, external, skipped = [], set(), set(), []
|
|
79
|
+
if extract_dir:
|
|
80
|
+
Path(extract_dir).mkdir(parents=True, exist_ok=True)
|
|
81
|
+
|
|
82
|
+
with sync_playwright() as pw:
|
|
83
|
+
browser = launch(pw, log)
|
|
84
|
+
ctx = browser.new_context(viewport={"width": 1440, "height": 900}, user_agent=UA)
|
|
85
|
+
|
|
86
|
+
def get(url):
|
|
87
|
+
try:
|
|
88
|
+
r = ctx.request.get(url, timeout=20000)
|
|
89
|
+
return r if r.ok else None
|
|
90
|
+
except Exception:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
robots = robotparser.RobotFileParser()
|
|
94
|
+
r = get(urljoin(start, "/robots.txt"))
|
|
95
|
+
robots.parse(r.text().splitlines() if r else [])
|
|
96
|
+
sitemaps = re.findall(r"(?im)^sitemap:\s*(\S+)", r.text()) if r else []
|
|
97
|
+
for sm in sitemaps or [urljoin(start, "/sitemap.xml")]:
|
|
98
|
+
s = get(urljoin(start, sm))
|
|
99
|
+
if s:
|
|
100
|
+
for loc in re.findall(r"<loc>\s*(.*?)\s*</loc>", s.text()):
|
|
101
|
+
u = page_key(loc, host)
|
|
102
|
+
if u and u not in seen:
|
|
103
|
+
seen.add(u)
|
|
104
|
+
queue.append(u)
|
|
105
|
+
|
|
106
|
+
page = ctx.new_page()
|
|
107
|
+
|
|
108
|
+
def on_response(resp):
|
|
109
|
+
u = resp.url
|
|
110
|
+
if not u.startswith("http"):
|
|
111
|
+
return
|
|
112
|
+
if host_of(u) != host:
|
|
113
|
+
external.add(urlparse(u).netloc)
|
|
114
|
+
elif resp.request.resource_type != "document":
|
|
115
|
+
assets.add(u)
|
|
116
|
+
|
|
117
|
+
page.on("response", on_response)
|
|
118
|
+
|
|
119
|
+
while queue and len(pages) < max_pages:
|
|
120
|
+
url = queue.popleft()
|
|
121
|
+
if respect_robots and not robots.can_fetch("perfectclone", url):
|
|
122
|
+
skipped.append({"url": url, "reason": "robots.txt"})
|
|
123
|
+
continue
|
|
124
|
+
log(f" [{len(pages) + 1}] {url}")
|
|
125
|
+
try:
|
|
126
|
+
resp = page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
|
127
|
+
# A slow third-party image, a video or a polling connection can hold off "load"
|
|
128
|
+
# and "networkidle" forever, so each gets a bounded wait.
|
|
129
|
+
for state, ms in (("load", 15000), ("networkidle", 8000)):
|
|
130
|
+
try:
|
|
131
|
+
page.wait_for_load_state(state, timeout=ms)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
page.evaluate(SCROLL_JS)
|
|
135
|
+
page.wait_for_timeout(400)
|
|
136
|
+
links = page.eval_on_selector_all("a[href]", "as => as.map(a => a.href)")
|
|
137
|
+
except Exception as e:
|
|
138
|
+
skipped.append({"url": url, "reason": str(e).splitlines()[0]})
|
|
139
|
+
continue
|
|
140
|
+
status = resp.status if resp else None
|
|
141
|
+
ctype = resp.headers.get("content-type", "") if resp else ""
|
|
142
|
+
if (status and status >= 400) or "html" not in ctype:
|
|
143
|
+
skipped.append({"url": url, "reason": f"HTTP {status} {ctype}".strip()})
|
|
144
|
+
continue
|
|
145
|
+
pages.append({"url": url, "final_url": page.url, "status": status, "title": page.title()})
|
|
146
|
+
|
|
147
|
+
if extract_dir:
|
|
148
|
+
data = page.evaluate(EXTRACT_JS)
|
|
149
|
+
name = slug(url)
|
|
150
|
+
Path(extract_dir, f"{name}.json").write_text(json.dumps(data, indent=2, ensure_ascii=False))
|
|
151
|
+
Path(extract_dir, f"{name}.txt").write_text(data["text"])
|
|
152
|
+
page.screenshot(path=str(Path(extract_dir, f"{name}.png")), full_page=True)
|
|
153
|
+
|
|
154
|
+
for href in links:
|
|
155
|
+
u = page_key(href, host)
|
|
156
|
+
if u and u not in seen:
|
|
157
|
+
seen.add(u)
|
|
158
|
+
queue.append(u)
|
|
159
|
+
|
|
160
|
+
browser.close()
|
|
161
|
+
return pages, assets, sorted(external), skipped
|
perfectclone/edit.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Edit a cloned site in place without breaking it.
|
|
2
|
+
|
|
3
|
+
Text replacement leaves URLs alone (renaming "Acme" must not break https://acme.com/logo.png or
|
|
4
|
+
/acme/app.js) and goes through the flight payload on Next.js pages so the server HTML and the
|
|
5
|
+
React payload stay identical.
|
|
6
|
+
"""
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
from .flight import edit_flight, has_flight, map_rows, outside_flight
|
|
10
|
+
from .util import text_files
|
|
11
|
+
|
|
12
|
+
# URLs, root-relative asset paths and mailto/tel targets are never rewritten.
|
|
13
|
+
PROTECTED = re.compile(r"""(https?://[^\s"'\\<>)]+|(?<=["'(=])/[^\s"'\\<>)]*|mailto:[^\s"'\\<>]+)""")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def case_variants(old, new):
|
|
17
|
+
"""Acme → Globex also maps ACME → GLOBEX and acme → globex."""
|
|
18
|
+
pairs = [(old, new)]
|
|
19
|
+
for f in (str.upper, str.lower, str.capitalize):
|
|
20
|
+
if f(old) != old and (f(old), f(new)) not in pairs:
|
|
21
|
+
pairs.append((f(old), f(new)))
|
|
22
|
+
return pairs
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def replacer(pairs, whole_word=True):
|
|
26
|
+
rx = [(re.compile(rf"\b{re.escape(o)}\b" if whole_word else re.escape(o)), n) for o, n in pairs]
|
|
27
|
+
|
|
28
|
+
def swap(text):
|
|
29
|
+
parts = PROTECTED.split(text)
|
|
30
|
+
for i in range(0, len(parts), 2): # odd indices are protected
|
|
31
|
+
for r, n in rx:
|
|
32
|
+
parts[i] = r.sub(n, parts[i])
|
|
33
|
+
return "".join(parts)
|
|
34
|
+
return swap
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def apply(site, fn, html_fn=None, log=print):
|
|
38
|
+
"""Run fn over every text file. Flight data (in HTML, or saved .rsc navigation payloads) is
|
|
39
|
+
edited row by row so length-prefixed rows stay valid; html_fn, if given, is used for the HTML
|
|
40
|
+
outside the payload instead of fn."""
|
|
41
|
+
changed = 0
|
|
42
|
+
for p in text_files(site):
|
|
43
|
+
text = p.read_text(errors="surrogateescape")
|
|
44
|
+
suffix = p.suffix.lower()
|
|
45
|
+
if suffix == ".rsc":
|
|
46
|
+
new = map_rows(text, fn)
|
|
47
|
+
elif suffix in (".html", ".htm"):
|
|
48
|
+
new = outside_flight(edit_flight(text, fn), html_fn or fn) if has_flight(text) else (html_fn or fn)(text)
|
|
49
|
+
else:
|
|
50
|
+
new = fn(text)
|
|
51
|
+
if new != text:
|
|
52
|
+
p.write_text(new, errors="surrogateescape")
|
|
53
|
+
changed += 1
|
|
54
|
+
log(f" changed {changed} files")
|
|
55
|
+
return changed
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def replace(site, old, new, variants=True, whole_word=True, log=print):
|
|
59
|
+
pairs = case_variants(old, new) if variants else [(old, new)]
|
|
60
|
+
return apply(site, replacer(pairs, whole_word), log=log)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def origin_variants(origin):
|
|
64
|
+
m = re.match(r"https?://(?:www\.)?([^/]+)", origin.rstrip("/"))
|
|
65
|
+
host = m.group(1) if m else origin
|
|
66
|
+
return re.compile(rf"https?://(?:www\.)?{re.escape(host)}(?![\w.-])")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def rehost(site, old_origin, new_origin, log=print):
|
|
70
|
+
"""Point absolute URLs at a new origin (canonical, og:url, JSON-LD, sitemap)."""
|
|
71
|
+
rx = origin_variants(old_origin)
|
|
72
|
+
new_origin = new_origin.rstrip("/")
|
|
73
|
+
return apply(site, lambda t: rx.sub(new_origin, t), log=log)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
ASSET_TAG = re.compile(r"<(?:img|script|source|video|audio|track|link|image|use)\b[^>]*>", re.I)
|
|
77
|
+
# Module scripts need CORS like fonts do, and import maps list them by absolute URL.
|
|
78
|
+
STYLE_BLOCK = re.compile(r"(<style\b[^>]*>|<script\b[^>]*type=[\"']?importmap[^>]*>)(.*?)(</style>|</script>)", re.S | re.I)
|
|
79
|
+
DOC_LINK = re.compile(r"""rel=["']?(?:canonical|alternate|shortlink|prev|next|author|me)\b""", re.I)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def localize(site, origin, log=print):
|
|
83
|
+
"""Make the site's own absolute asset URLs root-relative, so the clone loads its local copies.
|
|
84
|
+
|
|
85
|
+
Browsers block cross-origin fonts without CORS headers, so a stylesheet or @font-face still
|
|
86
|
+
pointing at the original domain renders in a fallback font. Page links and metadata keep
|
|
87
|
+
their absolute URLs; `rehost` is for those.
|
|
88
|
+
"""
|
|
89
|
+
rx = re.compile(origin_variants(origin).pattern + r"(?=/)")
|
|
90
|
+
|
|
91
|
+
def tag(m):
|
|
92
|
+
t = m.group(0)
|
|
93
|
+
return t if t[:5].lower() == "<link" and DOC_LINK.search(t) else rx.sub("", t)
|
|
94
|
+
|
|
95
|
+
def html(t):
|
|
96
|
+
t = ASSET_TAG.sub(tag, t)
|
|
97
|
+
return STYLE_BLOCK.sub(lambda m: m.group(1) + rx.sub("", m.group(2)) + m.group(3), t)
|
|
98
|
+
|
|
99
|
+
changed = 0
|
|
100
|
+
for p in text_files(site):
|
|
101
|
+
suffix = p.suffix.lower()
|
|
102
|
+
if suffix not in (".css", ".html", ".htm"):
|
|
103
|
+
continue
|
|
104
|
+
text = p.read_text(errors="surrogateescape")
|
|
105
|
+
new = rx.sub("", text) if suffix == ".css" else outside_flight(text, html)
|
|
106
|
+
if new != text:
|
|
107
|
+
p.write_text(new, errors="surrogateescape")
|
|
108
|
+
changed += 1
|
|
109
|
+
return changed
|
perfectclone/flight.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Read and rewrite the React Server Components "flight" payload that Next.js inlines in HTML.
|
|
2
|
+
|
|
3
|
+
An App Router page ships its server-rendered HTML *and* a serialized copy of the same React tree
|
|
4
|
+
in `<script>self.__next_f.push([1,"..."])</script>` chunks. On load React hydrates the HTML
|
|
5
|
+
against that payload. Change the HTML without changing the payload (or the other way round) and
|
|
6
|
+
React throws a hydration error and re-renders from the payload, undoing the edit.
|
|
7
|
+
|
|
8
|
+
The payload is a stream of rows:
|
|
9
|
+
<id>:<json>\n most rows
|
|
10
|
+
<id>:T<hex byte length>,<text> long text, length-prefixed in UTF-8 bytes, no newline
|
|
11
|
+
Rows can span push() chunks, and a text row's length must be recomputed after any edit.
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
|
|
16
|
+
PUSH = re.compile(r"<script[^>]*>self\.__next_f\.push\((\[.*?\])\)</script>", re.S)
|
|
17
|
+
TEXT_ROW = re.compile(rb"([0-9a-f]*):T([0-9a-f]+),")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def has_flight(page):
|
|
21
|
+
return "self.__next_f.push(" in page
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def map_rows(stream, fn):
|
|
25
|
+
"""Apply fn(str) -> str to every row of a decoded flight stream."""
|
|
26
|
+
b, out, i = stream.encode(), [], 0
|
|
27
|
+
while i < len(b):
|
|
28
|
+
m = TEXT_ROW.match(b, i, min(len(b), i + 40))
|
|
29
|
+
if m:
|
|
30
|
+
start = m.end()
|
|
31
|
+
end = start + int(m.group(2), 16)
|
|
32
|
+
text = fn(b[start:end].decode()).encode()
|
|
33
|
+
out.append(m.group(1) + b":T" + format(len(text), "x").encode() + b"," + text)
|
|
34
|
+
i = end
|
|
35
|
+
else:
|
|
36
|
+
j = b.find(b"\n", i)
|
|
37
|
+
j = len(b) if j < 0 else j + 1
|
|
38
|
+
out.append(fn(b[i:j].decode()).encode())
|
|
39
|
+
i = j
|
|
40
|
+
return b"".join(out).decode()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def edit_flight(page, fn):
|
|
44
|
+
"""Apply fn row by row to a page's flight payload and write it back.
|
|
45
|
+
|
|
46
|
+
The decoded stream is written back as a single push() in place of the first data chunk, so a
|
|
47
|
+
row that used to straddle two chunks is handled like any other.
|
|
48
|
+
"""
|
|
49
|
+
chunks = []
|
|
50
|
+
for m in PUSH.finditer(page):
|
|
51
|
+
arr = json.loads(m.group(1))
|
|
52
|
+
if len(arr) > 1 and arr[0] == 1 and isinstance(arr[1], str):
|
|
53
|
+
chunks.append((m, arr[1]))
|
|
54
|
+
if not chunks:
|
|
55
|
+
return page
|
|
56
|
+
stream = "".join(s for _, s in chunks)
|
|
57
|
+
new = map_rows(stream, fn)
|
|
58
|
+
if new == stream:
|
|
59
|
+
return page
|
|
60
|
+
enc = json.dumps([1, new], ensure_ascii=False, separators=(",", ":"))
|
|
61
|
+
# Same escaping Next.js applies so the payload can't close the <script> early.
|
|
62
|
+
enc = enc.replace("<", "\\u003c").replace("\u2028", "\\u2028").replace("\u2029", "\\u2029")
|
|
63
|
+
first = chunks[0][0].group(0)
|
|
64
|
+
tag = first[: first.index(">") + 1] # keep any nonce attribute
|
|
65
|
+
out, last = [], 0
|
|
66
|
+
for k, (m, _) in enumerate(chunks):
|
|
67
|
+
out.append(page[last:m.start()])
|
|
68
|
+
if k == 0:
|
|
69
|
+
out.append(f"{tag}self.__next_f.push({enc})</script>")
|
|
70
|
+
last = m.end()
|
|
71
|
+
out.append(page[last:])
|
|
72
|
+
return "".join(out)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def outside_flight(page, fn):
|
|
76
|
+
"""Apply fn to the parts of the page that are not flight chunks."""
|
|
77
|
+
out, last = [], 0
|
|
78
|
+
for m in PUSH.finditer(page):
|
|
79
|
+
out.append(fn(page[last:m.start()]))
|
|
80
|
+
out.append(m.group(0))
|
|
81
|
+
last = m.end()
|
|
82
|
+
out.append(fn(page[last:]))
|
|
83
|
+
return "".join(out)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def edit_json_rows(page, fn):
|
|
87
|
+
"""Rewrite each JSON row's tree in a page's payload with fn(node) -> node (None drops an element)."""
|
|
88
|
+
return edit_flight(page, json_row_editor(fn))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def json_row_editor(fn):
|
|
92
|
+
def row(text):
|
|
93
|
+
m = re.match(r"([0-9a-f]+):(.*?)(\n?)$", text, re.S)
|
|
94
|
+
if not m or not m.group(2).startswith(("[", "{")):
|
|
95
|
+
return text
|
|
96
|
+
try:
|
|
97
|
+
tree = json.loads(m.group(2))
|
|
98
|
+
except ValueError:
|
|
99
|
+
return text
|
|
100
|
+
new = walk(tree, fn)
|
|
101
|
+
if new == tree:
|
|
102
|
+
return text
|
|
103
|
+
return f"{m.group(1)}:{json.dumps(new, ensure_ascii=False, separators=(',', ':'))}{m.group(3)}"
|
|
104
|
+
return row
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def walk(node, fn):
|
|
108
|
+
node = fn(node)
|
|
109
|
+
if isinstance(node, list):
|
|
110
|
+
return [walk(n, fn) for n in node]
|
|
111
|
+
if isinstance(node, dict):
|
|
112
|
+
return {k: walk(v, fn) for k, v in node.items()}
|
|
113
|
+
return node
|
perfectclone/mirror.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Download crawled pages and assets into a folder that serves as a working copy.
|
|
2
|
+
|
|
3
|
+
Pages are saved as the server's HTML, not the rendered DOM, because that is what the site's
|
|
4
|
+
JavaScript hydrates. Assets keep their URL paths so root-relative references resolve unchanged.
|
|
5
|
+
"""
|
|
6
|
+
import html
|
|
7
|
+
import re
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import parse_qs, unquote, urljoin, urlparse
|
|
10
|
+
|
|
11
|
+
from playwright.sync_api import sync_playwright
|
|
12
|
+
|
|
13
|
+
from .flight import has_flight
|
|
14
|
+
from .util import UA, host_of
|
|
15
|
+
|
|
16
|
+
REF = re.compile(
|
|
17
|
+
r"""(?:src|poster|data-src)=["']([^"']+)["']"""
|
|
18
|
+
r"""|url\(\s*["']?([^"')]+?)["']?\s*\)"""
|
|
19
|
+
r"""|(?:srcset|srcSet|imagesrcset|data-srcset)=["']([^"']+)["']"""
|
|
20
|
+
r"""|href=["']([^"']+)["']"""
|
|
21
|
+
)
|
|
22
|
+
# Pages link to each other with href too; those are left to the crawler so --max-pages and
|
|
23
|
+
# robots.txt still apply. From an href, only files that are clearly assets are mirrored.
|
|
24
|
+
ASSET_HREF = re.compile(
|
|
25
|
+
r"\.(css|js|mjs|ico|png|jpe?g|gif|svg|webp|avif|woff2?|ttf|otf|json|webmanifest|xml|pdf|mp4|webm)(\?|$)", re.I)
|
|
26
|
+
# Lazy chunks and other files loaded at runtime appear only as string literals inside scripts.
|
|
27
|
+
# Bundlers leave node_modules source paths in strings too; those aren't served.
|
|
28
|
+
CHUNK = re.compile(
|
|
29
|
+
r"""["'`](?:\./)?(/?(?!node_modules/)[\w-]+/[^"'`\s?]+?"""
|
|
30
|
+
r"""\.(?:js|mjs|css|woff2?|ttf|otf|png|jpe?g|webp|avif|svg|gif|mp4|webm|json|wasm|riv|bin|glb|gltf)"""
|
|
31
|
+
r"""(?:\?[^"'`\s]*)?)["'`]""")
|
|
32
|
+
TEXT_TYPES = (".html", ".css", ".js", ".mjs", ".svg", ".json", ".xml", ".txt", ".webmanifest")
|
|
33
|
+
# Files a site often has that no page references directly (service workers are registered
|
|
34
|
+
# from script, browsers request icons and manifests on their own).
|
|
35
|
+
EXTRAS = ("/sw.js", "/service-worker.js", "/manifest.json", "/site.webmanifest", "/favicon.ico",
|
|
36
|
+
"/apple-touch-icon.png", "/robots.txt")
|
|
37
|
+
# String literals that look like paths but are templates ("/static/${id}.js") or fragments.
|
|
38
|
+
BAD_URL = re.compile(r"[\s{}<>`$\\^|()*#]|%23")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def contained(site, out):
|
|
42
|
+
"""out, if it stays inside site once resolved; percent-encoded ../ must not escape."""
|
|
43
|
+
out = out.resolve()
|
|
44
|
+
return out if out.is_relative_to(site.resolve()) else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def page_path(site, url):
|
|
48
|
+
path = unquote(urlparse(url).path)
|
|
49
|
+
if Path(path).suffix in (".html", ".htm"):
|
|
50
|
+
return contained(site, site / path.lstrip("/"))
|
|
51
|
+
return contained(site, site / path.strip("/") / "index.html")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def asset_path(site, url):
|
|
55
|
+
path = unquote(urlparse(url).path)
|
|
56
|
+
if path.endswith("/"):
|
|
57
|
+
path += "index.html"
|
|
58
|
+
return contained(site, site / path.lstrip("/"))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def refs(text, base, host):
|
|
62
|
+
"""Same-site URLs referenced from HTML, CSS or JS."""
|
|
63
|
+
out = set()
|
|
64
|
+
for m in REF.finditer(text):
|
|
65
|
+
val = html.unescape(next(g for g in m.groups() if g)).strip().strip("\"'")
|
|
66
|
+
if val.startswith("data:"):
|
|
67
|
+
continue
|
|
68
|
+
if m.group(4) and not ASSET_HREF.search(urlparse(val).path + ("?" if urlparse(val).query else "")):
|
|
69
|
+
continue
|
|
70
|
+
# Only srcset is a comma list; a data: URL or query string may contain commas too.
|
|
71
|
+
for part in val.split(", ") if m.group(3) else [val]:
|
|
72
|
+
u = part.strip().split(" ")[0]
|
|
73
|
+
if u and not u.startswith(("data:", "#", "mailto:", "tel:", "javascript:", "blob:")):
|
|
74
|
+
out.add(urljoin(base, u))
|
|
75
|
+
for m in CHUNK.finditer(text):
|
|
76
|
+
out.add(urljoin(base, "/" + m.group(1).lstrip("/")))
|
|
77
|
+
# /_next/image responses are keyed by query string and often refused to direct requests, so
|
|
78
|
+
# mirror the original image instead; `serve` maps the optimizer URL back to it.
|
|
79
|
+
for u in [u for u in out if urlparse(u).path == "/_next/image"]:
|
|
80
|
+
out.discard(u)
|
|
81
|
+
src = parse_qs(urlparse(u).query).get("url", [""])[0]
|
|
82
|
+
if src.startswith("/"):
|
|
83
|
+
out.add(urljoin(base, src))
|
|
84
|
+
return {u for u in out if u.startswith("http") and host_of(u) == host and not BAD_URL.search(u)}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def mirror(pages, assets, out_dir, log=print):
|
|
88
|
+
"""Fetch pages and assets into out_dir. Returns (saved_pages, urls_tried, failures)."""
|
|
89
|
+
site = Path(out_dir)
|
|
90
|
+
site.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
host = host_of(pages[0]["url"])
|
|
92
|
+
page_paths = {urlparse(p["url"]).path.rstrip("/") or "/" for p in pages}
|
|
93
|
+
todo, done, failures = set(assets), set(), []
|
|
94
|
+
saved_pages = 0
|
|
95
|
+
|
|
96
|
+
with sync_playwright() as pw:
|
|
97
|
+
req = pw.request.new_context(user_agent=UA)
|
|
98
|
+
|
|
99
|
+
def fetch(url, headers=None, quiet=False):
|
|
100
|
+
try:
|
|
101
|
+
r = req.get(url, headers=headers, timeout=30000)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
if not quiet:
|
|
104
|
+
failures.append({"url": url, "error": str(e).splitlines()[0]})
|
|
105
|
+
return None
|
|
106
|
+
if not r.ok:
|
|
107
|
+
if not quiet:
|
|
108
|
+
failures.append({"url": url, "status": r.status})
|
|
109
|
+
return None
|
|
110
|
+
return r
|
|
111
|
+
|
|
112
|
+
for p in pages:
|
|
113
|
+
r = fetch(p["url"])
|
|
114
|
+
if r is None:
|
|
115
|
+
continue
|
|
116
|
+
body = r.text()
|
|
117
|
+
out = page_path(site, p["url"])
|
|
118
|
+
if out is None:
|
|
119
|
+
failures.append({"url": p["url"], "error": "path escapes the output folder"})
|
|
120
|
+
continue
|
|
121
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
out.write_text(body)
|
|
123
|
+
saved_pages += 1
|
|
124
|
+
todo |= refs(body, p["url"], host)
|
|
125
|
+
if has_flight(body) and out.name == "index.html":
|
|
126
|
+
# Next.js navigates between pages by fetching this payload (?_rsc=...) instead
|
|
127
|
+
# of HTML; without it every client-side link 404s and falls back to a reload.
|
|
128
|
+
rsc = fetch(p["url"], headers={"RSC": "1"}, quiet=True)
|
|
129
|
+
if rsc and "text/x-component" in rsc.headers.get("content-type", ""):
|
|
130
|
+
out.with_name("index.rsc").write_bytes(rsc.body())
|
|
131
|
+
|
|
132
|
+
for path in EXTRAS:
|
|
133
|
+
r = fetch(urljoin(pages[0]["url"], path), quiet=True)
|
|
134
|
+
out = asset_path(site, urljoin(pages[0]["url"], path))
|
|
135
|
+
if r and out and not out.exists() and "text/html" not in r.headers.get("content-type", ""):
|
|
136
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
out.write_bytes(r.body())
|
|
138
|
+
done.add(urljoin(pages[0]["url"], path))
|
|
139
|
+
|
|
140
|
+
while todo:
|
|
141
|
+
url = todo.pop()
|
|
142
|
+
parsed = urlparse(url)
|
|
143
|
+
key = parsed._replace(fragment="").geturl()
|
|
144
|
+
if key in done or (parsed.path.rstrip("/") or "/") in page_paths:
|
|
145
|
+
continue
|
|
146
|
+
done.add(key)
|
|
147
|
+
if "/cdn-cgi/" in parsed.path: # Cloudflare's own endpoints
|
|
148
|
+
continue
|
|
149
|
+
r = fetch(key)
|
|
150
|
+
if r is None:
|
|
151
|
+
continue
|
|
152
|
+
if "text/html" in r.headers.get("content-type", "") and not Path(parsed.path).suffix:
|
|
153
|
+
continue # a page the crawl didn't reach
|
|
154
|
+
out = asset_path(site, key)
|
|
155
|
+
if out is None:
|
|
156
|
+
failures.append({"url": key, "error": "path escapes the output folder"})
|
|
157
|
+
continue
|
|
158
|
+
if out.is_dir():
|
|
159
|
+
out = out / "index"
|
|
160
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
body = r.body()
|
|
162
|
+
out.write_bytes(body)
|
|
163
|
+
if out.suffix in TEXT_TYPES:
|
|
164
|
+
todo |= refs(body.decode("utf-8", "ignore"), key, host) - done
|
|
165
|
+
req.dispose()
|
|
166
|
+
|
|
167
|
+
log(f" mirrored {saved_pages} pages, {len(done) - len(failures)} assets")
|
|
168
|
+
return saved_pages, len(done), failures
|
perfectclone/serve.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Serve a clone locally the way a static host would."""
|
|
2
|
+
import functools
|
|
3
|
+
import http.server
|
|
4
|
+
import io
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
9
|
+
|
|
10
|
+
from .util import site_dir
|
|
11
|
+
|
|
12
|
+
RANGE = re.compile(r"bytes=(\d*)-(\d*)$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Handler(http.server.SimpleHTTPRequestHandler):
|
|
16
|
+
extensions_map = {**http.server.SimpleHTTPRequestHandler.extensions_map, ".rsc": "text/x-component"}
|
|
17
|
+
|
|
18
|
+
def end_headers(self):
|
|
19
|
+
self.send_header("Accept-Ranges", "bytes")
|
|
20
|
+
super().end_headers()
|
|
21
|
+
|
|
22
|
+
def send_head(self):
|
|
23
|
+
self.redirect_to = None
|
|
24
|
+
self.route()
|
|
25
|
+
if self.redirect_to:
|
|
26
|
+
self.send_response(302)
|
|
27
|
+
self.send_header("Location", self.redirect_to)
|
|
28
|
+
self.end_headers()
|
|
29
|
+
return None
|
|
30
|
+
if self.headers.get("Range"):
|
|
31
|
+
return self.send_range()
|
|
32
|
+
return super().send_head()
|
|
33
|
+
|
|
34
|
+
def send_range(self):
|
|
35
|
+
# Safari only plays <video> from servers that answer byte-range requests with 206.
|
|
36
|
+
path = self.translate_path(self.path)
|
|
37
|
+
m = RANGE.match(self.headers["Range"].strip())
|
|
38
|
+
if not m or not os.path.isfile(path) or m.group(1) == m.group(2) == "":
|
|
39
|
+
return super().send_head()
|
|
40
|
+
size = os.path.getsize(path)
|
|
41
|
+
if m.group(1):
|
|
42
|
+
start, end = int(m.group(1)), min(int(m.group(2) or size - 1), size - 1)
|
|
43
|
+
else:
|
|
44
|
+
start, end = max(size - int(m.group(2)), 0), size - 1
|
|
45
|
+
if start > end:
|
|
46
|
+
self.send_response(416)
|
|
47
|
+
self.send_header("Content-Range", f"bytes */{size}")
|
|
48
|
+
self.end_headers()
|
|
49
|
+
return None
|
|
50
|
+
with open(path, "rb") as f:
|
|
51
|
+
f.seek(start)
|
|
52
|
+
body = f.read(end - start + 1)
|
|
53
|
+
self.send_response(206)
|
|
54
|
+
self.send_header("Content-Type", self.guess_type(path))
|
|
55
|
+
self.send_header("Content-Range", f"bytes {start}-{end}/{size}")
|
|
56
|
+
self.send_header("Content-Length", str(len(body)))
|
|
57
|
+
self.end_headers()
|
|
58
|
+
return io.BytesIO(body)
|
|
59
|
+
|
|
60
|
+
def route(self):
|
|
61
|
+
"""Map the request onto a file the way a static host would."""
|
|
62
|
+
url = urlparse(self.path)
|
|
63
|
+
if "_rsc" in parse_qs(url.query) or self.headers.get("RSC") == "1":
|
|
64
|
+
rsc = Path(self.directory) / unquote(url.path).strip("/") / "index.rsc"
|
|
65
|
+
if rsc.is_file():
|
|
66
|
+
self.path = url.path.rstrip("/") + "/index.rsc"
|
|
67
|
+
return
|
|
68
|
+
if url.path == "/_next/image":
|
|
69
|
+
# The mirror keeps original images, not optimizer output. Remote originals (a CMS
|
|
70
|
+
# CDN) were never on the site, so the browser is sent to them.
|
|
71
|
+
src = parse_qs(url.query).get("url", [""])[0]
|
|
72
|
+
if src.startswith(("http://", "https://")):
|
|
73
|
+
self.redirect_to = src
|
|
74
|
+
elif src.startswith("/"):
|
|
75
|
+
self.path = src
|
|
76
|
+
elif not Path(url.path).suffix:
|
|
77
|
+
local = Path(self.directory) / unquote(url.path).lstrip("/")
|
|
78
|
+
if (local / "index.html").is_file():
|
|
79
|
+
self.path = url.path.rstrip("/") + "/index.html"
|
|
80
|
+
|
|
81
|
+
def log_message(self, fmt, *args):
|
|
82
|
+
if args and str(args[1]).startswith(("4", "5")):
|
|
83
|
+
super().log_message(fmt, *args)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def serve(path, port=8000):
|
|
87
|
+
site = site_dir(path)
|
|
88
|
+
handler = functools.partial(Handler, directory=str(site.resolve()))
|
|
89
|
+
for attempt in range(port, port + 20):
|
|
90
|
+
try:
|
|
91
|
+
httpd = http.server.ThreadingHTTPServer(("127.0.0.1", attempt), handler)
|
|
92
|
+
break
|
|
93
|
+
except OSError:
|
|
94
|
+
continue
|
|
95
|
+
else:
|
|
96
|
+
raise SystemExit(f"No free port between {port} and {port + 19}.")
|
|
97
|
+
with httpd:
|
|
98
|
+
print(f"Serving {site} at http://localhost:{attempt} (Ctrl+C to stop)")
|
|
99
|
+
try:
|
|
100
|
+
httpd.serve_forever()
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
pass
|
perfectclone/trackers.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Remove the original owner's analytics and ad pixels, which would otherwise report a clone's
|
|
2
|
+
visitors to their accounts. Next.js next/script trackers exist only in the flight payload.
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from .flight import edit_json_rows, has_flight, json_row_editor, map_rows, outside_flight
|
|
7
|
+
from .util import text_files
|
|
8
|
+
|
|
9
|
+
SIGNATURES = re.compile(
|
|
10
|
+
r"googletagmanager\.com|google-analytics\.com|\bgtag\(|\bga\(['\"]|doubleclick\.net"
|
|
11
|
+
r"|connect\.facebook\.net|facebook\.com/tr|\bfbq\("
|
|
12
|
+
r"|_linkedin_partner_id|snap\.licdn\.com|px\.ads\.linkedin\.com"
|
|
13
|
+
r"|static\.hotjar\.com|\bhj\(|clarity\.ms"
|
|
14
|
+
r"|cdn\.segment\.com|analytics\.load\("
|
|
15
|
+
r"|reb2b|ddwl4m2hdecbv\.cloudfront\.net"
|
|
16
|
+
r"|posthog|plausible\.io|cdn\.mxpnl\.com|mixpanel"
|
|
17
|
+
r"|js\.hs-scripts\.com|js\.hs-analytics\.net|hs-banner"
|
|
18
|
+
r"|analytics\.tiktok\.com|\bttq\.|bat\.bing\.com|\buetq\b"
|
|
19
|
+
r"|static\.ads-twitter\.com|\btwq\(|redditstatic\.com/ads|\brdt\("
|
|
20
|
+
r"|static\.cloudflareinsights\.com|data-cf-beacon"
|
|
21
|
+
r"|cdn\.amplitude\.com|heapanalytics|fullstory\.com|\bFS\.identify|cdn\.lr-ingest|logrocket",
|
|
22
|
+
re.I,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Script loaders that bundles inject at runtime (e.g. a GoogleTagManager React component).
|
|
26
|
+
# Pointing them at a data: URL holding a JS comment lets the injecting code run untouched
|
|
27
|
+
# while nothing loads. Beacon/collect endpoints are left alone: rewriting those can throw.
|
|
28
|
+
LOADER = re.compile(
|
|
29
|
+
r"(?:https?:)?//(?:www\.)?(?:googletagmanager\.com/(?:gtm|gtag/js)|connect\.facebook\.net/[\w_]+/fbevents\.js"
|
|
30
|
+
r"|snap\.licdn\.com/li\.lms-analytics/insight\.min\.js|static\.hotjar\.com/c/hotjar-|www\.clarity\.ms/tag/"
|
|
31
|
+
r"|cdn\.segment\.com/analytics\.js|js\.hs-scripts\.com/|static\.cloudflareinsights\.com/beacon"
|
|
32
|
+
r"|ddwl4m2hdecbv\.cloudfront\.net/b/|plausible\.io/js/|cdn\.amplitude\.com/|cdn\.mxpnl\.com/"
|
|
33
|
+
r"|analytics\.tiktok\.com/i18n/pixel|static\.ads-twitter\.com/uwt\.js|bat\.bing\.com/bat\.js)"
|
|
34
|
+
)
|
|
35
|
+
SCRIPT = re.compile(r"<script\b[^>]*>.*?</script>", re.S | re.I)
|
|
36
|
+
NOSCRIPT = re.compile(r"<noscript\b[^>]*>.*?</noscript>", re.S | re.I)
|
|
37
|
+
HINT = re.compile(r"<link\b[^>]*rel=[\"']?(?:preload|preconnect|dns-prefetch|prefetch)[^>]*>", re.I)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _strip_html(text):
|
|
41
|
+
def drop(m):
|
|
42
|
+
tag = m.group(0)
|
|
43
|
+
if "__next_f" in tag or "application/ld+json" in tag:
|
|
44
|
+
return tag
|
|
45
|
+
return "" if SIGNATURES.search(tag) else tag
|
|
46
|
+
|
|
47
|
+
def empty(m):
|
|
48
|
+
# The element may be part of a React tree, so removing it breaks hydration. React never
|
|
49
|
+
# hydrates noscript children, so emptying it is safe and stops the no-JS pixel.
|
|
50
|
+
tag = m.group(0)
|
|
51
|
+
return tag[: tag.index(">") + 1] + "</noscript>" if SIGNATURES.search(tag) else tag
|
|
52
|
+
|
|
53
|
+
text = SCRIPT.sub(drop, text)
|
|
54
|
+
text = NOSCRIPT.sub(empty, text)
|
|
55
|
+
return HINT.sub(drop, text)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _is_tracker_element(node):
|
|
59
|
+
"""A React element ["$", type, key, props] for a script whose src/id/body is a tracker."""
|
|
60
|
+
if not (isinstance(node, list) and len(node) == 4 and node[0] == "$" and isinstance(node[3], dict)):
|
|
61
|
+
return False
|
|
62
|
+
props = node[3]
|
|
63
|
+
is_script = node[1] == "script" or "strategy" in props # next/script carries strategy
|
|
64
|
+
if not is_script:
|
|
65
|
+
return False
|
|
66
|
+
body = " ".join(str(props.get(k, "")) for k in ("src", "id", "children"))
|
|
67
|
+
body += str((props.get("dangerouslySetInnerHTML") or {}).get("__html", ""))
|
|
68
|
+
return bool(SIGNATURES.search(body))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _drop(node):
|
|
72
|
+
return None if _is_tracker_element(node) else node
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def strip(site, log=print):
|
|
76
|
+
changed = 0
|
|
77
|
+
for p in text_files(site):
|
|
78
|
+
text = p.read_text(errors="surrogateescape")
|
|
79
|
+
suffix = p.suffix.lower()
|
|
80
|
+
if suffix in (".js", ".mjs"):
|
|
81
|
+
new = LOADER.sub(lambda m: "data:text/javascript,//" + m.group(0).split("//", 1)[1], text)
|
|
82
|
+
elif suffix == ".rsc":
|
|
83
|
+
new = map_rows(text, json_row_editor(_drop))
|
|
84
|
+
elif suffix in (".html", ".htm"):
|
|
85
|
+
new = outside_flight(edit_json_rows(text, _drop), _strip_html) if has_flight(text) else _strip_html(text)
|
|
86
|
+
changed += new != text
|
|
87
|
+
else:
|
|
88
|
+
continue
|
|
89
|
+
if new != text:
|
|
90
|
+
p.write_text(new, errors="surrogateescape")
|
|
91
|
+
if changed:
|
|
92
|
+
log(f" removed trackers from {changed} pages")
|
|
93
|
+
return changed
|
perfectclone/util.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Helpers shared by the crawler, mirror, editors and server."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from urllib.parse import urlparse
|
|
4
|
+
|
|
5
|
+
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
6
|
+
"Chrome/130.0 Safari/537.36 perfectclone")
|
|
7
|
+
TEXT_TYPES = {".html", ".htm", ".js", ".mjs", ".css", ".json", ".xml", ".txt", ".svg", ".webmanifest", ".rsc"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def host_of(url):
|
|
11
|
+
return urlparse(url).netloc.lower().removeprefix("www.")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def text_files(site):
|
|
15
|
+
for p in Path(site).rglob("*"):
|
|
16
|
+
if p.is_file() and p.suffix.lower() in TEXT_TYPES:
|
|
17
|
+
yield p
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def site_dir(path):
|
|
21
|
+
"""Accept a clone folder (with site/ inside) or the site folder itself."""
|
|
22
|
+
p = Path(path)
|
|
23
|
+
if not (p / "index.html").exists() and (p / "site" / "index.html").exists():
|
|
24
|
+
p = p / "site"
|
|
25
|
+
return p
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: perfectclone
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Clone any website 1:1 with a real browser, then edit it safely.
|
|
5
|
+
Author: Eshaan Gulati
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/eshaangulati123/perfectclone-website-cloner
|
|
8
|
+
Project-URL: Issues, https://github.com/eshaangulati123/perfectclone-website-cloner/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/eshaangulati123/perfectclone-website-cloner/blob/main/CHANGELOG.md
|
|
10
|
+
Keywords: website cloner,clone website,website copier,website mirror,playwright,nextjs,httrack alternative,web scraping
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Site Management
|
|
18
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: playwright>=1.40
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest; extra == "dev"
|
|
25
|
+
Requires-Dist: pillow; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# perfectclone: website cloner that copies the real site, 1:1
|
|
30
|
+
|
|
31
|
+
**Pull the real frontend of any public site in ~15 seconds. No AI, no tokens.**
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
perfectclone clones any website 1:1. It crawls the site with a real browser (Playwright),
|
|
36
|
+
downloads the site's own HTML, JavaScript, CSS, fonts and images, and gives you a folder you can
|
|
37
|
+
serve, edit and deploy. Then it lets you rebrand it, rehost it and strip the original owner's
|
|
38
|
+
trackers without breaking it, including Next.js sites that hydrate on load.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install perfectclone
|
|
42
|
+
|
|
43
|
+
perfectclone clone example.com
|
|
44
|
+
perfectclone serve example.com # → http://localhost:8000
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That's it. Chromium is downloaded automatically the first time you clone. Requires Python 3.10+.
|
|
48
|
+
|
|
49
|
+
Or try it without installing anything, using [uv](https://docs.astral.sh/uv/):
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
uvx perfectclone clone example.com
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Why not an AI cloner?
|
|
56
|
+
|
|
57
|
+
Most "clone any website" tools today regenerate the site: an AI agent looks at screenshots or
|
|
58
|
+
scraped markup, writes a new React/Next.js app, and compares the result against the original
|
|
59
|
+
with a visual-similarity score until it's close enough. That gets you a lookalike. The code is
|
|
60
|
+
new, the behaviour is approximate, and there are always gaps.
|
|
61
|
+
|
|
62
|
+
perfectclone doesn't regenerate anything. It mirrors the bytes the site actually serves, so
|
|
63
|
+
the clone *is* the site: same markup, same bundles, same animations, same JavaScript running.
|
|
64
|
+
Sometimes the real solution is the simple one.
|
|
65
|
+
|
|
66
|
+
| | AI cloners | wget / HTTrack | perfectclone |
|
|
67
|
+
| --------------------------------------- | :--------: | :------------: | :----------: |
|
|
68
|
+
| Copies the site's real files | ❌ | ✅ | ✅ |
|
|
69
|
+
| Pixel-identical on modern JS sites | ❌ | ❌ | ✅ |
|
|
70
|
+
| Catches lazy-loaded JS, fonts, images | — | ❌ | ✅ |
|
|
71
|
+
| React / Next.js sites still run | — | ❌ | ✅ |
|
|
72
|
+
| Rebrand without breaking the site | ✅ | ⚠️ | ✅ |
|
|
73
|
+
| Strips the original site's trackers | — | ❌ | ✅ |
|
|
74
|
+
| Works without an LLM or API key | ❌ | ✅ | ✅ |
|
|
75
|
+
|
|
76
|
+
## Tested on real sites
|
|
77
|
+
|
|
78
|
+
Each card is one site split down the middle: the live site on the left, the clone on the right.
|
|
79
|
+
|
|
80
|
+

|
|
81
|
+
|
|
82
|
+
`scripts/compat.py` clones each site, serves the copy, and compares its home page with the live
|
|
83
|
+
one at 1440×900, with animations and videos paused on the same frame:
|
|
84
|
+
|
|
85
|
+
| Site | Built with | Pixel match | Hydration errors | Notes |
|
|
86
|
+
| --- | --- | --- | --- | --- |
|
|
87
|
+
| huly.io | Next.js | 100% | 0 | |
|
|
88
|
+
| nextjs.org | Next.js | 100% | 0 | |
|
|
89
|
+
| astro.build | Astro | 100% | 0 | |
|
|
90
|
+
| svelte.dev | SvelteKit | 100% | 0 | |
|
|
91
|
+
| vitepress.dev | VitePress (Vue) | 100% | 0 | |
|
|
92
|
+
| docusaurus.io | Docusaurus (React) | 100% | 0 | |
|
|
93
|
+
| wordpress.org/news | WordPress | 100% | 0 | |
|
|
94
|
+
| paulgraham.com | Hand-written HTML | 100% | 0 | |
|
|
95
|
+
| example.com | Static HTML | 100% | 0 | |
|
|
96
|
+
| framer.com | Framer | 96% | 0 | Cookie banner needs Framer's backend |
|
|
97
|
+
| webflow.com | Webflow | 93% | 0 | Cookie banner won't run on localhost; carousel caught mid-scroll |
|
|
98
|
+
|
|
99
|
+
Every clone loaded with zero requests to trackers. Run it yourself with
|
|
100
|
+
`python scripts/compat.py`, and please open an issue for any site that doesn't come out right.
|
|
101
|
+
|
|
102
|
+
## Commands
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
perfectclone clone <url> [-o out] [--max-pages 200] [--extract] [--keep-trackers] [--ignore-robots]
|
|
106
|
+
perfectclone serve <site> [-p 8000]
|
|
107
|
+
perfectclone replace <site> <old> <new> [--exact-case] [--substring]
|
|
108
|
+
perfectclone rehost <site> <old-origin> <new-origin>
|
|
109
|
+
perfectclone strip-trackers <site>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
- **clone** writes `out/site/` (the working copy) and `out/manifest.json` (pages, failures,
|
|
113
|
+
third-party hosts the site uses). `--extract` also saves each page's text, metadata
|
|
114
|
+
(headings, links, images, forms, JSON-LD) and a full-page screenshot to `out/extract/`.
|
|
115
|
+
Trackers are stripped by default.
|
|
116
|
+
- **replace** renames text everywhere: HTML, JS, CSS, JSON, SVG. It also handles the UPPER,
|
|
117
|
+
lower and Capitalized variants, and leaves URLs and asset paths alone so nothing 404s.
|
|
118
|
+
- **rehost** points canonical, Open Graph, JSON-LD and sitemap URLs at your domain.
|
|
119
|
+
- **serve** works like a static host: clean URLs (`/about` → `/about/index.html`), and
|
|
120
|
+
`/_next/image` requests are served from the mirrored originals.
|
|
121
|
+
|
|
122
|
+
Rebrand a clone and put it online:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
perfectclone clone acme.com -o acme
|
|
126
|
+
perfectclone replace acme "Acme" "Globex"
|
|
127
|
+
perfectclone rehost acme https://acme.com https://globex.com
|
|
128
|
+
npx netlify-cli deploy --dir acme/site --prod # or any static host
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## How it works
|
|
132
|
+
|
|
133
|
+
1. **Crawl with a real browser.** Each page is rendered in Chromium and scrolled to the bottom,
|
|
134
|
+
and every response is recorded. That catches what static mirrors miss: code-split JS chunks,
|
|
135
|
+
fonts requested from CSS, images behind lazy loading. Links are followed breadth-first,
|
|
136
|
+
seeded from the sitemap. robots.txt is respected unless you pass `--ignore-robots`.
|
|
137
|
+
2. **Mirror the server's bytes.** Pages are saved as the HTML the *server* sends, not the
|
|
138
|
+
rendered DOM, because that's what the site's JavaScript expects to hydrate. Assets keep
|
|
139
|
+
their URL paths, and CSS/JS are scanned for further references (`url()`, `srcset`, chunk
|
|
140
|
+
paths) until nothing new turns up.
|
|
141
|
+
3. **Edit without breaking hydration.** A Next.js App Router page ships its HTML *and* a
|
|
142
|
+
serialized copy of the React tree in `self.__next_f.push(...)` script chunks. Change one and
|
|
143
|
+
not the other, and React throws a hydration error and re-renders from the payload, undoing
|
|
144
|
+
your edit. perfectclone decodes the payload stream, edits it row by row, recomputes the
|
|
145
|
+
UTF-8 byte lengths of length-prefixed text rows (`<id>:T<hex>,…`), rejoins rows that span
|
|
146
|
+
chunks, and writes it back next to the matching HTML edit.
|
|
147
|
+
4. **Strip trackers.** Google Analytics/Tag Manager, the Meta pixel, LinkedIn Insight, Hotjar,
|
|
148
|
+
Clarity, Segment, PostHog, HubSpot, TikTok, Reddit, X, Cloudflare Web Analytics, RB2B and
|
|
149
|
+
more. They're removed from `<script>`/`<noscript>` tags, from preload hints, and from Next.js
|
|
150
|
+
`next/script` elements that only exist in the payload.
|
|
151
|
+
|
|
152
|
+
## Limits
|
|
153
|
+
|
|
154
|
+
- **No backend.** Forms, logins, search and API calls still go to the original servers (or
|
|
155
|
+
fail). perfectclone copies the front end.
|
|
156
|
+
- **Next.js client-side navigation** needs each page's RSC payload answered for `?_rsc=`
|
|
157
|
+
requests. perfectclone mirrors them and `perfectclone serve` routes them. On a plain static
|
|
158
|
+
host, internal links fall back to full page loads: everything still works, just without the
|
|
159
|
+
instant transitions.
|
|
160
|
+
- **Chunks that are only requested after a click** (say, a modal's code) are captured if the
|
|
161
|
+
crawl triggered them or a bundle names them as a string. A bundle can also assemble a chunk
|
|
162
|
+
URL at runtime, and that one may be missed.
|
|
163
|
+
- **Bot protection** (CAPTCHAs, aggressive Cloudflare rules) can block the crawl. perfectclone
|
|
164
|
+
does not try to get around it.
|
|
165
|
+
- Embeds (YouTube, Cal.com, maps) and third-party CDNs keep loading from their own hosts.
|
|
166
|
+
|
|
167
|
+
## Roadmap
|
|
168
|
+
|
|
169
|
+
**Clone apps behind a login, like dashboards.** Today perfectclone sees what an anonymous visitor
|
|
170
|
+
sees, and data that loads from APIs after the page renders isn't kept. The plan, for apps you
|
|
171
|
+
have an account on:
|
|
172
|
+
|
|
173
|
+
- **Crawl as you.** Sign in once in a browser window; perfectclone saves the session and
|
|
174
|
+
crawls with it.
|
|
175
|
+
- **Record API responses.** Keep every JSON response the app fetches during the crawl and have
|
|
176
|
+
`perfectclone serve` replay them, so charts, tables and lists show real data offline.
|
|
177
|
+
- **Explore by clicking.** Click through navigation, tabs and menus to reach views that only
|
|
178
|
+
exist in client-side routing.
|
|
179
|
+
|
|
180
|
+
The result is a clickable snapshot of the UI and its data at capture time. Saving, server-side
|
|
181
|
+
filtering and live updates still need the real backend.
|
|
182
|
+
|
|
183
|
+
Other ideas: hydration-aware editing for Nuxt, SvelteKit and Remix payloads, and a deploy
|
|
184
|
+
helper that writes `_redirects` / `vercel.json` rules so `?_rsc=` and `/_next/image` work on
|
|
185
|
+
static hosts. Contributions welcome.
|
|
186
|
+
|
|
187
|
+
## Contributing
|
|
188
|
+
|
|
189
|
+
perfectclone is open source under the [MIT License](https://github.com/eshaangulati123/perfectclone-website-cloner/blob/main/LICENSE). Issues and pull requests are
|
|
190
|
+
welcome, especially reports of sites that don't clone cleanly. See
|
|
191
|
+
[CONTRIBUTING.md](https://github.com/eshaangulati123/perfectclone-website-cloner/blob/main/CONTRIBUTING.md), and please follow the [Code of Conduct](https://github.com/eshaangulati123/perfectclone-website-cloner/blob/main/CODE_OF_CONDUCT.md).
|
|
192
|
+
Report security issues privately as described in [SECURITY.md](https://github.com/eshaangulati123/perfectclone-website-cloner/blob/main/SECURITY.md).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
perfectclone/__init__.py,sha256=OzRJ-OIbvZh0GkFQNI-zusuLMG2mQf6pBE4rog5s7VE,92
|
|
2
|
+
perfectclone/__main__.py,sha256=bYt9eEaoRQWdejEHFD8REx9jxVEdZptECFsV7F49Ink,30
|
|
3
|
+
perfectclone/cli.py,sha256=OpeqoM0LiKTggYyDICeAsXvf6RaIrSJ_WUGwnw5UkHc,4076
|
|
4
|
+
perfectclone/crawl.py,sha256=npHfsfXe70W-dBp1_I5ZTyZcNPonfHHRtPSjMnHPkgs,6805
|
|
5
|
+
perfectclone/edit.py,sha256=RGaJhjn3G-lS019XEk3YaHNot5AA5CJA6o2O4EU6VNg,4371
|
|
6
|
+
perfectclone/flight.py,sha256=qkSWXroSA3DYg9Jcssb4VLf1MkEMmF-28pnKfShP_JQ,4090
|
|
7
|
+
perfectclone/mirror.py,sha256=UYysFF989L-8ZGfbuAhCeXcDGe4iMSMbADJa8jNNe1A,7597
|
|
8
|
+
perfectclone/serve.py,sha256=jVd7x-bLA7XWi19pb8-41Gv0UIKPMVXMuHI1hHDYd2M,3858
|
|
9
|
+
perfectclone/trackers.py,sha256=69e5sekSoqPCynq9pWcF1MzV_tGdrtJOCn4nFeCkSyA,4281
|
|
10
|
+
perfectclone/util.py,sha256=fL_ScJrVAyPVzisGgNycUnxQWszd_vY8lk5ynWuv6OA,838
|
|
11
|
+
perfectclone-0.1.0.dist-info/licenses/LICENSE,sha256=j-vFyE02_F_CtlA090nue9I-GFoKFZwRDwOkacWHqSw,1070
|
|
12
|
+
perfectclone-0.1.0.dist-info/METADATA,sha256=m0lurcj9VFN-55UvG0ec9VTQyqFz7pGQPrsO-ip5JMg,10587
|
|
13
|
+
perfectclone-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
14
|
+
perfectclone-0.1.0.dist-info/entry_points.txt,sha256=rmTjd89EiWV2naTFIq54mx27rgSrILo_dZCckMtHung,55
|
|
15
|
+
perfectclone-0.1.0.dist-info/top_level.txt,sha256=Wt4oOBaA8l6KXOom0LmjA9dVJB2mSbHyw061Q-FaoCA,13
|
|
16
|
+
perfectclone-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eshaan Gulati
|
|
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 @@
|
|
|
1
|
+
perfectclone
|