Files
dr-shahab-mousavi/tools/clone_page.py
T
alireza-hassaniandCursor 56c4df0d18 Initial commit of the Dr Shahab Mousavi website.
Next.js clone of the clinic site with Meshkee-backed articles and portfolio pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:09:31 +03:30

423 lines
16 KiB
Python

"""Regenerate the page components in src/components/generated from dr-mousavi.com.
Downloads each source page, mirrors any asset it references into public/theme
(preserving the remote paths so the theme CSS keeps resolving), then converts the
markup to JSX with class names, inline styles and AOS attributes intact.
python3 tools/clone_page.py # all pages
python3 tools/clone_page.py services # a single page
Source HTML is cached under tools/.cache; delete it to pull fresh markup.
Storage-hosted article thumbnails have Persian filenames, so they are stored
under a hash of their URL, which keeps names stable when the article list changes.
"""
import hashlib
import json
import os
import re
import sys
import urllib.parse
import urllib.request
from html.parser import HTMLParser
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CACHE = os.path.join(ROOT, "tools", ".cache")
THEME = os.path.join(ROOT, "public", "theme")
OUT = os.path.join(ROOT, "src", "components", "generated")
TPL = "https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/dr-mousavi_RsrCTgktvU/assets/"
STORE = "https://meshkee-storage.sas.amin.parminstorage.ir/"
ASSET_EXT = {".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".ttf", ".woff", ".woff2"}
# source page -> (url, component name, section of the document to convert)
PAGES = {
"home": ("https://www.dr-mousavi.com/", "HomeSections", "main"),
"about": ("https://www.dr-mousavi.com/about-us", "AboutSections", "main"),
"contact": ("https://www.dr-mousavi.com/contact-us", "ContactSections", "main"),
"services": ("https://www.dr-mousavi.com/ourservices", "ServicesSections", "main"),
"header": ("https://www.dr-mousavi.com/", "SiteHeader", "header"),
"footer": ("https://www.dr-mousavi.com/", "SiteFooter", "footer"),
}
# Pages that exist in this clone; anything else keeps pointing at the live site.
INTERNAL = {
"https://www.dr-mousavi.com": "/",
"https://www.dr-mousavi.com/": "/",
"http://www.dr-mousavi.com": "/",
"https://dr-mousavi.com": "/",
"https://www.dr-mousavi.com/about-us": "/about-us",
"https://www.dr-mousavi.com/contact-us": "/contact-us",
"https://www.dr-mousavi.com/ourservices": "/ourservices",
"https://www.dr-mousavi.com/surgeries": "/surgeries",
"https://www.dr-mousavi.com/articles": "/articles",
}
# The original points its account icon at an app subdomain; this clone sends
# visitors to the Meshkee customer portal instead, whose URL lives in one place.
CUSTOMER_PORTAL_HOSTS = ("app.dr-mousavi.com", "customer.dr-mousavi.com")
CUSTOMER_URL_TOKEN = "__CUSTOMER_URL__"
VOID = {"img", "input", "br", "hr", "meta", "link", "source", "area", "base", "col", "embed", "track", "wbr"}
DROP_TAGS = {"script", "style", "noscript"}
ATTR_RENAME = {
"class": "className",
"for": "htmlFor",
"colspan": "colSpan",
"rowspan": "rowSpan",
"tabindex": "tabIndex",
"maxlength": "maxLength",
"allowfullscreen": "allowFullScreen",
"frameborder": "frameBorder",
"srcset": "srcSet",
"novalidate": "noValidate",
"autocomplete": "autoComplete",
"readonly": "readOnly",
"viewbox": "viewBox",
"preserveaspectratio": "preserveAspectRatio",
"stroke-dasharray": "strokeDasharray",
"stroke-dashoffset": "strokeDashoffset",
}
# Builder bookkeeping and Bootstrap JS hooks; behaviour is reimplemented in React.
DROP_ATTR_PREFIX = ("wb-", "data-toggle", "data-target", "data-parent", "aria-expanded",
"webkitallowfullscreen", "mozallowfullscreen")
DROP_ATTR = {"onclick", "onsubmit", "onload"}
# The theme's date script reads this attribute; SiteBehaviour does the same.
ATTR_RENAME["date"] = "data-date"
def jstr(value):
return json.dumps(value, ensure_ascii=False)
class Node:
def __init__(self, tag=None, attrs=None):
self.tag = tag
self.attrs = dict(attrs or [])
self.children = []
self.text = None
class Builder(HTMLParser):
def __init__(self):
super().__init__(convert_charrefs=False)
self.root = Node("#root")
self.stack = [self.root]
def handle_starttag(self, tag, attrs):
node = Node(tag, attrs)
self.stack[-1].children.append(node)
if tag not in VOID:
self.stack.append(node)
def handle_startendtag(self, tag, attrs):
self.stack[-1].children.append(Node(tag, attrs))
def handle_endtag(self, tag):
for i in range(len(self.stack) - 1, 0, -1):
if self.stack[i].tag == tag:
del self.stack[i:]
return
def _text(self, value):
node = Node()
node.text = value
self.stack[-1].children.append(node)
def handle_data(self, data):
self._text(data)
def handle_entityref(self, name):
self._text(f"&{name};")
def handle_charref(self, name):
self._text(f"&#{name};")
def fetch(url, dest):
if os.path.exists(dest) and os.path.getsize(dest) > 0:
return True
os.makedirs(os.path.dirname(dest), exist_ok=True)
parts = urllib.parse.urlsplit(url)
encoded = urllib.parse.urlunsplit(
(parts.scheme, parts.netloc, urllib.parse.quote(parts.path), parts.query, parts.fragment)
)
try:
request = urllib.request.Request(encoded, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=60) as response, open(dest, "wb") as handle:
handle.write(response.read())
return True
except Exception as exc: # noqa: BLE001 - upstream 404s are expected and skippable
print(" asset failed:", url.rsplit("/", 1)[-1], exc)
if os.path.exists(dest):
os.remove(dest)
return False
def download_pages(selected):
os.makedirs(CACHE, exist_ok=True)
html_by_page = {}
for name in selected:
url = PAGES[name][0]
path = os.path.join(CACHE, f"{name}.html")
if not os.path.exists(path):
print("downloading", url)
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=60) as response:
open(path, "wb").write(response.read())
html_by_page[name] = open(path, encoding="utf-8", errors="ignore").read()
return html_by_page
def collect_asset_urls(html):
"""Attribute values and CSS url() targets; article paths may contain parentheses."""
html = html.replace("\\/", "/")
urls = set()
# Anchor url() matches on the file extension: article filenames contain "()".
image = r"\.(?:png|jpe?g|gif|svg|webp)"
for pattern in (
r"(?:src|href|data-src)=\"(https?://[^\"]+)\"",
rf"url\((https?://.+?{image})\)",
rf"url\('(https?://.+?{image})'\)",
):
urls.update(re.findall(pattern, html, re.IGNORECASE))
return {u.split("?")[0] for u in urls if u.startswith(TPL) or u.startswith(STORE)}
def mirror_assets(html_by_page):
"""Return a remote-url -> local-path map, downloading anything missing."""
urls = set()
for html in html_by_page.values():
urls.update(collect_asset_urls(html))
mapping = {}
for url in sorted(urls):
ext = os.path.splitext(url)[1].lower()
if ext not in ASSET_EXT:
continue
if url.startswith(TPL):
rel = url[len(TPL):]
else:
rel = f"articles/{hashlib.md5(url.encode()).hexdigest()[:12]}{ext}"
if fetch(url, os.path.join(THEME, rel)):
mapping[url] = f"/theme/{rel}"
return mapping
def prune_articles(mapping):
"""Drop thumbnails from earlier runs so no stale file shadows a new name."""
folder = os.path.join(THEME, "articles")
if not os.path.isdir(folder):
return
keep = {os.path.basename(path) for path in mapping.values()}
for name in os.listdir(folder):
if name not in keep:
os.remove(os.path.join(folder, name))
def camel(prop):
if prop.startswith("--"):
return prop
head, *rest = prop.split("-")
return head + "".join(part.capitalize() for part in rest)
class Converter:
def __init__(self, assets):
self.assets = assets
self.icons = set()
self.unsupported_important = set()
def rewrite_urls(self, value):
for url, local in self.assets.items():
if url in value:
value = value.replace(url, local)
return value
def rewrite_link(self, href):
href = self.rewrite_urls(href.strip())
if any(host in href for host in CUSTOMER_PORTAL_HOSTS):
return CUSTOMER_URL_TOKEN
if href in INTERNAL:
return INTERNAL[href]
if href.startswith("/assets"):
return "#"
if href.startswith("/article-details"):
return "/articles"
return href
def style_object(self, value):
"""Return the JSX style object plus the declarations marked !important.
React style objects cannot express !important, so the caller has to
replace those declarations with a class that carries the same weight.
"""
pairs = []
important = {}
for chunk in value.split(";"):
if ":" not in chunk:
continue
prop, val = chunk.split(":", 1)
prop, val = prop.strip(), val.strip()
if not prop or not val or val == "undefined":
continue
plain = re.sub(r"\s*!\s*important$", "", val)
if plain != val:
important[prop] = plain
pairs.append(f"{jstr(camel(prop))}: {jstr(self.rewrite_urls(plain))}")
return ("{ " + ", ".join(pairs) + " }" if pairs else None), important
def important_classes(self, important):
"""Map !important inline declarations onto the helper classes in globals.css."""
classes = []
for prop, val in important.items():
if prop == "direction" and val in ("ltr", "rtl"):
classes.append(f"wb-force-{val}")
else:
self.unsupported_important.add(f"{prop}: {val}")
return classes
@staticmethod
def should_drop(node):
if node.tag in DROP_TAGS:
return True
cls = (node.attrs.get("class") or "").split()
if "loader-mask" in cls or "copyright-willaarts" in cls:
return True
return node.tag == "input" and node.attrs.get("type") == "hidden"
def serialize_attrs(self, node):
out = []
style_obj, important = self.style_object(node.attrs.get("style") or "")
extra_classes = self.important_classes(important)
for key, value in node.attrs.items():
low = key.lower().strip()
if not low or low in DROP_ATTR or any(low.startswith(p) for p in DROP_ATTR_PREFIX):
continue
value = value or ""
if low == "style":
if style_obj:
out.append(f"style={{{style_obj}}}")
continue
if low in ("src", "poster"):
value = self.rewrite_urls(value)
elif low == "href":
value = self.rewrite_link(value)
if value == CUSTOMER_URL_TOKEN:
out.append("href={CUSTOMER_URL}")
continue
elif low == "class":
value = " ".join(dict.fromkeys(value.split() + extra_classes))
extra_classes = []
elif low == "value" and node.tag in ("input", "textarea"):
out.append(f"defaultValue={jstr(value)}")
continue
elif low == "allowfullscreen":
out.append("allowFullScreen")
continue
name = ATTR_RENAME.get(low, low)
if "-" in name and not name.startswith(("data-", "aria-")):
head, *rest = name.split("-")
name = head + "".join(part.capitalize() for part in rest)
out.append(f"{name}={jstr(value)}")
if extra_classes:
out.append(f"className={jstr(' '.join(extra_classes))}")
if node.tag == "img" and "alt" not in node.attrs:
out.append('alt=""')
return out
def serialize(self, node, indent=0):
pad = " " * indent
if node.text is not None:
text = node.text.strip()
return pad + text.replace("{", "&#123;").replace("}", "&#125;") + "\n" if text else ""
if self.should_drop(node):
return ""
self.icons.update(re.findall(r"WMi-[a-zA-Z0-9_-]+", node.attrs.get("class") or ""))
children = "".join(self.serialize(child, indent + 1) for child in node.children)
attr_text = " ".join(self.serialize_attrs(node))
attr_text = f" {attr_text}" if attr_text else ""
if (node.attrs.get("id") or "").startswith("owl-carousel"):
margin = 10 if node.attrs["id"] == "owl-carouselone" else 30
return f"{pad}<Carousel margin={{{margin}}}>\n{children}{pad}</Carousel>\n"
tag = node.tag
# A <p> around block markup is invalid HTML: browsers reparent it, which
# would leave React's hydrated tree different from the server output.
if tag == "p" and any(
child.tag in {"div", "figure", "section", "ul", "ol", "table", "form", "p"}
for child in node.children
):
tag = "div"
elif tag == "a":
href = self.rewrite_link(node.attrs.get("href") or "")
if href.startswith("/") and not href.startswith("//"):
tag = "Link"
if tag in VOID:
return f"{pad}<{tag}{attr_text} />\n"
if not children:
return f"{pad}<{tag}{attr_text}></{tag}>\n"
return f"{pad}<{tag}{attr_text}>\n{children}{pad}</{tag}>\n"
def extract(html, section):
if section == "header":
start, end = '<header class="wb-header">', "</header>\n <main"
elif section == "footer":
start, end = '<footer class="wb-footer">', "</footer>"
else:
start, end = '<main class="wb-content">', "</main>"
begin = html.index(start) + len(start)
return html[begin:html.index(end, begin)]
def main():
selected = sys.argv[1:] or list(PAGES)
unknown = [name for name in selected if name not in PAGES]
if unknown:
sys.exit(f"unknown page(s): {', '.join(unknown)}\navailable: {', '.join(PAGES)}")
html_by_page = download_pages(sorted(set(selected) | {"home", "about", "contact", "services"}))
assets = mirror_assets(html_by_page)
prune_articles(assets)
print(f"assets mapped: {len(assets)}")
os.makedirs(OUT, exist_ok=True)
for name in selected:
_, component, section = PAGES[name]
converter = Converter(assets)
tree = Builder()
tree.feed(extract(html_by_page[name], section))
jsx = "".join(converter.serialize(child, 3) for child in tree.root.children)
imports = ""
if "<Link" in jsx:
imports += 'import Link from "next/link";\n'
if "<Carousel" in jsx:
imports += 'import Carousel from "@/components/Carousel";\n'
if "{CUSTOMER_URL}" in jsx:
imports += 'import { CUSTOMER_URL } from "@/lib/meshkee";\n'
if imports:
imports += "\n"
path = os.path.join(OUT, f"{component}.tsx")
with open(path, "w", encoding="utf-8") as handle:
handle.write(
f"{imports}export default function {component}() {{\n"
f" return (\n <>\n{jsx} </>\n );\n}}\n"
)
print(f"wrote {component}.tsx icons={sorted(converter.icons)}")
for declaration in sorted(converter.unsupported_important):
print(f" !important declaration needs a helper class: {declaration}")
if __name__ == "__main__":
main()