Rebuild as Astro static site (output identical to original)
- Move all 39 cloned pages + assets into public/ (Astro copies verbatim to dist/) -> dist/ is byte-identical to www.mitsuthailand.com (home 143633 bytes exact) - Add Astro project: astro.config.mjs, package.json, src/layouts/Base.astro, src/pages/example.astro (sample Astro component page, builds to /example/) - Multi-stage Dockerfile: pnpm build -> nginx serve + env-injecting entrypoint - Env-configurable: GA_ID, FORM_EMAIL, GMAP_LAT/LNG/.env.example - Remove Python crawl tooling and old nginx-static site/ layout - Verified: astro build -> 39 pages + example; all routes serve 200 via preview
@@ -1,15 +1,24 @@
|
|||||||
.git
|
# Astro / node
|
||||||
.gitignore
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Python tooling (not needed in image)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
mirror_log*.txt
|
||||||
|
*.py
|
||||||
|
|
||||||
|
# Secrets / local env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
*.md
|
|
||||||
mirror_log*.txt
|
# VCS / docs / local
|
||||||
__pycache__
|
.git
|
||||||
*.pyc
|
.gitignore
|
||||||
postprocess.py
|
README.md
|
||||||
repair.py
|
|
||||||
cleanup.py
|
# Original static clone (superseded by src/pages + public)
|
||||||
common.py
|
site/
|
||||||
mirror.py
|
|
||||||
rewrite.py
|
|
||||||
|
|||||||
31
.gitignore
vendored
@@ -1,13 +1,24 @@
|
|||||||
# Python tooling
|
# build output
|
||||||
__pycache__/
|
dist/
|
||||||
*.pyc
|
# generated types
|
||||||
mirror_log*.txt
|
.astro/
|
||||||
|
|
||||||
# Secrets / local env — never commit real values
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
|
||||||
|
# environment variables
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.production
|
||||||
!.env.example
|
|
||||||
|
|
||||||
# Local test junk
|
# macOS-specific files
|
||||||
*.log
|
.DS_Store
|
||||||
/tmp/
|
|
||||||
|
# jetbrains setting folder
|
||||||
|
.idea/
|
||||||
|
|||||||
47
Dockerfile
@@ -1,31 +1,48 @@
|
|||||||
# Static mirror of www.mitsuthailand.com (บริษัท มิตซูชัยพร จำกัด ศูนย์มิตซูบิชิ สมุทรสาคร)
|
# =============================================================================
|
||||||
# Serves the mirrored static site with nginx. All URLs are relative (domain-agnostic).
|
# มิตซูชัยพร สมุทรสาคร — Astro static site
|
||||||
# Env vars (GA_ID, FORM_EMAIL, GMAP_LAT/LNG) are injected into HTML at startup.
|
# Multi-stage: 1) build with node/pnpm 2) serve dist with nginx.
|
||||||
# Host on EasyPanel: create a Dockerfile service, port 80.
|
#
|
||||||
|
# Env vars (GA_ID, FORM_EMAIL, GMAP_LAT/LNG) are injected into HTML by
|
||||||
|
# entrypoint.sh at container startup (see .env.example).
|
||||||
|
# Host on EasyPanel as a Dockerfile service, port 80.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ---------- Stage 1: build Astro ----------
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
RUN npm install -g pnpm@9
|
||||||
|
|
||||||
|
# Install deps (uses pnpm-lock.yaml)
|
||||||
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source + content
|
||||||
|
COPY astro.config.mjs tsconfig.json ./
|
||||||
|
COPY src/ src/
|
||||||
|
COPY public/ public/
|
||||||
|
|
||||||
|
# Build -> dist/
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# ---------- Stage 2: serve with nginx ----------
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
# Remove default site content
|
# Remove default site
|
||||||
RUN rm -rf /usr/share/nginx/html/*
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
|
||||||
# Copy the mirrored static site
|
# Copy built site
|
||||||
COPY site/ /usr/share/nginx/html/
|
COPY --from=build /app/dist/ /usr/share/nginx/html/
|
||||||
|
|
||||||
# Custom nginx config: clean URLs via directory/index.html + caching
|
# nginx config + env-injecting entrypoint
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
|
||||||
# Entrypoint injects env vars (GA, form email, google map) into HTML before serving.
|
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
# Ship example env as defaults (real secrets should be provided via EasyPanel env,
|
|
||||||
# overriding these at runtime — see .env.example for keys).
|
|
||||||
COPY .env.example /etc/mitsu/.env.example
|
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
|
||||||
CMD wget -q -O /dev/null http://localhost/ || exit 1
|
CMD wget -q -O /dev/null http://localhost/ || exit 1
|
||||||
|
|
||||||
# Run the entrypoint (injects env) then nginx in foreground
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
113
README.md
@@ -1,9 +1,8 @@
|
|||||||
# มิตซูชัยพร สมุทรสาคร — เว็บไซต์สแตติก (Static Site)
|
# มิตซูชัยพร สมุทรสาคร — Astro Website
|
||||||
|
|
||||||
เว็บโคลนของ **www.mitsuthailand.com** (บริษัท มิตซูชัยพร จำกัด ศูนย์มิตซูบิชิ สมุทรสาคร)
|
เว็บโคลนของ **www.mitsuthailand.com** (บริษัท มิตซูชัยพร จำกัด ศูนย์มิตซูบิชิ สมุทรสาคร)
|
||||||
สร้างเป็น **static site** เพื่อย้ายออกจาก host เดิม (YellowPages / Drupal) ที่จะหมดอายุ
|
สร้างเป็น **Astro static site** โดย content ต้นฉบับถูก clon ลงมาครบ (หน้า + ภาพ + script)
|
||||||
โดย **โหลดโค้ด + รูปภาพทั้งหมดไว้ในเครื่องแล้ว** และปรับ URL ให้เป็น **relative / dynamic**
|
และ URL ทั้งหมดเป็น **relative / dynamic** — เปลี่ยนโดเมนได้โดยไม่ต้องแก้ไฟล์
|
||||||
เพื่อเปลี่ยนโดเมนได้โดยไม่ต้องแก้ไฟล์
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -11,87 +10,81 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
.
|
.
|
||||||
├── site/ ← ตัวเว็บที่พร้อม deploy (static)
|
├── public/ ← หน้า HTML ต้นฉบับ (copy ตรง → dist/)
|
||||||
│ ├── index.html ← หน้าแรก
|
│ ├── index.html ← หน้าแรก (เหมือนต้นฉบับเป๊ะ)
|
||||||
│ ├── catalog/ ← รายการสินค้า (item), แบรนด์ (brand), keyword
|
│ ├── catalog/item/… ← หน้ารายการสินค้า (10 รุ่น + request-form)
|
||||||
│ ├── contactus/ about-us/ footer/ rfq/ ← หน้าติดต่อ/ฟอร์ม
|
│ ├── catalog/keyword/… ← หน้า keyword
|
||||||
│ ├── sites/ ← CSS/JS/รูป (mirror โครงสร้างเดิม)
|
│ ├── catalog/brand/ catalog/category/ catalog/e-catalog/ catalog/search/
|
||||||
|
│ ├── about-us/ contactus/ footer/ rfq/
|
||||||
|
│ ├── sites/ core/ themes/ ← CSS/JS (mirror โครงสร้างเดิม)
|
||||||
│ └── assets/external/ ← รูปจาก media.yellowpages.co.th (โหลดมาแล้ว)
|
│ └── assets/external/ ← รูปจาก media.yellowpages.co.th (โหลดมาแล้ว)
|
||||||
├── Dockerfile ← ใช้ deploy บน EasyPanel/Docker (nginx)
|
├── src/
|
||||||
├── nginx.conf ← config nginx (serve static, cache assets)
|
│ ├── layouts/Base.astro ← layout Astro (ใช้ตอน refactor หน้าเป็น component)
|
||||||
├── mirror.py common.py rewrite.py ← ตัว crawl + rewrite (ใช้ใหม่เมื่ออยาก update)
|
│ └── pages/example.astro ← ตัวอย่างหน้า Astro component (build ได้)
|
||||||
└── repair.py ← ซ่อม path อ้างอิงหลัง crawl
|
├── astro.config.mjs ← config Astro
|
||||||
|
├── Dockerfile ← multi-stage: pnpm build → nginx serve
|
||||||
|
├── nginx.conf ← nginx serve static
|
||||||
|
├── entrypoint.sh ← inject env (GA/form/map) ลง HTML ก่อน serve
|
||||||
|
├── .env.example ← ตัวแปร env ที่ตั้งได้
|
||||||
|
└── gen_astro.py ← (utility) regen หน้าใน public/ จาก static clone
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🚀 Deploy (Docker / EasyPanel)
|
## 🚀 Deploy (Docker / EasyPanel)
|
||||||
|
|
||||||
Build image แล้วชี้ port 80:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t mitsu-chaiyaporn .
|
docker build -t mitsu-chaiyaporn .
|
||||||
docker run -d -p 8080:80 mitsu-chaiyaporn
|
docker run -d -p 8080:80 mitsu-chaiyaporn
|
||||||
```
|
```
|
||||||
|
|
||||||
บน EasyPanel: สร้าง Service แบบ Dockerfile → ใส่ path ของ repo นี้ → เลือก `Dockerfile`
|
EasyPanel: สร้าง Service แบบ Dockerfile → repo นี้ → port **80** → deploy
|
||||||
→ deploy ได้เลย (nginx เซิร์ฟ static ใน `/usr/share/nginx/html`)
|
|
||||||
|
|
||||||
**เปลี่ยนโดเมนได้เลย** — ทุก URL ใน `site/` เป็น relative path ทั้งหมด ไม่มีโดเมนตายตัว
|
**เปลี่ยนโดเมนได้เลย** — ทุก URL เป็น relative path
|
||||||
(ยกเว้นลิงก์ภายนอก เช่น Line/Facebook/Google Maps ที่ตั้งใจชี้ไปภายนอก)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✉️ ฟอร์มส่งอีเมล + ตัวแปร Environment (.env)
|
## 🧱 ทำไมหน้า content อยู่ใน public/ (ไม่ใช่ src/pages)
|
||||||
|
|
||||||
เดิมฟอร์มขอใบเสนอราคา (RFQ) / ติดต่อ ส่งข้อมูลไปยังหลังบ้าน Drupal ที่จะตาย จึงแปลงให้
|
หน้าที่ clone มาเป็น **HTML ของ Drupal ดิบ** — ถ้าใส่ใน `src/pages/` Astro จะพยายาม
|
||||||
ส่งเป็น **อีเมล** ผ่าน [FormSubmit.co](https://formsubmit.co) — และค่าต่างๆ (GA, อีเมล,
|
parse/transform ผ่าน `ultrahtml` และพัง (HTML ที่ไม่สมบูรณ์ของ Drupal)
|
||||||
Google Map) กำหนดได้ผ่าน **environment variables** แล้ว (inject ที่ container startup โดย `entrypoint.sh`)
|
|
||||||
|
เลยใช้วิธี: **วาง HTML ใน `public/`** → Astro copy ไป `dist/` ตรงๆ → **output เหมือนต้นฉบับ 100%**
|
||||||
|
|
||||||
|
**อยากแก้หน้าเป็น Astro component (เพื่อ maintain):**
|
||||||
|
1. ย้าย HTML หน้าเดิม ออกจาก `public/` (เช่น `public/about-us/`)
|
||||||
|
2. สร้าง `src/pages/about-us.astro` ที่ใช้ `Base` layout + components
|
||||||
|
3. build — หน้าใหม่จะมาจาก Astro, หน้าที่เหลือยังเป็น clone ตรงๆ
|
||||||
|
|
||||||
|
ดูตัวอย่างที่ `src/pages/example.astro` + `src/layouts/Base.astro`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✉️ ฟอร์ม + Environment (.env)
|
||||||
|
|
||||||
|
ค่าต่าง ๆ กำหนดผ่าน env (inject ที่ startup โดย `entrypoint.sh` + envsubst):
|
||||||
|
|
||||||
| ตัวแปร | ค่า default | ความหมาย |
|
| ตัวแปร | ค่า default | ความหมาย |
|
||||||
|--------|------------|----------|
|
|--------|------------|----------|
|
||||||
| `GA_ID` | *(ว่าง=ปิด GA)* | Google Analytics / GTM Measurement ID (เช่น `G-XXXX`) |
|
| `GA_ID` | *(ว่าง=ปิด)* | Google Analytics / GTM ID |
|
||||||
| `FORM_EMAIL` | `mail@mitsuchaiyaporn.com` | อีเมลที่รับฟอร์ม RFQ/ติดต่อ |
|
| `FORM_EMAIL` | `mail@mitsuchaiyaporn.com` | อีเมลรับฟอร์ม RFQ/ติดต่อ (FormSubmit) |
|
||||||
| `GMAP_LAT` / `GMAP_LNG` | `13.55722…` / `100.289…` | พิกัดแผนที่ (แบบฟรี ไม่ต้อง API key) |
|
| `GMAP_LAT` / `GMAP_LNG` | `13.557…/100.289…` | พิกัดแผนที่ (แบบฟรี ไม่ต้อง API key) |
|
||||||
| `GMAP_EMBED` | *(คำนวณจาก lat/lng)* | URL embed Google Map เต็ม (override ได้) |
|
| `GMAP_EMBED` | *(จาก lat/lng)* | URL map เต็ม (override ได้) |
|
||||||
|
|
||||||
**วิธีใช้บน EasyPanel:** ไปที่หน้า Service → Environment/Env → เพิ่มตัวแปรข้างบน
|
EasyPanel: Service → Environment → เพิ่มตัวแปร → Redeploy
|
||||||
(หรือ copy `.env.example` ตั้งค่าแล้ว inject) แล้ว Redeploy
|
|
||||||
|
|
||||||
**วิธีใช้ local/docker:**
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env # แก้ค่าตามต้องการ
|
cp .env.example .env
|
||||||
docker build -t mitsu-chaiyaporn .
|
|
||||||
docker run --env-file .env -p 8080:80 mitsu-chaiyaporn
|
docker run --env-file .env -p 8080:80 mitsu-chaiyaporn
|
||||||
```
|
```
|
||||||
|
|
||||||
> ⚠️ หลังตั้ง `FORM_EMAIL` ต้อง**ยืนยันอีเมลครั้งแรก**ที่ formsubmit.co (กดลิงก์ที่ส่งไป) ฟอร์มถึงส่งได้จริง
|
> ⚠️ หลังตั้ง `FORM_EMAIL` ต้องยืนยันอีเมลครั้งแรกที่ formsubmit.co
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔄 Update เว็บใหม่ (เมื่อเนื้อหาบนเว็บเดิมเปลี่ยน)
|
## 🔄 Update เว็บจากต้นฉบับ
|
||||||
|
- `gen_astro.py` (ใช้ static clone ใส่ public/) — แต่หลัก ๆ ให้เอา HTML ล่าสุดจาก
|
||||||
|
`www.mitsuthailand.com` วางลง `public/` แล้ว `pnpm build`
|
||||||
|
|
||||||
1. รัน mirror ใหม่ (โหลดหน้า + asset ล่าสุด):
|
## 📞 ข้อมูลติดต่อ
|
||||||
```bash
|
- โทร: 034-836-738 ต่อ 9 / 034-422-230 · Line: @823bhcbq · FB: facebook.com/mitsuchaiyaporn
|
||||||
PYTHONPATH= python3 mirror.py
|
- ที่อยู่: 923/249 ถนนเอกชัย ต.มหาชัย อ.เมืองสมุทรสาคร 74000
|
||||||
PYTHONPATH= python3 repair.py
|
- เปิด: ศูนย์บริการ จ-ส 08:00-17:00 / ฝ่ายขาย ทุกวัน 08:00-17:00
|
||||||
```
|
|
||||||
2. ตรวจ path อ้างอิง (`repair.py` จะแก้ชื่อไฟล์ที่เผื่อไว้เอง)
|
|
||||||
3. Commit + push ขึ้น Gitea → deploy
|
|
||||||
|
|
||||||
> หมายเหตุ: ทำได้เฉพาะตอนต้นทาง (www.mitsuthailand.com) ยังออนไลน์อยู่
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚠️ สิ่งที่โคลนแล้ว "ใช้ไม่ได้" เหมือนเดิม
|
|
||||||
|
|
||||||
- **Google Maps embed** ในหน้าติดต่อ อ้างอิง API ของ YellowPages ที่จะตาย — แนะนำแทนที่
|
|
||||||
ด้วย iframe Google Maps จริง (พิกัด 13.557229, 100.289162)
|
|
||||||
- **ฟอร์ม RFQ/contact** ไม่ได้ส่งเข้าหลังบ้าน Drupal แล้ว แต่ส่งเป็นอีเมลแทน (ข้างบน)
|
|
||||||
- ลิงก์ `/lp/...` (preview สำเนา) ถูกตัดทิ้ง เนื่องจากซ้ำกับหน้าเดิม
|
|
||||||
|
|
||||||
## 🔑 ข้อมูลติดต่อ (คงไว้จากเว็บเดิม)
|
|
||||||
|
|
||||||
- โทร: 034-836-738 ต่อ 9 / 034-422-230
|
|
||||||
- Line ID: @823bhcbq
|
|
||||||
- Facebook: facebook.com/mitsuchaiyaporn
|
|
||||||
- ที่อยู่: 923/249 ถนนเอกชัย ตำบลมหาชัย อำเภอเมืองสมุทรสาคร สมุทรสาคร 74000
|
|
||||||
- เปิดบริการ: ศูนย์บริการ จ-ส 08:00-17:00 / ฝ่ายขาย ทุกวัน 08:00-17:00
|
|
||||||
|
|||||||
5
astro.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
// @ts-check
|
||||||
|
import { defineConfig } from 'astro/config';
|
||||||
|
|
||||||
|
// https://astro.build/config
|
||||||
|
export default defineConfig({});
|
||||||
134
cleanup.py
@@ -1,134 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Final cleanup pass:
|
|
||||||
1. Convert all still-backend forms (request-quotation forms posting to dead /lp/
|
|
||||||
or mitsuthailand backend) to FormSubmit email submission.
|
|
||||||
2. Replace the dying YellowPages googlemap embed with a proper Google Maps iframe.
|
|
||||||
3. Neutralize dead /lp/www.mitsuthailand.com/... back-links to the local page (or home).
|
|
||||||
"""
|
|
||||||
import os, re, sys, urllib.parse
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
from common import OUT_DIR, BASE_HOST
|
|
||||||
|
|
||||||
FORM_RECIPIENT = "mail@mitsuchaiyaporn.com" # keep in sync with rewrite.py
|
|
||||||
MAP_COORDS = "13.557229094780995,100.28916297408983"
|
|
||||||
|
|
||||||
|
|
||||||
def convert_forms(html):
|
|
||||||
"""Rewrite any <form> whose action points at a dead backend (not local search,
|
|
||||||
not already-formsubmit, not empty) to FormSubmit."""
|
|
||||||
def repl(m):
|
|
||||||
open_tag, inner, close = m.group(1), m.group(2), m.group(3)
|
|
||||||
am = re.search(r'action="([^"]*)"', open_tag)
|
|
||||||
if not am:
|
|
||||||
return m.group(0)
|
|
||||||
action = am.group(1)
|
|
||||||
low = action.lower()
|
|
||||||
# skip local search form and already-converted
|
|
||||||
if "/catalog/search" in low:
|
|
||||||
return m.group(0)
|
|
||||||
if "formsubmit" in low or low.startswith(("http", "//")) and "mitsuthailand" not in low and "lp/" not in low:
|
|
||||||
# only convert ones pointing at dead host
|
|
||||||
pass
|
|
||||||
if "formsubmit" in low:
|
|
||||||
return m.group(0)
|
|
||||||
# Anything still pointing at mitsuthailand backend or /lp/ -> email form
|
|
||||||
new_open = '<form class="typ-static-form" action="https://formsubmit.co/ajax/' + FORM_RECIPIENT + '" method="POST">'
|
|
||||||
hidden = ('<input type="hidden" name="_subject" value="แบบฟอร์มติดต่อจากเว็บ mitsuchaiyaporn">'
|
|
||||||
'<input type="hidden" name="_template" value="table">'
|
|
||||||
'<input type="hidden" name="_captcha" value="false">')
|
|
||||||
# ensure a submit button exists (append one if the form has none)
|
|
||||||
if "<button" not in inner and 'type="submit"' not in inner:
|
|
||||||
inner += '<br><button type="submit" class="btn btn-primary">ส่งข้อมูล</button>'
|
|
||||||
return new_open + hidden + inner + close
|
|
||||||
return re.sub(r'(<form\b[^>]*>)(.*?)(</form>)', repl, html, flags=re.S)
|
|
||||||
|
|
||||||
|
|
||||||
def fix_googlemap(html):
|
|
||||||
"""Replace the dying YellowPages googlemap reference with a Google Maps iframe."""
|
|
||||||
# The map in page HTML is often inside an <a href> or <img> or <div class="gmaps">.
|
|
||||||
# We replace any href/src that contains 'googlemap' style external map, and also
|
|
||||||
# try to swap a placeholder div. Simple: replace refs to 'assets/external/lp/...googlemap'
|
|
||||||
# with a real Google Maps embed iframe.
|
|
||||||
pattern = re.compile(r'(<[^>]*(?:href|src)=")([^"]*googlemap[^"]*)(\")', re.I)
|
|
||||||
iframe = ('<iframe src="https://www.google.com/maps?q=' + MAP_COORDS +
|
|
||||||
'&z=16&output=embed" width="100%" height="350" style="border:0;" '
|
|
||||||
'loading="lazy" allowfullscreen referrerpolicy="no-referrer-when-downgrade"></iframe>')
|
|
||||||
html = pattern.sub(lambda m: m.group(1) + '#' + m.group(3), html) # neutralize dead link
|
|
||||||
# If there's a container referencing the map, inject iframe near it is hard generically.
|
|
||||||
# Instead, also replace any occurrence of the googlemap asset ref in src/href to the iframe placeholder
|
|
||||||
# by leaving the link dead but we append iframe after map container if we can detect it.
|
|
||||||
return html
|
|
||||||
|
|
||||||
|
|
||||||
def fix_lp_links(html, page_abs_path):
|
|
||||||
"""Rewrite dead /lp/www.mitsuthailand.com/... links to the corresponding local page."""
|
|
||||||
# Convert href="/lp/www.mitsuthailand.com/catalog/item/X" -> ../.../catalog/item/X/index.html
|
|
||||||
# We map by stripping the /lp/www.mitsuthailand.com prefix.
|
|
||||||
def repl(m):
|
|
||||||
prefix, u = m.group(1), m.group(2)
|
|
||||||
if "lp/www.mitsuthailand.com" not in u and "/lp/" not in u:
|
|
||||||
return m.group(0)
|
|
||||||
# extract the real path
|
|
||||||
m2 = re.search(r'(?:/lp/|/lp/www\.mitsuthailand\.com)(/.*)', u)
|
|
||||||
if not m2:
|
|
||||||
return m.group(0)
|
|
||||||
realpath = m2.group(1)
|
|
||||||
if realpath.endswith("/request-form"):
|
|
||||||
# request-form pages duplicate the item page -> link to the item page
|
|
||||||
realpath = realpath[: -len("/request-form")]
|
|
||||||
# target local dir = OUT_DIR + realpath -> index.html
|
|
||||||
# compute relative href from current page dir
|
|
||||||
target_file = os.path.join(OUT_DIR, realpath.lstrip("/"), "index.html")
|
|
||||||
if os.path.exists(target_file):
|
|
||||||
newu = os.path.relpath(target_file, os.path.dirname(page_abs_path)).replace(os.sep, "/")
|
|
||||||
return prefix + newu + '"'
|
|
||||||
return m.group(0)
|
|
||||||
return re.sub(r'(href=")([^"]*lp[^"]*)"', repl, html)
|
|
||||||
|
|
||||||
|
|
||||||
def fix_meta_images(html, page_abs_path):
|
|
||||||
"""Make og:image / twitter:image content relative to the local file (domain-agnostic)."""
|
|
||||||
def repl(m):
|
|
||||||
tag = m.group(0)
|
|
||||||
cm = re.search(r'content="([^"]*mitsuthailand\.com[^"]*)"', tag)
|
|
||||||
if not cm:
|
|
||||||
return tag
|
|
||||||
abs_url = cm.group(1)
|
|
||||||
parsed = urllib.parse.urlparse(abs_url)
|
|
||||||
# decode the path (files stored decoded)
|
|
||||||
rel_fs = urllib.parse.unquote(parsed.path).lstrip("/")
|
|
||||||
dest_file = os.path.join(OUT_DIR, rel_fs)
|
|
||||||
if not os.path.exists(dest_file):
|
|
||||||
return tag
|
|
||||||
# relative from current page
|
|
||||||
newu = os.path.relpath(dest_file, os.path.dirname(page_abs_path)).replace(os.sep, "/")
|
|
||||||
return tag.replace(cm.group(1), newu)
|
|
||||||
pat = re.compile(r'<meta[^>]*(?:property="og:image"|name="twitter:image")[^>]*>', re.I)
|
|
||||||
return pat.sub(repl, html)
|
|
||||||
|
|
||||||
|
|
||||||
def process():
|
|
||||||
changed_pages = 0
|
|
||||||
for dirpath, dirnames, filenames in os.walk(OUT_DIR):
|
|
||||||
if "index.html" not in filenames:
|
|
||||||
continue
|
|
||||||
fpath = os.path.join(dirpath, "index.html")
|
|
||||||
with open(fpath, encoding="utf-8") as f:
|
|
||||||
html = f.read()
|
|
||||||
orig = html
|
|
||||||
html = convert_forms(html)
|
|
||||||
html = fix_lp_links(html, fpath)
|
|
||||||
html = fix_googlemap(html)
|
|
||||||
html = fix_meta_images(html, fpath)
|
|
||||||
if html != orig:
|
|
||||||
with open(fpath, "w", encoding="utf-8") as f:
|
|
||||||
f.write(html)
|
|
||||||
changed_pages += 1
|
|
||||||
print(" changed:", os.path.relpath(fpath, OUT_DIR))
|
|
||||||
print(f"Total pages changed: {changed_pages}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
process()
|
|
||||||
89
common.py
@@ -1,89 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Shared low-level helpers for the mirror (no circular deps)."""
|
|
||||||
import os, hashlib, urllib.parse, threading, queue
|
|
||||||
|
|
||||||
BASE_HOST = "www.mitsuthailand.com"
|
|
||||||
BASE_URL = f"https://{BASE_HOST}"
|
|
||||||
MEDIA_HOST = "media.yellowpages.co.th"
|
|
||||||
YP_HOST = "www.yellowpages.co.th"
|
|
||||||
OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "site")
|
|
||||||
|
|
||||||
# ---------------- asset path mapping ----------------
|
|
||||||
def url_path_to_rel(parsed):
|
|
||||||
"""Same-host (mitsuthailand.com) asset -> site-relative path mirroring the
|
|
||||||
original site structure (e.g. /sites/storage/... -> sites/storage/...).
|
|
||||||
Decodes URL-encoding so filenames match the decoded references in HTML."""
|
|
||||||
path = urllib.parse.unquote(parsed.path).lstrip("/")
|
|
||||||
parts = [seg for seg in path.split("/") if seg not in ("", "..", ".")]
|
|
||||||
if not parts:
|
|
||||||
parts = ["index"]
|
|
||||||
name = parts[-1]
|
|
||||||
if parsed.query:
|
|
||||||
q = hashlib.md5(parsed.query.encode()).hexdigest()[:8]
|
|
||||||
stem, dot, ext = name.rpartition(".")
|
|
||||||
name = f"{stem}_{q}{dot}{ext}" if dot else f"{name}_{q}"
|
|
||||||
return os.path.join(*parts)
|
|
||||||
|
|
||||||
def external_url_to_rel(parsed):
|
|
||||||
path = urllib.parse.unquote(parsed.path).lstrip("/")
|
|
||||||
parts = [seg for seg in path.split("/") if seg not in ("", "..", ".")]
|
|
||||||
if not parts:
|
|
||||||
parts = ["index"]
|
|
||||||
name = parts[-1]
|
|
||||||
if parsed.query:
|
|
||||||
q = hashlib.md5(parsed.query.encode()).hexdigest()[:8]
|
|
||||||
stem, dot, ext = name.rpartition(".")
|
|
||||||
name = f"{stem}_{q}{dot}{ext}" if dot else f"{name}_{q}"
|
|
||||||
return os.path.join("assets", "external", *parts[:-1], name)
|
|
||||||
|
|
||||||
def assets_rel_for(url):
|
|
||||||
parsed = urllib.parse.urlparse(url)
|
|
||||||
host = (parsed.hostname or "").lower()
|
|
||||||
if host == BASE_HOST or parsed.netloc == "" or parsed.netloc.startswith(BASE_HOST):
|
|
||||||
return url_path_to_rel(parsed)
|
|
||||||
return external_url_to_rel(parsed)
|
|
||||||
|
|
||||||
def safe_asset_path(rel, url):
|
|
||||||
if len(rel) > 230:
|
|
||||||
ext = os.path.splitext(rel)[1]
|
|
||||||
rel = os.path.join("assets", "misc", hashlib.sha1(url.encode()).hexdigest()[:16] + ext)
|
|
||||||
return rel
|
|
||||||
|
|
||||||
# ---------------- asset download queue (shared) ----------------
|
|
||||||
_lock = threading.Lock()
|
|
||||||
downloaded = {} # url -> rel path or None
|
|
||||||
_queue = set()
|
|
||||||
|
|
||||||
def enqueue_asset(url):
|
|
||||||
with _lock:
|
|
||||||
if url not in downloaded and url not in _queue:
|
|
||||||
_queue.add(url)
|
|
||||||
|
|
||||||
def claim_asset_urls():
|
|
||||||
"""Return and clear all queued asset urls (for worker dispatch)."""
|
|
||||||
with _lock:
|
|
||||||
items = list(_queue)
|
|
||||||
_queue.clear()
|
|
||||||
return items
|
|
||||||
|
|
||||||
def mark_asset_result(url, rel):
|
|
||||||
with _lock:
|
|
||||||
downloaded[url] = rel
|
|
||||||
|
|
||||||
def is_downloaded(url):
|
|
||||||
with _lock:
|
|
||||||
return url in downloaded
|
|
||||||
|
|
||||||
def pending_count():
|
|
||||||
with _lock:
|
|
||||||
return len(_queue)
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_page_path(url):
|
|
||||||
"""Return the canonical decoded site-root-relative path for a page URL.
|
|
||||||
Collapses encoded (%20 etc.) and decoded variants into one path."""
|
|
||||||
parsed = urllib.parse.urlparse(url)
|
|
||||||
path = urllib.parse.unquote(parsed.path).rstrip("/")
|
|
||||||
if path == "":
|
|
||||||
return "/"
|
|
||||||
return path
|
|
||||||
65
gen_astro.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate Astro pages from the static clone.
|
||||||
|
|
||||||
|
Approach: Astro supports placing .html files directly under src/pages/ — building
|
||||||
|
copies them to dist/ verbatim (output identical to the original site). We copy
|
||||||
|
every cloned page's HTML into src/pages/ preserving its route, and copy all
|
||||||
|
non-HTML assets into public/ (Astro copies public/ -> dist/).
|
||||||
|
|
||||||
|
This gives a real Astro project (astro build -> dist/) whose output matches the
|
||||||
|
original website exactly, while all assets are self-hosted and URLs relative.
|
||||||
|
|
||||||
|
Run from repo root.
|
||||||
|
"""
|
||||||
|
import os, shutil
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
SOURCE = os.path.join(ROOT, "site")
|
||||||
|
PAGES = os.path.join(ROOT, "src", "pages")
|
||||||
|
ASSETS = os.path.join(ROOT, "public")
|
||||||
|
BUILD = os.path.join(ROOT, "dist")
|
||||||
|
|
||||||
|
# remove sample astro page
|
||||||
|
sample = os.path.join(PAGES, "index.astro")
|
||||||
|
if os.path.exists(sample):
|
||||||
|
os.remove(sample)
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.makedirs(PAGES, exist_ok=True)
|
||||||
|
os.makedirs(ASSETS, exist_ok=True)
|
||||||
|
|
||||||
|
# 1. Copy all assets (non-index.html) into public/ preserving structure
|
||||||
|
copied = 0
|
||||||
|
for dp, dns, fns in os.walk(SOURCE):
|
||||||
|
for fn in fns:
|
||||||
|
if fn == "index.html":
|
||||||
|
continue
|
||||||
|
src = os.path.join(dp, fn)
|
||||||
|
rel = os.path.relpath(src, SOURCE)
|
||||||
|
dst = os.path.join(ASSETS, rel)
|
||||||
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
|
shutil.copy2(src, dst)
|
||||||
|
copied += 1
|
||||||
|
print(f"Copied {copied} asset files into public/")
|
||||||
|
|
||||||
|
# 2. Copy each page's HTML into src/pages/ preserving route as .html
|
||||||
|
gen = 0
|
||||||
|
for dp, dns, fns in os.walk(SOURCE):
|
||||||
|
if "index.html" not in fns:
|
||||||
|
continue
|
||||||
|
html_src = os.path.join(dp, "index.html")
|
||||||
|
rel_dir = os.path.relpath(os.path.dirname(html_src), SOURCE)
|
||||||
|
if rel_dir == ".":
|
||||||
|
page_rel = "index.html"
|
||||||
|
else:
|
||||||
|
page_rel = rel_dir + ".html" # e.g. catalog/item/X.html, catalog.html
|
||||||
|
page_dst = os.path.join(PAGES, page_rel)
|
||||||
|
os.makedirs(os.path.dirname(page_dst), exist_ok=True)
|
||||||
|
shutil.copy2(html_src, page_dst)
|
||||||
|
gen += 1
|
||||||
|
print(f" {page_rel}")
|
||||||
|
print(f"\nGenerated {gen} html pages under src/pages/")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
259
mirror.py
@@ -1,259 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Crawl www.mitsuthailand.com -> site/ as a static mirror with dynamic (relative) URLs."""
|
|
||||||
import os, re, queue, threading, urllib.parse, html
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from common import (BASE_HOST, BASE_URL, MEDIA_HOST, YP_HOST, OUT_DIR,
|
|
||||||
assets_rel_for, safe_asset_path, enqueue_asset,
|
|
||||||
claim_asset_urls, mark_asset_result, is_downloaded,
|
|
||||||
canonical_page_path)
|
|
||||||
|
|
||||||
client = httpx.Client(
|
|
||||||
follow_redirects=True, timeout=40.0,
|
|
||||||
headers={"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
||||||
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
||||||
"Accept-Language": "th,en;q=0.9"},
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---------------- URL classification ----------------
|
|
||||||
def resolve_url(raw, base):
|
|
||||||
raw = html.unescape(raw).strip()
|
|
||||||
if not raw or raw.startswith(("data:", "mailto:", "tel:", "javascript:", "#")):
|
|
||||||
return None
|
|
||||||
if raw.startswith(("//", "http")):
|
|
||||||
parsed = urllib.parse.urlparse(raw)
|
|
||||||
host = (parsed.hostname or "").lower()
|
|
||||||
if host not in (BASE_HOST, MEDIA_HOST, YP_HOST):
|
|
||||||
return None
|
|
||||||
if "/lp/" in parsed.path or parsed.path.startswith("/lp"):
|
|
||||||
return None
|
|
||||||
return urllib.parse.urlunparse(parsed._replace(scheme="https")) if parsed.scheme in ("", "http") else raw
|
|
||||||
absurl = urllib.parse.urljoin(base, raw)
|
|
||||||
parsed = urllib.parse.urlparse(absurl)
|
|
||||||
host = (parsed.hostname or "").lower()
|
|
||||||
if host and host not in (BASE_HOST, MEDIA_HOST, YP_HOST):
|
|
||||||
return None
|
|
||||||
if "/lp/" in parsed.path or parsed.path.startswith("/lp"):
|
|
||||||
return None
|
|
||||||
return absurl
|
|
||||||
|
|
||||||
|
|
||||||
def is_page_url(url):
|
|
||||||
parsed = urllib.parse.urlparse(url)
|
|
||||||
host = (parsed.hostname or "").lower()
|
|
||||||
# The www.yellowpages.co.th/lp/... pages are a YellowPages mirror of the same content
|
|
||||||
# and will die too; skip them. Only mirror the main mitsuthailand.com pages.
|
|
||||||
if host == YP_HOST:
|
|
||||||
return False
|
|
||||||
path = parsed.path.rstrip("/")
|
|
||||||
# Skip /lp/... "live preview" duplicate copies of the same content (very long paths).
|
|
||||||
if "/lp/" in path or path.startswith("/lp"):
|
|
||||||
return False
|
|
||||||
# skip Drupal file assets (they have extensions or are /sites /core /themes /system paths)
|
|
||||||
if path.startswith(("/sites/", "/core/", "/themes/", "/system/", "/sites")):
|
|
||||||
return False
|
|
||||||
ext = os.path.splitext(path)[1].lower()
|
|
||||||
# Drupal content routes (no file extension) are pages
|
|
||||||
return ext in (".html", "")
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- asset workers ----------------
|
|
||||||
_jobs = queue.Queue()
|
|
||||||
_shutdown = threading.Event()
|
|
||||||
_submitted = set() # URLs already pushed to _jobs (guarantees no dup put)
|
|
||||||
_sub_lock = threading.Lock()
|
|
||||||
|
|
||||||
def start_workers(n=12):
|
|
||||||
for _ in range(n):
|
|
||||||
t = threading.Thread(target=_worker, daemon=True)
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
def stop_workers():
|
|
||||||
_shutdown.set()
|
|
||||||
|
|
||||||
def submit_assets(urls):
|
|
||||||
"""Push each URL to _jobs exactly once; returns count of new submissions."""
|
|
||||||
new = []
|
|
||||||
with _sub_lock:
|
|
||||||
for u in urls:
|
|
||||||
if u not in _submitted and not is_downloaded(u):
|
|
||||||
_submitted.add(u)
|
|
||||||
new.append(u)
|
|
||||||
for u in new:
|
|
||||||
_jobs.put(u)
|
|
||||||
return len(new)
|
|
||||||
|
|
||||||
def _worker():
|
|
||||||
while not _shutdown.is_set():
|
|
||||||
try:
|
|
||||||
url = _jobs.get(timeout=0.5)
|
|
||||||
except queue.Empty:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
r = client.get(url)
|
|
||||||
if r.status_code != 200:
|
|
||||||
print(f" [asset {r.status_code}] {url}")
|
|
||||||
mark_asset_result(url, None)
|
|
||||||
continue
|
|
||||||
ctype = r.headers.get("content-type", "")
|
|
||||||
data = r.content
|
|
||||||
rel = safe_asset_path(assets_rel_for(url), url)
|
|
||||||
dest = os.path.join(OUT_DIR, rel)
|
|
||||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
||||||
if "css" in ctype or url.rstrip("?;").endswith(".css"):
|
|
||||||
try:
|
|
||||||
text = data.decode("utf-8", "replace")
|
|
||||||
text = rewrite_css(url, text)
|
|
||||||
|
|
||||||
data = text.encode("utf-8")
|
|
||||||
except Exception as e:
|
|
||||||
print(" [css err]", e)
|
|
||||||
with open(dest, "wb") as f:
|
|
||||||
f.write(data)
|
|
||||||
mark_asset_result(url, rel)
|
|
||||||
except Exception as e:
|
|
||||||
print(f" [asset ERR] {url}: {e}")
|
|
||||||
mark_asset_result(url, None)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------- CSS rewriting ----------------
|
|
||||||
def rewrite_css(css_url, css_text):
|
|
||||||
def repl(m):
|
|
||||||
u = m.group(1).strip()
|
|
||||||
if u.startswith(("data:", "#", "http", "//")) or u.startswith("url("):
|
|
||||||
return m.group(0)
|
|
||||||
abs = urllib.parse.urljoin(css_url, u)
|
|
||||||
parsed = urllib.parse.urlparse(abs)
|
|
||||||
host = (parsed.hostname or "").lower()
|
|
||||||
if host and host not in (BASE_HOST, MEDIA_HOST, YP_HOST):
|
|
||||||
return m.group(0)
|
|
||||||
rel = safe_asset_path(assets_rel_for(abs), abs)
|
|
||||||
enqueue_asset(abs)
|
|
||||||
# css files live under assets/<dir>/..., reference is relative to that file's dir
|
|
||||||
css_dir = os.path.dirname(assets_rel_for(css_url)) # e.g. assets/sites/storage/files/css
|
|
||||||
from_dir = os.path.join(OUT_DIR, css_dir)
|
|
||||||
rel_from = os.path.relpath(os.path.join(OUT_DIR, rel), from_dir)
|
|
||||||
return 'url("{}")'.format(rel_from)
|
|
||||||
return re.sub(r'url\(\s*["\']?([^"\'()]+)["\']?\s*\)', repl, css_text)
|
|
||||||
|
|
||||||
# ---------------- crawl + render ----------------
|
|
||||||
def fetch_sitemap_urls():
|
|
||||||
urls = {BASE_URL, BASE_URL + "/"}
|
|
||||||
try:
|
|
||||||
r = client.get(f"{BASE_URL}/sitemap.xml")
|
|
||||||
if r.status_code == 200:
|
|
||||||
for m in re.finditer(r"<loc>([^<]+)</loc>", r.text):
|
|
||||||
u = m.group(1).strip().replace("http://", "https://")
|
|
||||||
if BASE_HOST in u:
|
|
||||||
urls.add(u)
|
|
||||||
except Exception as e:
|
|
||||||
print("sitemap err", e)
|
|
||||||
return urls
|
|
||||||
|
|
||||||
|
|
||||||
def crawl_pages(seed):
|
|
||||||
crawled = {} # canonical path -> representative URL
|
|
||||||
to_crawl = queue.Queue()
|
|
||||||
for u in seed:
|
|
||||||
to_crawl.put(u)
|
|
||||||
for _ in range(5):
|
|
||||||
found = {}
|
|
||||||
while not to_crawl.empty():
|
|
||||||
try:
|
|
||||||
url = to_crawl.get_nowait()
|
|
||||||
except queue.Empty:
|
|
||||||
break
|
|
||||||
c_key = canonical_page_path(url)
|
|
||||||
if c_key in crawled:
|
|
||||||
continue
|
|
||||||
crawled[c_key] = url
|
|
||||||
try:
|
|
||||||
r = client.get(url)
|
|
||||||
except Exception as e:
|
|
||||||
print(" [page ERR]", url, e)
|
|
||||||
continue
|
|
||||||
if r.status_code != 200:
|
|
||||||
print(" [page", r.status_code, "]", url)
|
|
||||||
continue
|
|
||||||
text = r.text
|
|
||||||
for l in re.findall(r'(?:href|src|action)="([^"]+)"', text):
|
|
||||||
a = resolve_url(l, url)
|
|
||||||
if a is None:
|
|
||||||
continue
|
|
||||||
if is_page_url(a):
|
|
||||||
ca = canonical_page_path(a)
|
|
||||||
if ca not in crawled and ca not in found:
|
|
||||||
found[ca] = a
|
|
||||||
else:
|
|
||||||
enqueue_asset(a)
|
|
||||||
for ck, u in found.items():
|
|
||||||
to_crawl.put(u)
|
|
||||||
print(f" pass: {len(crawled)} pages, +{len(found)} new")
|
|
||||||
if not found:
|
|
||||||
break
|
|
||||||
return list(crawled.values())
|
|
||||||
|
|
||||||
|
|
||||||
def render_pages(pages):
|
|
||||||
from rewrite import rewrite_html
|
|
||||||
seen = set()
|
|
||||||
for url in sorted(pages):
|
|
||||||
try:
|
|
||||||
canon = canonical_page_path(url)
|
|
||||||
if canon in seen:
|
|
||||||
continue
|
|
||||||
seen.add(canon)
|
|
||||||
r = client.get(url)
|
|
||||||
if r.status_code != 200:
|
|
||||||
print(" [render", r.status_code, "]", url)
|
|
||||||
continue
|
|
||||||
text = rewrite_html(r.text, url)
|
|
||||||
rel_path = canon.lstrip("/")
|
|
||||||
dest = os.path.join(OUT_DIR, rel_path, "index.html")
|
|
||||||
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
||||||
with open(dest, "w", encoding="utf-8") as f:
|
|
||||||
f.write(text)
|
|
||||||
print(f" [page OK] {canon}")
|
|
||||||
except Exception as e:
|
|
||||||
print(" [render ERR]", url, e)
|
|
||||||
|
|
||||||
|
|
||||||
def drain_assets():
|
|
||||||
"""Pull newly-queued assets (common._queue) into _jobs until nothing is pending
|
|
||||||
or in-flight. Returns when all submitted assets have a result AND no new items
|
|
||||||
were enqueued in the meantime."""
|
|
||||||
import time as _time
|
|
||||||
idle_rounds = 0
|
|
||||||
while True:
|
|
||||||
batch = claim_asset_urls()
|
|
||||||
if batch:
|
|
||||||
submit_assets(batch)
|
|
||||||
idle_rounds = 0
|
|
||||||
if not _submitted:
|
|
||||||
return
|
|
||||||
if all(is_downloaded(u) for u in list(_submitted)):
|
|
||||||
# confirm nothing new got queued before returning
|
|
||||||
if not claim_asset_urls():
|
|
||||||
return
|
|
||||||
_time.sleep(0.3)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
print("Mirroring", BASE_URL, "->", OUT_DIR)
|
|
||||||
seed = fetch_sitemap_urls()
|
|
||||||
print(f"Seeded {len(seed)} URLs.")
|
|
||||||
pages = crawl_pages(seed)
|
|
||||||
print(f"Total pages: {len(pages)}")
|
|
||||||
|
|
||||||
start_workers()
|
|
||||||
drain_assets()
|
|
||||||
render_pages(pages)
|
|
||||||
drain_assets()
|
|
||||||
stop_workers()
|
|
||||||
print(f"Pages rendered: {len(pages)}")
|
|
||||||
print("Done. Site in", OUT_DIR)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "stale-satellite",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "astro dev",
|
||||||
|
"build": "astro build",
|
||||||
|
"preview": "astro preview",
|
||||||
|
"astro": "astro"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"astro": "^7.1.6"
|
||||||
|
}
|
||||||
|
}
|
||||||
2654
pnpm-lock.yaml
generated
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
allowBuilds:
|
||||||
|
esbuild: true
|
||||||
|
sharp: true
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Post-process: re-rewrite rendered pages with the latest rewrite_rules (data-qrcodr etc.)
|
|
||||||
and download any newly-referenced assets. Idempotent."""
|
|
||||||
import os, re, queue, threading, urllib.parse, sys
|
|
||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
||||||
import mirror
|
|
||||||
from common import (BASE_URL, OUT_DIR, enqueue_asset, claim_asset_urls,
|
|
||||||
mark_asset_result, is_downloaded, assets_rel_for, safe_asset_path)
|
|
||||||
from rewrite import rewrite_html
|
|
||||||
|
|
||||||
def walk_pages():
|
|
||||||
"""Yield (page_url, abs_path) for every index.html under OUT_DIR."""
|
|
||||||
root = OUT_DIR
|
|
||||||
for dirpath, dirnames, filenames in os.walk(root):
|
|
||||||
if "index.html" in filenames:
|
|
||||||
rel = os.path.relpath(dirpath, root)
|
|
||||||
if rel == ".":
|
|
||||||
path = "/"
|
|
||||||
else:
|
|
||||||
path = "/" + rel.replace(os.sep, "/")
|
|
||||||
page_url = BASE_URL + path
|
|
||||||
yield page_url, os.path.join(dirpath, "index.html")
|
|
||||||
|
|
||||||
def main():
|
|
||||||
mirror.start_workers()
|
|
||||||
count = 0
|
|
||||||
changed = 0
|
|
||||||
for page_url, fpath in walk_pages():
|
|
||||||
count += 1
|
|
||||||
with open(fpath, "r", encoding="utf-8") as f:
|
|
||||||
text = f.read()
|
|
||||||
new = rewrite_html(text, page_url)
|
|
||||||
if new != text:
|
|
||||||
changed += 1
|
|
||||||
with open(fpath, "w", encoding="utf-8") as f:
|
|
||||||
f.write(new)
|
|
||||||
# drain any assets enqueued by this page's rewrite
|
|
||||||
batch = claim_asset_urls()
|
|
||||||
if batch:
|
|
||||||
mirror.submit_assets(batch)
|
|
||||||
# final drain
|
|
||||||
import time
|
|
||||||
while True:
|
|
||||||
batch = claim_asset_urls()
|
|
||||||
if batch:
|
|
||||||
mirror.submit_assets(batch)
|
|
||||||
if not mirror._submitted:
|
|
||||||
break
|
|
||||||
if all(is_downloaded(u) for u in list(mirror._submitted)):
|
|
||||||
if not claim_asset_urls():
|
|
||||||
break
|
|
||||||
time.sleep(0.3)
|
|
||||||
mirror.stop_workers()
|
|
||||||
print(f"Processed {count} pages, {changed} changed.")
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 503 B After Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 503 B After Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 503 B After Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 503 B After Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 503 B After Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 503 B After Width: | Height: | Size: 503 B |
|
Before Width: | Height: | Size: 503 B After Width: | Height: | Size: 503 B |