# -*- coding: utf-8 -*-
"""网站离线镜像爬虫(纯标准库版,无需 requests / beautifulsoup4)
用法:
python mirror_stdlib.py <起始URL> [最大页数]
示例:
python mirror_stdlib.py http://171.80.3.206:39897/
python mirror_stdlib.py https://example.com/ 5000
行为:
- 页面 BFS 爬取(仅目标域),统一存为 .html 便于 file:// 浏览
- 附件(pdf/zip 等)任何域名都下载
- CSS/JS/图片/字体等资源下载并把 HTML/CSS 内链接改写为本地相对路径
- 断点续传(crawl_manifest.json),可重复运行补漏
- 每请求间隔 0.3s,礼貌抓取
"""
import os, re, sys, json, time, hashlib, threading, gzip, zlib
from urllib.parse import urlparse, urljoin, urldefrag, unquote
from urllib.request import Request, urlopen
from urllib.error import HTTPError, URLError
from collections import deque
from concurrent.futures import ThreadPoolExecutor
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
TARGET = sys.argv[1].rstrip("/")
MAX_PAGES = int(sys.argv[2]) if len(sys.argv) > 2 else 3000
ROOT = os.path.dirname(os.path.abspath(__file__))
HOST = urlparse(TARGET).netloc
HOST_DIR = re.sub(r'[<>:"\\|?*]', "_", HOST) # Windows 目录名非法字符替换(端口冒号)
OUT = os.path.join(ROOT, "mirror", HOST_DIR)
MANIFEST = os.path.join(ROOT, "crawl_manifest.json")
QUEUE_FILE = os.path.join(ROOT, "queue.json")
LOG = open(os.path.join(ROOT, "crawl_log.txt"), "a", encoding="utf-8")
PAGE_HOSTS = {HOST.lower()}
START_URLS = [TARGET]
ATT_EXT = {
".pdf", ".zip", ".rar", ".7z", ".stp", ".step", ".dwg", ".dxf", ".igs", ".iges",
".stl", ".xls", ".xlsx", ".csv", ".doc", ".docx", ".ppt", ".pptx", ".tar", ".gz",
}
SKIP_QUERY_KEYS = {"cmsfkt", "print", "lang", "language", "sprache", "session", "cfid", "cftoken", "search", "q"}
SKIP_QUERY_VALS = {"watchlist", "nl_add", "logout", "login", "cart", "basket"}
DELAY = 0.3
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
SKIP_ASSET_HOSTS = {"etracker.com", "google-analytics.com", "googletagmanager.com",
"doubleclick.net", "hotjar.com", "statcounter.com", "matomo.org",
"google.com", "facebook.com", "twitter.com", "linkedin.com"}
failed_logged = set()
LOCK = threading.RLock()
POOL = ThreadPoolExecutor(max_workers=4)
manifest = {}
if os.path.exists(MANIFEST):
try:
manifest = json.load(open(MANIFEST, encoding="utf-8"))
except Exception:
pass
visited = {u for u, v in manifest.items() if v.get("status") == "ok"}
failed = []
ext_html_seen = set()
def log(*a):
s = " ".join(str(x) for x in a)
print(s, flush=True)
with LOCK:
LOG.write(s + "\n"); LOG.flush()
def save_manifest():
with LOCK:
with open(MANIFEST, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=1)
def load_q():
try:
return json.load(open(QUEUE_FILE, encoding="utf-8"))
except Exception:
return []
def save_q(q):
with LOCK:
json.dump(list(q), open(QUEUE_FILE, "w", encoding="utf-8"))
def norm_url(u):
u = u.strip()
if not u or u.startswith(("javascript:", "mailto:", "tel:", "data:", "sms:", "about:", "#")):
return None
u, frag = urldefrag(u)
p = urlparse(u)
if p.scheme not in ("http", "https"):
return None
netloc = (p.netloc or "").lower() # 保留端口,不能 split(":") 去端口
path = p.path or "/"
qs = []
if p.query:
for kv in p.query.split("&"):
k = kv.split("=", 1)[0].lower()
v = kv.split("=", 1)[1].lower() if "=" in kv else ""
if k in SKIP_QUERY_KEYS or v in SKIP_QUERY_VALS:
continue
qs.append(kv)
query = "&".join(qs)
return f"{p.scheme}://{netloc}{path}" + (f"?{query}" if query else "")
def is_attachment(u):
p = urlparse(u)
path = unquote(p.path).lower()
return path.endswith(tuple(ATT_EXT))
STATIC_EXT = (".jpg", ".jpeg", ".png", ".gif", ".ico", ".svg", ".bmp", ".webp", ".tif", ".tiff")
def is_static(u):
return urlparse(u).path.lower().endswith(STATIC_EXT)
def skip_asset(u):
p = urlparse(u)
h = (p.netloc or "").lower().split(":")[0]
for bad in SKIP_ASSET_HOSTS:
if h == bad or h.endswith("." + bad):
return True
if not p.path or p.path == "/":
return True
return False
def is_junk_page(u):
p = urlparse(u)
path = p.path.lower()
return path.endswith((".css", ".js", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".svg",
".woff", ".woff2", ".ttf", ".webp", ".bin", ".map"))
def sanitize_seg(seg):
seg = re.sub(r'[<>:"\\|?*]', "_", seg)
seg = seg.strip(" .")
if not seg:
seg = "_"
if len(seg) > 90:
seg = seg[:80] + "_" + hashlib.md5(seg.encode()).hexdigest()[:8]
return seg
def local_path(u, is_page):
p = urlparse(u)
path = unquote(p.path)
if path.endswith("/"):
path += "index"
segs = [sanitize_seg(s) for s in path.split("/") if s]
if not segs:
segs = ["index"]
name = segs.pop()
if is_page:
name += ".html"
else:
if "." not in name or not re.search(r"\.[A-Za-z0-9]{1,6}$", name):
name += ".bin"
if p.query:
qs = sanitize_seg(unquote(p.query).replace("/", "_").replace("\\", "_"))[:120]
name = name.rsplit(".", 1)[0] + "@@" + qs + "." + name.rsplit(".", 1)[1]
return os.path.join(*segs, name)
def http_get(u, timeout=30):
"""urllib GET,返回 (bytes, content_type)。处理 gzip/deflate。"""
req = Request(u, headers={"User-Agent": UA, "Accept-Encoding": "identity"})
resp = urlopen(req, timeout=timeout)
data = resp.read()
headers = resp.headers
enc = (headers.get("Content-Encoding", "") or "").lower()
if enc == "gzip":
try:
data = gzip.decompress(data)
except Exception:
pass
elif enc == "deflate":
try:
data = zlib.decompress(data)
except Exception:
pass
ct = headers.get("Content-Type", "") or ""
return data, ct
def fetch(u, timeout=(15, 60)):
for attempt in range(3):
try:
return http_get(u, timeout=timeout[1])
except HTTPError as e:
if e.code in (400, 401, 403, 404, 410):
raise
if attempt == 2:
raise
time.sleep(2 * (attempt + 1))
except Exception:
if attempt == 2:
raise
time.sleep(2 * (attempt + 1))
def decode(data, ct):
"""字节 -> 文本,尽力识别编码。"""
enc = None
m = re.search(r'charset=["\']?([\w\-]+)', ct, re.I)
if m:
enc = m.group(1)
if not enc:
head = data[:4096]
m = re.search(br']+charset=["\']?([\w\-]+)', head, re.I)
if m:
enc = m.group(1).decode("ascii", "ignore")
if not enc and data.startswith(b"\xef\xbb\xbf"):
enc = "utf-8-sig"
if not enc:
enc = "utf-8"
for e in (enc, "utf-8", "gbk", "gb18030", "latin-1"):
try:
return data.decode(e)
except Exception:
continue
return data.decode("latin-1", "replace")
def download_file(u, kind):
"""下载二进制资源/附件,返回相对路径。"""
if skip_asset(u):
return None
rel = local_path(u, is_page=False)
fp = os.path.join(OUT, rel)
with LOCK:
if u in manifest and manifest[u].get("status") == "ok" and os.path.exists(fp):
return rel
manifest[u] = {"path": rel, "type": kind, "status": "pending"}
save_manifest()
try:
data, ct = http_get(u)
os.makedirs(os.path.dirname(fp), exist_ok=True)
with open(fp, "wb") as f:
f.write(data)
# CSS: 改写 url() 引用为本地路径
if kind == "asset" and (rel.lower().endswith(".css") or "css" in ct):
try:
new_css = css_urls(data.decode("utf-8", "replace"), u)
with open(fp, "w", encoding="utf-8") as f:
f.write(new_css)
except Exception:
pass
with LOCK:
manifest[u] = {"path": rel, "type": kind, "status": "ok",
"size": len(data), "ct": ct}
save_manifest()
return rel
except HTTPError as e:
with LOCK:
failed.append((u, str(e)))
manifest[u] = {"path": rel, "type": kind,
"status": "dead" if e.code in (404, 410) else "failed", "err": str(e)[:200]}
save_manifest()
if u not in failed_logged:
failed_logged.add(u)
log(f" [FAIL] {kind} {u} : {e}")
return None
except Exception as e:
with LOCK:
failed.append((u, str(e)))
manifest[u] = {"path": rel, "type": kind, "status": "failed", "err": str(e)[:200]}
save_manifest()
if u not in failed_logged:
failed_logged.add(u)
log(f" [FAIL] {kind} {u} : {e}")
return None
def css_urls(css, css_url):
def rep(m):
raw = m.group(1).strip().strip("'\"")
if raw.startswith(("data:", "javascript:", "#")):
return m.group(0)
if raw.startswith("//"):
absu = "http:" + raw
else:
absu = urljoin(css_url, raw)
rel = download_file(absu, "asset")
if rel:
from_dir = os.path.dirname(local_path(css_url, is_page=False))
rr = os.path.relpath(rel, from_dir).replace("\\", "/")
return f"url({rr})"
return m.group(0)
return re.sub(r"url\(\s*(.*?)\s*\)", rep, css, flags=re.I | re.S)
def process_page(u, data, ct):
html = decode(data, ct)
page_rel = local_path(u, is_page=True)
page_dir = os.path.dirname(page_rel)
# base 标签:用于解析相对链接,随后删除
base_for_resolve = u
m = re.search(r'