@elitedcs/ghl-mcp 3.56.0 → 3.57.0
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.
- package/CHANGELOG.md +98 -0
- package/README.md +1 -1
- package/dist/index.js +2 -2
- package/package.json +1 -1
- package/skills/clone-site/README.md +42 -0
- package/skills/clone-site/SKILL.md +247 -0
- package/skills/clone-site/references/facts-and-substitution.md +96 -0
- package/skills/clone-site/references/hosting-verify-and-audit.md +101 -0
- package/skills/clone-site/references/rights-and-lanes.md +72 -0
- package/skills/clone-site/scripts/audit.mjs +364 -0
- package/skills/clone-site/scripts/extract-design.mjs +372 -0
- package/skills/clone-site/scripts/lib.mjs +262 -0
- package/skills/clone-site/scripts/mirror.mjs +489 -0
- package/skills/clone-site/scripts/repair.mjs +113 -0
- package/skills/clone-site/scripts/substitute.mjs +418 -0
- package/skills/clone-site/scripts/verify.mjs +193 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Rights, the two lanes, and how to talk about them
|
|
2
|
+
|
|
3
|
+
## Why this is step 0 and not a footnote
|
|
4
|
+
|
|
5
|
+
Two things are true at once:
|
|
6
|
+
|
|
7
|
+
1. The tool should do what the user asks. A user who owns a page, or whose client owns it, has a completely ordinary job — migrating a site — and refusing it produces a worse result and a worse experience. When that refusal happened in the field, the fallback rebuild came back "not even close."
|
|
8
|
+
2. Copying a page you have no rights to is a real exposure — the layout and copy, other people's testimonials and photographs, trademarks, and advertising claims that were substantiated by a different business.
|
|
9
|
+
|
|
10
|
+
Asking once, at the start, resolves both. The answer is recorded, the user owns their declaration, and the flow adapts. What you must not do is stay silent and let the request fail later, or ask and then keep re-litigating it.
|
|
11
|
+
|
|
12
|
+
## The question (ask it in these words)
|
|
13
|
+
|
|
14
|
+
> **Before I copy this page — who owns it, or what permission do you have to copy it?**
|
|
15
|
+
> 1. **I own it**
|
|
16
|
+
> 2. **My client owns it and authorized the migration**
|
|
17
|
+
> 3. **I have written permission from the owner**
|
|
18
|
+
> 4. **None of these** — I found the page and want something like it
|
|
19
|
+
>
|
|
20
|
+
> I'm not checking this, and I'm not going to argue with your answer — it goes in the run report so it's on the record, and it decides which of two ways I build this.
|
|
21
|
+
|
|
22
|
+
Ask it once. Do not ask for evidence. Do not ask again later in the run.
|
|
23
|
+
|
|
24
|
+
## Recording
|
|
25
|
+
|
|
26
|
+
The answer becomes `--rights <own|client-authorized|written-permission|none>` on `mirror.mjs`, with the user's own words in `--declared`. It is written into `reports/run-report.json` and reprinted in the header of `REVIEW-REQUIRED.md`:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
| Rights declared | **client-authorized** — "Client owns northgate-wellness.com, moving to a new host" |
|
|
30
|
+
| Declaration recorded | 2026-07-30T17:18:49.094Z (user's statement; this tool does not verify ownership) |
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`mirror.mjs` exits 2 if `--rights` is missing and exits 3 on `none`. The gate is structural: it cannot be skipped because you were in a hurry.
|
|
34
|
+
|
|
35
|
+
## Lane routing
|
|
36
|
+
|
|
37
|
+
| Answer | Lane | What gets copied |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| `own` | Full clone | Everything — bytes, media, CSS, JS |
|
|
40
|
+
| `client-authorized` | Full clone | Everything |
|
|
41
|
+
| `written-permission` | Full clone | Everything |
|
|
42
|
+
| `none` | **Style clone** | Design tokens and structure only. No bytes, images, video or copy. |
|
|
43
|
+
|
|
44
|
+
## When the user has no rights but wants the full copy anyway
|
|
45
|
+
|
|
46
|
+
Say it once, plainly, then move:
|
|
47
|
+
|
|
48
|
+
> I can't do a byte-for-byte copy of a page you don't have rights to — that's their layout, their copy, their customers' testimonials and photos, and any claims on the page were substantiated by their business, not yours.
|
|
49
|
+
>
|
|
50
|
+
> Here's the thing though: what I *can* do is usually closer to what you want. I'll pull the page's actual design system — the exact palette, type scale, spacing rhythm, radii and section structure — and rebuild on that with your copy and your images. That's not me redrawing it from memory, which is what makes those rebuilds look cheap. It's the real design with your business in it.
|
|
51
|
+
|
|
52
|
+
Then run the style lane. Do not moralize, do not repeat it, do not add disclaimers to every later message.
|
|
53
|
+
|
|
54
|
+
If they come back with "actually, my client owns it" — take the new answer, record it (both answers, in order, in the `--declared` note), and proceed on the full-clone lane. It is their declaration to make.
|
|
55
|
+
|
|
56
|
+
## What the tool is and is not
|
|
57
|
+
|
|
58
|
+
**Is:** a copy engine with a recorded declaration and an over-inclusive liability report.
|
|
59
|
+
|
|
60
|
+
**Is not:** a rights verifier, a lawyer, or a filter on what the user may publish. `REVIEW-REQUIRED.md` says this in its own footer, and you should say the same if asked.
|
|
61
|
+
|
|
62
|
+
## Style-lane quality bar
|
|
63
|
+
|
|
64
|
+
The style lane is a first-class mode, not a consolation prize. A style-lane rebuild is done when:
|
|
65
|
+
|
|
66
|
+
- the palette, font stack, type scale, spacing values and radii come from `design-tokens.json`, not from taste
|
|
67
|
+
- the section order and density match the table in `DESIGN-SYSTEM.md`
|
|
68
|
+
- every line of copy is original
|
|
69
|
+
- every image is the client's own or licensed, with the source recorded
|
|
70
|
+
- `audit.mjs` has been run on the finished page and shown to the user
|
|
71
|
+
|
|
72
|
+
If the source is a client-rendered SPA, `extract-design.mjs` reports `spaShell: true` and the structure table will be empty. Open the page in a browser and record the section order by eye before rebuilding — do not guess.
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* audit.mjs — the mandatory pre-launch REVIEW REQUIRED report.
|
|
4
|
+
*
|
|
5
|
+
* Runs on every clone, clean or not. Nothing here is auto-fixed: the whole
|
|
6
|
+
* point is to surface what belongs to the ORIGINAL business so a human decides.
|
|
7
|
+
* Three review classes plus the exclusions:
|
|
8
|
+
*
|
|
9
|
+
* 1. MUST REPOINT — destinations that route leads, bookings or money
|
|
10
|
+
* 2. REVIEW: other people's content — names, addresses, testimonials, likenesses
|
|
11
|
+
* 3. REVIEW: inherited + regulated claims — FDA/medical, ratings, counts, awards, pricing
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node audit.mjs --dir <clone-dir> [--facts facts.json] [--out REVIEW-REQUIRED.md]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
import { parseArgs, readJson, walk, isTextFile, ext, humanBytes, nowIso, resolveRun } from "./lib.mjs";
|
|
20
|
+
|
|
21
|
+
const args = parseArgs(process.argv.slice(2));
|
|
22
|
+
if (!args.dir) {
|
|
23
|
+
console.error("usage: audit.mjs --dir <clone-dir> [--facts facts.json] [--out REVIEW-REQUIRED.md]");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const RUN = resolveRun(String(args.dir));
|
|
27
|
+
const DIR = RUN.site;
|
|
28
|
+
const REPORTS = RUN.reports;
|
|
29
|
+
fs.mkdirSync(REPORTS, { recursive: true });
|
|
30
|
+
const OUT = path.resolve(String(args.out && args.out !== true ? args.out : path.join(REPORTS, "REVIEW-REQUIRED.md")));
|
|
31
|
+
const facts = args.facts && args.facts !== true ? readJson(path.resolve(String(args.facts))) : {};
|
|
32
|
+
|
|
33
|
+
const RULES = [
|
|
34
|
+
// ---- 1. MUST REPOINT ----------------------------------------------------
|
|
35
|
+
{ s: "repoint", id: "form-action", label: "Form action", re: /<form[^>]*\saction=["']([^"']+)["']/gi, why: "Submissions go wherever this points — the original owner's CRM until you change it." },
|
|
36
|
+
{ s: "repoint", id: "webhook", label: "Webhook / API endpoint", re: /https?:\/\/[^\s"'`)]*(?:webhook|hooks?\.|leadconnector|msgsndr|zapier|make\.com|n8n|formspree|netlify\.app\/\.netlify\/functions)[^\s"'`)]*/gi, why: "A live lead destination belonging to someone else." },
|
|
37
|
+
{ s: "repoint", id: "api-path", label: "Same-site API path", re: /["'`](\/api\/[A-Za-z0-9._/-]+)["'`]/g, why: "The clone's form posts here; the backing function must exist on YOUR host or submissions vanish." },
|
|
38
|
+
{ s: "repoint", id: "stripe-key", label: "Stripe publishable key", re: /pk_(?:live|test)_[A-Za-z0-9]{10,}/g, why: "Charges would land in the original owner's Stripe account. Never ship this." },
|
|
39
|
+
{ s: "repoint", id: "payment-link", label: "Payment / checkout link", re: /https?:\/\/(?:buy\.stripe\.com|checkout\.stripe\.com|book\.stripe\.com|www\.paypal\.com\/[^\s"'`)]*|squareup\.com\/[^\s"'`)]*)[^\s"'`)]*/gi, why: "Money flows to whoever owns this link." },
|
|
40
|
+
{ s: "repoint", id: "booking", label: "Booking / calendar embed", re: /https?:\/\/[^\s"'`)]*(?:calendly\.com|acuityscheduling\.com|api\.leadconnectorhq\.com\/widget\/(?:booking|appointment)|widget\.leadconnectorhq|cal\.com)[^\s"'`)]*/gi, why: "Appointments book onto the original owner's calendar." },
|
|
41
|
+
{ s: "repoint", id: "ga", label: "Google Analytics / GTM ID", re: /\b(?:G-[A-Z0-9]{6,12}|UA-\d{4,}-\d+|GTM-[A-Z0-9]{4,})\b/g, why: "Your client's traffic reports into a stranger's analytics property." },
|
|
42
|
+
{ s: "repoint", id: "meta-pixel", label: "Meta pixel ID", re: /fbq\(\s*["']init["']\s*,\s*["'](\d{8,})["']/gi, why: "Conversions attribute to the original owner's ad account." },
|
|
43
|
+
{ s: "repoint", id: "other-pixel", label: "Other tracking (TikTok/Clarity/Hotjar)", re: /(?:ttq\.load\(\s*["'][^"']+["']|clarity\.ms\/tag\/[A-Za-z0-9]+|static\.hotjar\.com\/c\/hotjar-\d+)/gi, why: "Third-party tracking still wired to the original account." },
|
|
44
|
+
{ s: "repoint", id: "financing", label: "Consumer financing link", re: /https?:\/\/[^\s"'`)]*(?:mysynchrony\.com|greensky|wellsfargo\.com\/.*(?:credit|financing)|affirm\.com|klarna\.com|acimacredit|foundationfinance)[^\s"'`)]*/gi, why: "A merchant-specific financing application. Applications and the resulting revenue are tied to the ORIGINAL owner's merchant account." },
|
|
45
|
+
{ s: "repoint", id: "review-widget", label: "Reviews widget", re: /https?:\/\/[^\s"'`)]*(?:elfsight\.com|trustindex|birdeye|podium\.com|reviewsonmyweb|shopperapproved)[^\s"'`)]*/gi, why: "Loads the ORIGINAL business's reviews at runtime. No file edit changes it — it must be repointed or removed." },
|
|
46
|
+
{ s: "repoint", id: "chat", label: "Chat widget", re: /https?:\/\/[^\s"'`)]*(?:tawk\.to|intercom|drift\.com|crisp\.chat|tidio)[^\s"'`)]*/gi, why: "Chats route to the original owner's inbox." },
|
|
47
|
+
{ s: "repoint", id: "mailto", label: "mailto: address", re: /mailto:([^\s"'`)<]+)/gi, why: "Emails go to the original business." },
|
|
48
|
+
{ s: "repoint", id: "tel", label: "tel: number", re: /tel:\+?[\d().\s-]{7,}/gi, why: "Calls ring the original business." },
|
|
49
|
+
{ s: "repoint", id: "social", label: "Social / review profile", re: /https?:\/\/(?:www\.)?(?:facebook\.com|instagram\.com|tiktok\.com|linkedin\.com|youtube\.com|g\.page|goo\.gl\/maps|maps\.app\.goo\.gl|yelp\.com)\/[^\s"'`)]*/gi, why: "Links to the original owner's accounts and reviews." },
|
|
50
|
+
{ s: "repoint", id: "map", label: "Map embed", re: /https?:\/\/(?:www\.)?google\.com\/maps\/embed[^\s"'`)]*/gi, why: "Shows the original business's location." },
|
|
51
|
+
|
|
52
|
+
// ---- 2. OTHER PEOPLE'S CONTENT -----------------------------------------
|
|
53
|
+
{ scope: "text", s: "people", id: "street", label: "Street address", re: /\b\d{2,6}\s+(?:[NSEW]\.?\s+)?[A-Z][A-Za-z.'-]+(?:\s+[A-Z][A-Za-z.'-]+){0,3}\s+(?:St|Street|Ave|Avenue|Blvd|Boulevard|Rd|Road|Dr|Drive|Ln|Lane|Way|Pkwy|Parkway|Hwy|Highway|Ct|Court|Ste|Suite|Unit|Pl|Place|Ter|Terrace)\b\.?(?:\s*(?:Ste|Suite|Unit|#)\s*[A-Za-z0-9-]+)?/g, why: "A physical address belonging to the original business." },
|
|
54
|
+
{ scope: "text", s: "people", id: "testimonial", label: "Testimonial / review text", re: /(?:"[^"]{40,400}"|“[^”]{40,400}”|‘[^’]{40,400}’)/g, why: "Quoted words from the original business's clients — their statement, not your client's.", limit: 12 },
|
|
55
|
+
{ scope: "text", s: "people", id: "attribution", label: "Testimonial attribution", re: /(?:^|[>\s])[-—–]\s*[A-Z][a-z]+(?:\s+[A-Z][a-z.]+)?\s*(?:,|\||$)/gm, why: "A named person credited with a quote." },
|
|
56
|
+
{ s: "people", id: "person-image", label: "Person / before-after image", re: /["'`][^"'`\s]*(?:before|after|patient|client|testimonial|headshot|staff|team|owner|founder|dr[-_])[^"'`\s]*\.(?:png|jpe?g|webp|avif|gif)["'`]/gi, why: "A real person's likeness. Before/after health images carry the heaviest liability." },
|
|
57
|
+
{ s: "people", id: "license-number", label: "Contractor / professional license number", re: /\b(?:ROC|CSLB|LIC|License|Lic\.?|Reg(?:istration)?|Permit)[\s#:]*\u00a0?\s*\d{4,10}\b/gi, why: "A state license or registration number issued to the ORIGINAL business. Publishing it under another name misrepresents licensure — in the trades and in health care this is the single most serious item on the page." },
|
|
58
|
+
{ scope: "text", s: "people", id: "credential", label: "Named credential", re: /\b[A-Z][a-z]+\s+[A-Z][a-z]+,\s*(?:MD|DO|DC|RN|NP|PA-C|DDS|LMT|PhD|MSN|FNP)\b/g, why: "A licensed professional's name and credential." },
|
|
59
|
+
|
|
60
|
+
// ---- 3. INHERITED + REGULATED CLAIMS ------------------------------------
|
|
61
|
+
{ scope: "text", s: "claims", id: "regulated", label: "REGULATED health/efficacy claim", re: /\b(?:FDA[- ](?:approved|cleared|registered)|FDA\b|clinically (?:proven|shown|validated|tested)|doctor[- ]recommended|physician[- ]recommended|board[- ]certified|medically (?:proven|supervised)|prescription[- ]strength|HIPAA[- ]compliant|scientifically proven|cures?\b|treats?\s+(?:your\s+)?(?:disease|cancer|diabetes)|no side effects|100%\s*(?:safe|effective)|guaranteed results|pain[- ]free guarantee)/gi, why: "A regulated claim about a DIFFERENT business's product, staff or protocol. Substantiation does not transfer with a copy-paste — FTC/FDA exposure sits with whoever publishes it." },
|
|
62
|
+
{ scope: "text", s: "claims", id: "rating", label: "Star rating / review count", re: /\b(?:[45](?:\.\d)?\s*(?:\/\s*5|stars?|-star)|\d{1,3}(?:,\d{3})*\+?\s*(?:5[- ]star\s*)?reviews?)\b/gi, why: "Ratings earned by the original business." },
|
|
63
|
+
{ scope: "text", s: "claims", id: "count", label: "Client / result count", re: /\b\d{1,3}(?:,\d{3})*\+?\s*(?:happy\s+)?(?:clients?|customers?|patients?|members?|sessions?|treatments?|pounds?\s+lost|lbs\s+lost)\b/gi, why: "A results claim about the original business's book of business." },
|
|
64
|
+
{ scope: "text", s: "claims", id: "tenure", label: "Years in business", re: /\b(?:since\s+(?:19|20)\d{2}|(?:over\s+)?\d{1,2}\+?\s*years?\s+(?:of\s+)?(?:experience|in business|serving))\b/gi, why: "Tenure the new business has not earned." },
|
|
65
|
+
{ scope: "text", s: "claims", id: "award", label: "Award / superlative", re: /\b(?:#1\b|number one\b|award[- ]winning|best of\s+[A-Z][a-z]+|voted\s+best|top[- ]rated)\b/gi, why: "An award or superlative belonging to the original business." },
|
|
66
|
+
{ scope: "text", s: "claims", id: "price", label: "Price / offer amount", re: /\$\s?\d{1,3}(?:,\d{3})*(?:\.\d{2})?(?:\s*(?:\/|per\s)\s*(?:mo|month|session|visit|year))?/g, why: "The original business's pricing — confirm it matches your client's offer.", limit: 15 },
|
|
67
|
+
{ scope: "text", s: "claims", id: "guarantee", label: "Guarantee / refund promise", re: /\b(?:\d{1,3}[- ]day\s+(?:money[- ]back\s+)?guarantee|money[- ]back guarantee|risk[- ]free|full refund)\b/gi, why: "A promise your client must actually be willing and able to honor." },
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const files = walk(DIR).filter((f) => isTextFile(f));
|
|
71
|
+
|
|
72
|
+
const findings = new Map(); // key: section|id -> { rule, hits: [{file,line,snippet}] }
|
|
73
|
+
const MAX_PER_RULE = 25;
|
|
74
|
+
|
|
75
|
+
/** Visible page text: what a human actually reads. */
|
|
76
|
+
function visibleText(html) {
|
|
77
|
+
return html
|
|
78
|
+
.replace(/<script[\s\S]*?<\/script>/gi, " ")
|
|
79
|
+
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
|
80
|
+
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
81
|
+
.replace(/<[^>]+>/g, " ")
|
|
82
|
+
.replace(/ /gi, " ")
|
|
83
|
+
.replace(/&/gi, "&")
|
|
84
|
+
.replace(/"/gi, '"')
|
|
85
|
+
.replace(/'/g, "'")
|
|
86
|
+
.replace(/[ \t]+/g, " ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Copy that lives inside a JS bundle (SPAs keep every headline there).
|
|
91
|
+
* Keeps human sentences, drops minified code: needs spaces, letters, and no
|
|
92
|
+
* code punctuation.
|
|
93
|
+
*/
|
|
94
|
+
function jsCopyStrings(js) {
|
|
95
|
+
const out = [];
|
|
96
|
+
for (const m of js.matchAll(/(["'`])((?:\\.|(?!\1)[^\\\r\n]){25,400})\1/g)) {
|
|
97
|
+
const v = m[2];
|
|
98
|
+
if (!/[A-Za-z]{3}/.test(v)) continue;
|
|
99
|
+
if ((v.match(/ /g) || []).length < 4) continue;
|
|
100
|
+
if (/[{}=;<>]|\)\s*\{|=>|function |var |const |return /.test(v)) continue;
|
|
101
|
+
out.push(v);
|
|
102
|
+
}
|
|
103
|
+
return out.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
const rel = path.relative(DIR, file);
|
|
108
|
+
let text;
|
|
109
|
+
try {
|
|
110
|
+
text = fs.readFileSync(file, "utf8");
|
|
111
|
+
} catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const e = ext(file);
|
|
115
|
+
// Text-scope rules read what a visitor reads — never minified internals.
|
|
116
|
+
const derived =
|
|
117
|
+
[".html", ".htm"].includes(e) ? visibleText(text) :
|
|
118
|
+
[".js", ".mjs"].includes(e) ? jsCopyStrings(text) :
|
|
119
|
+
"";
|
|
120
|
+
const lineStarts = [];
|
|
121
|
+
{
|
|
122
|
+
let idx = 0;
|
|
123
|
+
for (const line of text.split("\n")) {
|
|
124
|
+
lineStarts.push(idx);
|
|
125
|
+
idx += line.length + 1;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const lineOf = (pos) => {
|
|
129
|
+
let lo = 0, hi = lineStarts.length - 1, ans = 0;
|
|
130
|
+
while (lo <= hi) {
|
|
131
|
+
const mid = (lo + hi) >> 1;
|
|
132
|
+
if (lineStarts[mid] <= pos) { ans = mid; lo = mid + 1; } else hi = mid - 1;
|
|
133
|
+
}
|
|
134
|
+
return ans + 1;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
for (const rule of RULES) {
|
|
138
|
+
const scope = rule.scope || "raw";
|
|
139
|
+
const haystack = scope === "text" ? derived : text;
|
|
140
|
+
if (!haystack) continue;
|
|
141
|
+
const key = `${rule.s}|${rule.id}`;
|
|
142
|
+
const re = new RegExp(rule.re.source, rule.re.flags.includes("g") ? rule.re.flags : rule.re.flags + "g");
|
|
143
|
+
let m;
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
while ((m = re.exec(haystack)) !== null) {
|
|
146
|
+
if (m[0].length === 0) { re.lastIndex++; continue; }
|
|
147
|
+
const raw = (m[1] || m[0]).trim();
|
|
148
|
+
const snippet = raw.replace(/\s+/g, " ").slice(0, 160);
|
|
149
|
+
if (!snippet || seen.has(snippet)) continue;
|
|
150
|
+
seen.add(snippet);
|
|
151
|
+
if (!findings.has(key)) findings.set(key, { rule, hits: [] });
|
|
152
|
+
const rec = findings.get(key);
|
|
153
|
+
if (rec.hits.length >= (rule.limit || MAX_PER_RULE)) continue;
|
|
154
|
+
const pos = scope === "text" ? text.indexOf(raw.slice(0, 40)) : m.index;
|
|
155
|
+
rec.hits.push({ file: rel, line: lineOf(pos >= 0 ? pos : 0), snippet });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---- context from the mirror + substitution runs ---------------------------
|
|
161
|
+
let mirror = null, subs = null;
|
|
162
|
+
try { mirror = readJson(path.join(REPORTS, "run-report.json")); } catch {}
|
|
163
|
+
try { subs = readJson(path.join(REPORTS, "substitution-report.json")); } catch {}
|
|
164
|
+
|
|
165
|
+
const SECTIONS = [
|
|
166
|
+
{ key: "repoint", title: "1. MUST REPOINT — leads, bookings and money", lead: "Every item below still points at the ORIGINAL owner. Anything left unchanged sends your client's leads, appointments, tracking or payments to someone else." },
|
|
167
|
+
{ key: "people", title: "2. REVIEW — other people's content", lead: "Names, addresses, likenesses and words that belong to the original business and its clients. Replace or remove; do not simply delete blocks you have not looked at." },
|
|
168
|
+
{ key: "claims", title: "3. REVIEW — inherited and regulated claims", lead: "Factual assertions about a DIFFERENT business. Regulated health/efficacy claims are the highest-risk items on this page: the substantiation does not travel with the copy." },
|
|
169
|
+
];
|
|
170
|
+
|
|
171
|
+
const md = [];
|
|
172
|
+
md.push("# REVIEW REQUIRED — before this page takes real traffic");
|
|
173
|
+
md.push("");
|
|
174
|
+
md.push(`Generated ${nowIso()} by GHL Command clone-site.`);
|
|
175
|
+
md.push("");
|
|
176
|
+
if (mirror) {
|
|
177
|
+
md.push("| | |");
|
|
178
|
+
md.push("|---|---|");
|
|
179
|
+
md.push(`| Source | ${mirror.source?.url || "(unknown)"} |`);
|
|
180
|
+
md.push(`| Rights declared | **${mirror.rights?.declaration || "(not recorded)"}**${mirror.rights?.note ? ` — "${mirror.rights.note}"` : ""} |`);
|
|
181
|
+
md.push(`| Declaration recorded | ${mirror.generatedAt} (user's statement; this tool does not verify ownership) |`);
|
|
182
|
+
md.push(`| Files mirrored | ${mirror.inventory?.files ?? "?"} (${mirror.inventory?.totalHuman ?? "?"}) |`);
|
|
183
|
+
md.push("");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let totalFindings = 0;
|
|
187
|
+
for (const sec of SECTIONS) {
|
|
188
|
+
const recs = [...findings.values()].filter((f) => f.rule.s === sec.key);
|
|
189
|
+
md.push(`## ${sec.title}`);
|
|
190
|
+
md.push("");
|
|
191
|
+
md.push(sec.lead);
|
|
192
|
+
md.push("");
|
|
193
|
+
if (recs.length === 0) {
|
|
194
|
+
md.push("_Nothing detected._ Detection is pattern-based — read the page yourself before launch.");
|
|
195
|
+
md.push("");
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
for (const rec of recs) {
|
|
199
|
+
totalFindings += rec.hits.length;
|
|
200
|
+
md.push(`### ${rec.rule.label} (${rec.hits.length}${rec.hits.length >= (rec.rule.limit || MAX_PER_RULE) ? "+" : ""})`);
|
|
201
|
+
md.push("");
|
|
202
|
+
md.push(`${rec.rule.why}`);
|
|
203
|
+
md.push("");
|
|
204
|
+
for (const h of rec.hits) md.push(`- \`${h.file}:${h.line}\` — ${h.snippet.replace(/\|/g, "\\|")}`);
|
|
205
|
+
md.push("");
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Internal navigation that has no page in this clone. Standard for a single-page
|
|
210
|
+
// mirror, and the first thing a client clicks. Worth naming explicitly.
|
|
211
|
+
const dangling = new Set();
|
|
212
|
+
// After a rebrand, internal nav is often absolute on the NEW domain. Treat those
|
|
213
|
+
// hosts as our own, or every internal link looks "external" and goes unchecked.
|
|
214
|
+
const ownHosts = new Set();
|
|
215
|
+
try {
|
|
216
|
+
const subsForHosts = readJson(path.join(REPORTS, "substitution-report.json"));
|
|
217
|
+
for (const h of subsForHosts.hits || []) {
|
|
218
|
+
if (!/^domain/.test(h.label) || !h.new) continue;
|
|
219
|
+
const host = String(h.new).replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
|
|
220
|
+
if (host) {
|
|
221
|
+
ownHosts.add(host);
|
|
222
|
+
ownHosts.add(host.replace(/^www\./, ""));
|
|
223
|
+
ownHosts.add("www." + host.replace(/^www\./, ""));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
} catch {}
|
|
227
|
+
|
|
228
|
+
for (const file of files) {
|
|
229
|
+
if (![".html", ".htm"].includes(ext(file))) continue;
|
|
230
|
+
const text = fs.readFileSync(file, "utf8");
|
|
231
|
+
for (const m of text.matchAll(/href=["']([^"'#]+)["']/gi)) {
|
|
232
|
+
let href = m[1].trim();
|
|
233
|
+
if (/^(mailto:|tel:|javascript:|data:)/i.test(href)) continue;
|
|
234
|
+
let p2 = null;
|
|
235
|
+
if (href.startsWith("/")) p2 = href;
|
|
236
|
+
else if (/^https?:\/\//i.test(href)) {
|
|
237
|
+
let u = null;
|
|
238
|
+
try { u = new URL(href); } catch { continue; }
|
|
239
|
+
if (!ownHosts.has(u.hostname.toLowerCase())) continue; // truly external — the repoint rules cover it
|
|
240
|
+
p2 = u.pathname;
|
|
241
|
+
}
|
|
242
|
+
else if (!href.includes(":")) p2 = "/" + href;
|
|
243
|
+
if (!p2) continue;
|
|
244
|
+
p2 = p2.split("?")[0].replace(/\/+$/, "");
|
|
245
|
+
if (!p2 || p2 === "") continue;
|
|
246
|
+
const rel = p2.replace(/^\/+/, "");
|
|
247
|
+
const candidates = [rel, rel + ".html", path.posix.join(rel, "index.html")];
|
|
248
|
+
if (!candidates.some((c) => fs.existsSync(path.join(DIR, c)))) dangling.add(p2);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Brand baked into image files. Text substitution cannot reach pixels: the logo,
|
|
253
|
+
// and any graphic carrying the old phone number or URL, still belong to the
|
|
254
|
+
// original business after a "clean" rebrand. Live-caught 2026-07-30 — the header
|
|
255
|
+
// logo still read the original name, phone and domain on an otherwise clean clone.
|
|
256
|
+
const loose = (v) => String(v).toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
257
|
+
const brandedAssets = [];
|
|
258
|
+
try {
|
|
259
|
+
const mirrorForAssets = readJson(path.join(REPORTS, "run-report.json"));
|
|
260
|
+
const subsForAssets = (() => { try { return readJson(path.join(REPORTS, "substitution-report.json")); } catch { return { hits: [] }; } })();
|
|
261
|
+
const needles = [...new Set((subsForAssets.hits || [])
|
|
262
|
+
.map((h) => loose(h.old))
|
|
263
|
+
.filter((v) => v.length >= 6))];
|
|
264
|
+
const imageExt = [".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg", ".avif", ".bmp", ".pdf"];
|
|
265
|
+
for (const a of mirrorForAssets.assets || []) {
|
|
266
|
+
if (!imageExt.includes(ext(a.local))) continue;
|
|
267
|
+
const base = loose(path.posix.basename(a.local));
|
|
268
|
+
const brandHit = needles.find((n) => base.includes(n));
|
|
269
|
+
const looksIdentity = /logo|header|banner|watermark|letterhead|business.?card|truck|van|sign/.test(path.posix.basename(a.local).toLowerCase());
|
|
270
|
+
if (brandHit || looksIdentity) {
|
|
271
|
+
brandedAssets.push({ local: a.local, reason: brandHit ? `filename carries the old brand ("${brandHit}")` : "filename suggests an identity asset (logo/banner/signage)" });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {}
|
|
275
|
+
if (brandedAssets.length) {
|
|
276
|
+
md.push("### Brand baked into image files (" + brandedAssets.length + ")");
|
|
277
|
+
md.push("");
|
|
278
|
+
md.push("**Substitution cannot edit pixels.** These image files carry the original business's name, phone number, web address or signage inside the artwork. On a rebranded page the text reads correctly while the logo still advertises the original company — the single most visible way a clone gives itself away, and a straight trademark problem if it ships. Replace the logo and any graphic containing contact details with the new client's artwork.");
|
|
279
|
+
md.push("");
|
|
280
|
+
for (const b of brandedAssets.slice(0, 40)) md.push(`- \`${b.local}\` — ${b.reason}`);
|
|
281
|
+
if (brandedAssets.length > 40) md.push(`- …and ${brandedAssets.length - 40} more`);
|
|
282
|
+
md.push("");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
md.push("## 4. Excluded, oversized and unresolved");
|
|
286
|
+
md.push("");
|
|
287
|
+
if (dangling.size) {
|
|
288
|
+
md.push(`**Internal links with no page in this clone:** ${dangling.size}`);
|
|
289
|
+
md.push("");
|
|
290
|
+
md.push("This is normal for a single-page mirror — the nav still lists the whole original site. Either mirror those pages too, point the links somewhere real, or trim the nav. As it stands they are the first thing a visitor clicks and the first thing that 404s.");
|
|
291
|
+
md.push("");
|
|
292
|
+
for (const d of [...dangling].sort().slice(0, 30)) md.push(`- ${d}`);
|
|
293
|
+
if (dangling.size > 30) md.push(`- …and ${dangling.size - 30} more`);
|
|
294
|
+
md.push("");
|
|
295
|
+
}
|
|
296
|
+
if (mirror) {
|
|
297
|
+
if ((mirror.oversized || []).length) {
|
|
298
|
+
md.push(`**Oversized — NOT downloaded (route to object storage, never hot-link the source):** ${mirror.oversized.length}`);
|
|
299
|
+
md.push("");
|
|
300
|
+
for (const o of mirror.oversized) md.push(`- ${humanBytes(o.bytes)} — ${o.url}`);
|
|
301
|
+
md.push("");
|
|
302
|
+
}
|
|
303
|
+
if ((mirror.skippedForeignOrMissing || []).length) {
|
|
304
|
+
md.push(`**Excluded — the origin served HTML where an asset should be** (missing, or another tenant's file): ${mirror.skippedForeignOrMissing.length}`);
|
|
305
|
+
md.push("");
|
|
306
|
+
for (const s of mirror.skippedForeignOrMissing.slice(0, 25)) md.push(`- ${s.url}`);
|
|
307
|
+
md.push("");
|
|
308
|
+
}
|
|
309
|
+
if ((mirror.failed || []).length) {
|
|
310
|
+
md.push(`**Failed to fetch:** ${mirror.failed.length}`);
|
|
311
|
+
md.push("");
|
|
312
|
+
for (const f of mirror.failed.slice(0, 25)) md.push(`- [${f.status}] ${f.url}`);
|
|
313
|
+
md.push("");
|
|
314
|
+
}
|
|
315
|
+
if ((mirror.externalRefsLeft || []).length) {
|
|
316
|
+
md.push(`**Third-party references left as-is** (fonts, SDKs, analytics loaders): ${mirror.externalRefsLeft.length}`);
|
|
317
|
+
md.push("");
|
|
318
|
+
for (const u of mirror.externalRefsLeft.slice(0, 25)) md.push(`- ${u}`);
|
|
319
|
+
md.push("");
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (subs) {
|
|
323
|
+
const noReplacement = (subs.hits || []).filter((h) => !h.new);
|
|
324
|
+
if (noReplacement.length) {
|
|
325
|
+
md.push(`**Present in the page with no replacement value supplied:** ${noReplacement.length}`);
|
|
326
|
+
md.push("");
|
|
327
|
+
for (const h of noReplacement) md.push(`- ${h.label}: \`${h.old}\` ×${h.total}`);
|
|
328
|
+
md.push("");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (!mirror && !subs) {
|
|
332
|
+
md.push("_No mirror/substitution reports found in this directory._");
|
|
333
|
+
md.push("");
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
md.push("---");
|
|
337
|
+
md.push("");
|
|
338
|
+
md.push("**Everything above describes the original business, not your client. Replace or remove each item before this page takes real traffic.**");
|
|
339
|
+
md.push("");
|
|
340
|
+
md.push("This report is pattern-based and deliberately over-inclusive. It is not legal advice, and it is not a rights check: you declared your authorization at step 0 and you are responsible for that declaration and for what you publish.");
|
|
341
|
+
md.push("");
|
|
342
|
+
|
|
343
|
+
fs.writeFileSync(OUT, md.join("\n"));
|
|
344
|
+
|
|
345
|
+
// ---- console summary -------------------------------------------------------
|
|
346
|
+
const counts = {};
|
|
347
|
+
for (const sec of SECTIONS) {
|
|
348
|
+
counts[sec.key] = [...findings.values()].filter((f) => f.rule.s === sec.key).reduce((n, f) => n + f.hits.length, 0);
|
|
349
|
+
}
|
|
350
|
+
const regulated = findings.get("claims|regulated");
|
|
351
|
+
const out = [];
|
|
352
|
+
out.push("");
|
|
353
|
+
out.push("PRE-LAUNCH AUDIT COMPLETE");
|
|
354
|
+
out.push(` MUST REPOINT (leads/bookings/money) : ${counts.repoint}`);
|
|
355
|
+
out.push(` Other people's content : ${counts.people}`);
|
|
356
|
+
out.push(` Inherited + regulated claims : ${counts.claims}`);
|
|
357
|
+
if (regulated) out.push(` ⚠ REGULATED CLAIMS FLAGGED : ${regulated.hits.length} — highest-risk items on the page`);
|
|
358
|
+
if (brandedAssets.length) out.push(` ⚠ Images with the ORIGINAL brand baked in (logo etc.): ${brandedAssets.length} — substitution cannot fix these`);
|
|
359
|
+
if (dangling.size) out.push(` Internal links with no page in this clone: ${dangling.size}`);
|
|
360
|
+
out.push(` report: ${OUT}`);
|
|
361
|
+
out.push("");
|
|
362
|
+
out.push(" Show the user this report before deploying. Never auto-delete a finding.");
|
|
363
|
+
out.push("");
|
|
364
|
+
console.log(out.join("\n"));
|