first commit

This commit is contained in:
Fabio 2025-12-23 13:06:25 +01:00
commit 8e5c45a7b6
54 changed files with 4630 additions and 0 deletions

10
.dockerignore Normal file
View file

@ -0,0 +1,10 @@
node_modules
npm-debug.log
Dockerfile
docker-compose.yml
.git
.git.gitignore
.env
dist
sites
server.js

5
.env Normal file
View file

@ -0,0 +1,5 @@
TYPE=http
HOST=192.168.1.3
PORT=3600
URL=https://mys.patachina2.casacam.net
# SITES=composerize,composeverter,decomposerize

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
composerize/
composeverter/
decomposerize/

86
Dockerfile Normal file
View file

@ -0,0 +1,86 @@
# --------
# STAGE: base runtime con Node e strumenti minimi
# --------
FROM node:20-bookworm-slim AS runtime-base
# Impostazioni ambiente e sicurezza build
ENV DEBIAN_FRONTEND=noninteractive \
PIP_NO_CACHE_DIR=1 \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
NODE_ENV=production
# Aggiorna e installa Python + strumenti
# Nota: build-essential utile per pacchetti Python nativi (puoi rimuoverlo se non necessario)
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-venv python3-pip \
ca-certificates curl tini \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Workdir applicazione
WORKDIR /usr/src/app
# Crea utente non-root e cartelle
#RUN useradd -m -u 10001 appuser \
# && mkdir -p /usr/src/app \
# && chown -R appuser:appuser /usr/src/app
# --------
# STAGE: dipendenze Node
# --------
FROM runtime-base AS node-deps
WORKDIR /usr/src/app
#copia tutto
COPY . .
COPY downloadsite-docker.sh downloadsite.sh
# Installa packages e dipendenze
RUN npm ci --only=production
# --------
# STAGE: dipendenze Python (venv)
# --------
FROM node-deps AS python-deps
WORKDIR /usr/src/app
# crea venv
RUN python3 -m venv /opt/pyenv \
&& /opt/pyenv/bin/pip install --upgrade pip \
&& /opt/pyenv/bin/pip install -r requirements.txt
# --------
# STAGE: final image
# --------
FROM python-deps AS final
# Riduci ulteriormente l'immagine togliendo strumenti di build se li avevi installati
# (In questo esempio li teniamo per eventuali run-time native libs; opzionale rimuoverli con apt-get purge)
WORKDIR /usr/src/app
# Imposta PATH per usare il venv e crea symlink "python3" puntato al venv
ENV PATH="/opt/pyenv/bin:${PATH}"
ENV PATH="/usr/src/app:${PATH}"
# Symlink esplicito utile se il tuo codice chiama "python3" hard-coded
RUN ln -sf /opt/pyenv/bin/python /usr/local/bin/python3
# Sicurezza: esegui come utente non-root
#USER appuser
# Espone la porta dell'Express server
EXPOSE 3600
# Healthcheck semplice sulla root
#HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
# CMD curl -fsS http://localhost:3000/ || exit 1
# Usa tini come init per gestire segnali e orphan processes
ENTRYPOINT ["/usr/bin/tini", "--"]
# Avvio
CMD ["node", "server_docker.js"]

47
README.md Normal file
View file

@ -0,0 +1,47 @@
# Creare un server per più website statici
per questo esempio scaricheremo i siti dal web usando
(https://forgit.patachina.it/Fabio/website-downloader.git)
1. creare il folder principale es: dock clonando la git
```sh
git clone https://forgit.patachina.it/Fabio/multi_static_website.git dock
```
2. scaricare i vari siti in directory differenti all'interno di dock
```
cd dock
downloadsite.sh https://www.decomposerize.com/ decomposerize
downloadsite.sh https://www.composerize.com/ composerize
downloadsite.sh https://www.composeverter.com/ composeverter
```
3. installare i packages per il server npm
npm install
4. inserire i parametri del server
http o https
IP
porta
5. inserire le directory separate da ,
SITES=
6. il file diventa
```sh
TYPE=http
HOST=192.168.1.3
PORT=12000
SITES=composerize,composeverter,decomposerize
```
7. avviare il server
node server.js

15
docker-compose.yml Normal file
View file

@ -0,0 +1,15 @@
services:
app:
image: sites:latest
container_name: sites
restart: unless-stopped
ports:
- 3600:3000
volumes:
- /home/nvme/dockerdata/sites:/usr/src/app/sites
environment:
NODE_ENV: production
PORT: 3000
HOST: 0.0.0.0
TYPE: http
URL: https://mys.patachina2.casacam.net

6
downloadsite-docker.sh Executable file
View file

@ -0,0 +1,6 @@
#!/bin/bash
python website-downloader.py \
--url $1 \
--destination $2 \
--max-pages 100 \
--threads 8

7
downloadsite.sh Executable file
View file

@ -0,0 +1,7 @@
#!/bin/bash
source /usr/local/python/website-downloader/.venv/bin/activate
python /usr/local/python/website-downloader/website-downloader.py \
--url $1 \
--destination $2 \
--max-pages 100 \
--threads 8

163
generateSitesJson.js Normal file
View file

@ -0,0 +1,163 @@
// Assicurati di avere "type": "module" nel package.json
// oppure salva il file come .mjs
import fs from "fs";
import path from "path";
const root = "./home/sites"; // cartella principale
const outputFile = "./home/sites.json"; // file di output
const iconsDir = "./home/icons"; // cartella dove salvare le icone
const baseDir = "./home";
/**
* Cancella un'icona dato il path relativo (es. "icons/composerize.ico")
* @param {string} iconPath - Percorso relativo dell'icona rispetto a baseDir
*/
function deleteIconSync(iconPath) {
try {
const fullPath = path.join(baseDir, iconPath);
fs.unlinkSync(fullPath);
console.log(`Cancellato: ${fullPath}`);
} catch (err) {
if (err.code === "ENOENT") {
console.error("File non trovato:", iconPath);
} else {
console.error("Errore durante la cancellazione:", err);
}
}
}
function parseSite(folder) {
const sitePath = path.join(root, folder);
let shortName = folder; // fallback
let iconPath = null;
// 1. Cerca manifest.json
const manifestPath = path.join(sitePath, "manifest.json");
if (fs.existsSync(manifestPath)) {
try {
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if (manifest.short_name) shortName = manifest.short_name;
if (manifest.icons && manifest.icons.length > 0) {
iconPath = path.join(sitePath, manifest.icons[0].src);
}
} catch (e) {}
}
// 2. Se non cè manifest, prova index.html
const indexPath = path.join(sitePath, "index.html");
if (!iconPath && fs.existsSync(indexPath)) {
const html = fs.readFileSync(indexPath, "utf8");
const titleMatch = html.match(/<title>(.*?)<\/title>/i);
if (titleMatch) shortName = titleMatch[1];
const iconMatch = html.match(/<link[^>]+rel=["']icon["'][^>]+href=["']([^"']+)["']/i);
if (iconMatch) {
iconPath = path.join(sitePath, iconMatch[1]);
}
}
// 3. Fallback su favicon.ico
const faviconPath = path.join(sitePath, "favicon.ico");
if (!iconPath && fs.existsSync(faviconPath)) {
iconPath = faviconPath;
}
// 4. Copia licona nella cartella comune
let finalIcon = "icons/default.png"; // path pulito
if (iconPath && fs.existsSync(iconPath)) {
const ext = path.extname(iconPath) || ".png";
const dest = path.join(iconsDir, `${folder}${ext}`);
try {
fs.copyFileSync(iconPath, dest);
finalIcon = `icons/${folder}${ext}`; // solo riferimento relativo
} catch (e) {
console.error(`Errore copia icona per ${folder}:`, e);
}
}
return { dir: folder, name: shortName, icon: finalIcon };
}
export function generateSitesJson() {
// crea la cartella icons se non esiste
if (!fs.existsSync(iconsDir)) {
fs.mkdirSync(iconsDir, { recursive: true });
}
// Scansiona tutte le sottocartelle (escludendo home e icons)
const sites = fs.readdirSync(root).filter(f =>
fs.statSync(path.join(root, f)).isDirectory() && f !== "home" && f !== "icons"
);
console.log("leggo i siti:", sites);
//console.log("primo sito", parseSite(sites[0]));
// Estrarre info
const results = sites.map(parseSite);
// Salva in JSON
fs.writeFileSync(outputFile, JSON.stringify(results, null, 2), "utf8");
results.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
console.log(`✅ File JSON salvato in ${outputFile}`);
return results;
}
// elimina dal file sites.json gli oggetti con dir = "a"
export function delFieldDir(dir) {
if (!fs.existsSync(outputFile)) {
console.error("❌ File non trovato:", outputFile);
return;
}
try {
const data = fs.readFileSync(outputFile, "utf8");
const results = JSON.parse(data);
const filt = results.filter(item => item.dir == dir);
//console.log("icona da canc: ",filt);
console.log("icona da canc: ",filt[0].icon);
deleteIconSync(filt[0].icon);
// filtra via gli elementi con directory = dir
const filtered = results.filter(item => item.dir !== dir);
//console.log("nuovo sites.json", filtered);
fs.writeFileSync(outputFile, JSON.stringify(filtered, null, 2), "utf8");
console.log(`✅ File aggiornato: rimossi i campi con dir = ${dir}`);
filtered.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
return filtered;
} catch (e) {
console.error("Errore durante la modifica del file:", e);
}
}
export function addFieldDir(dir) {
if (!fs.existsSync(outputFile)) {
console.error("❌ File non trovato:", outputFile);
return;
}
try {
const data = fs.readFileSync(outputFile, "utf8");
const results = JSON.parse(data);
//console.log("sito agg",parseSite(dir));
// aggiungi elemonto elementi con directory = dir
results.push(parseSite(dir));
//console.log("add site nuovo sites.json", results);
//const filtered = results.filter(item => item.dir !== dir);
//console.log("nuovo sites.json", filtered);
fs.writeFileSync(outputFile, JSON.stringify(results, null, 2), "utf8");
results.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
console.log(`✅ File aggiornato: rimossi i campi con dir = ${dir}`);
return results;
} catch (e) {
console.error("Errore durante la modifica del file:", e);
}
}

BIN
home/icons/composerize.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

BIN
home/icons/composerize.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
home/icons/composterize.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

50
home/index.html Normal file
View file

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Sites Dashboard</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<h1>Sites Dashboard</h1>
<div id="grid" class="grid"></div>
<script type="module">
async function loadSites() {
// carica sites.json dalla cartella superiore
const res = await fetch("./sites.json");
const sites = await res.json();
const grid = document.getElementById("grid");
grid.innerHTML = "";
sites.forEach(site => {
const card = document.createElement("div");
card.className = "card";
const img = document.createElement("img");
img.src = site.icon; // già completo
img.alt = site.name;
const title = document.createElement("div");
title.className = "title";
title.textContent = site.name;
card.appendChild(img);
card.appendChild(title);
// 👉 aggiungi click handler
card.addEventListener("click", () => {
// cambia l'iframe che contiene la dashboard
window.parent.document.getElementById("contentFrame").src = `/${site.dir}`;
});
grid.appendChild(card);
});
}
loadSites();
</script>
</body>
</html>

319
home/manifest-editor.html Normal file
View file

@ -0,0 +1,319 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Editor Manifest PWA</title>
<!-- Link al manifest con id per cache-busting -->
<link rel="manifest" id="manifestLink" href="/manifest.json?v=0">
<meta name="theme-color" content="#0d6efd">
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; }
fieldset { border: 1px solid #ddd; padding: 1rem; margin-bottom: 1rem; border-radius: 8px; }
label { display: block; margin-top: .5rem; }
input[type="text"], input[type="color"], select, textarea {
width: 100%; max-width: 640px; padding: .5rem; margin-top: .25rem;
}
.icons { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 1rem; }
.icon-card { border: 1px solid #eee; border-radius: 8px; padding: .75rem; background: #fafafa; }
.row { display: flex; gap: .5rem; align-items: center; flex-wrap: wrap; }
button { padding: .5rem .75rem; border-radius: 6px; border: 1px solid #ccc; background: #fff; cursor: pointer; }
button.primary { background: #0d6efd; color: #fff; border-color: #0d6efd; }
button.danger { background: #dc3545; color: #fff; border-color: #dc3545; }
.status { margin-top: .5rem; font-size: .95rem; color: #555; }
.thumb { width: 64px; height: 64px; object-fit: contain; border: 1px solid #ddd; background: #fff; border-radius: 6px; }
.meta { font-size: .9rem; color: #333; }
.note { font-size: .85rem; color: #666; }
</style>
</head>
<body>
<h1>Editor Manifest PWA</h1>
<div class="status" id="status">Caricamento manifest...</div>
<form id="manifestForm">
<fieldset>
<legend>Campi principali</legend>
<label>name <input type="text" id="name"></label>
<label>short_name <input type="text" id="short_name"></label>
<label>description <textarea id="description" rows="3"></textarea></label>
<label>start_url <input type="text" id="start_url" placeholder="/"></label>
<label>scope <input type="text" id="scope" placeholder="/"></label>
<label>display
<select id="display">
<option value="standalone">standalone</option>
<option value="fullscreen">fullscreen</option>
<option value="minimal-ui">minimal-ui</option>
<option value="browser">browser</option>
</select>
</label>
<div class="row">
<label style="flex:1">background_color <input type="color" id="background_color" value="#ffffff"></label>
<label style="flex:1">theme_color <input type="color" id="theme_color" value="#0d6efd"></label>
</div>
<label>orientation
<select id="orientation">
<option value="">(nessuna)</option>
<option value="any">any</option>
<option value="natural">natural</option>
<option value="portrait">portrait</option>
<option value="landscape">landscape</option>
<option value="portrait-primary">portrait-primary</option>
<option value="landscape-primary">landscape-primary</option>
</select>
</label>
<label>lang <input type="text" id="lang" placeholder="it"></label>
<label>categories (separate da virgola)
<input type="text" id="categories" placeholder="business, shopping">
</label>
</fieldset>
<fieldset>
<legend>Icone</legend>
<div id="iconsContainer" class="icons"></div>
<h3>Aggiungi nuova icona</h3>
<div class="row">
<input type="file" id="iconFile" name="icon" accept="image/png,image/jpeg,image/webp">
<select id="purpose">
<option value="any">any</option>
<option value="any maskable">any maskable</option>
<option value="maskable">maskable</option>
</select>
<button type="button" id="uploadIconBtn">Carica icona</button>
</div>
<small class="note">
Suggerito: carica unimmagine grande (es. 1024×1024) — il server genererà automaticamente 192×192 e 512×512.
</small>
</fieldset>
<div class="row">
<button type="submit" class="primary">Salva manifest</button>
<button type="button" id="reloadManifestBtn">Ricarica manifest</button>
</div>
</form>
<!--
<script src="https://cdn.jsdelivr.net/npm/eruda"></script>
<script>eruda.init();</script>
-->
<script>
// --- Setup iniziale ---
//console.log('[manifest] href:', window.location.href);
//console.log('[manifest] search:', window.location.search);
const params = new URLSearchParams(window.location.search);
const dir = params.get('dir')?.replace(/^\/+|\/+$/g, '');
//console.log("dir=",dir);
let manifest = {};
const statusEl = document.getElementById('status');
const iconsContainer = document.getElementById('iconsContainer');
async function loadManifest() {
statusEl.textContent = 'Carico manifest...';
const form = new FormData();
form.append('dir', dir);
const res = await fetch('/api/manifest', {
method: 'POST',
body: form
});
//const res = await fetch('/api/manifest');
manifest = await res.json();
statusEl.textContent = 'Manifest caricato';
// Popola campi
document.getElementById('name').value = manifest.name || '';
document.getElementById('short_name').value = manifest.short_name || '';
document.getElementById('description').value = manifest.description || '';
document.getElementById('start_url').value = manifest.start_url || '/';
document.getElementById('scope').value = manifest.scope || '/';
document.getElementById('display').value = manifest.display || 'standalone';
document.getElementById('background_color').value = manifest.background_color || '#ffffff';
document.getElementById('theme_color').value = manifest.theme_color || '#0d6efd';
document.getElementById('orientation').value = manifest.orientation || '';
document.getElementById('lang').value = manifest.lang || '';
document.getElementById('categories').value = (manifest.categories || []).join(', ');
renderIcons();
}
function renderIcons() {
iconsContainer.innerHTML = '';
const icons = manifest.icons || [];
if (icons.length === 0) {
iconsContainer.innerHTML = '<p class="note">Nessuna icona presente nel manifest.</p>';
return;
}
icons.forEach((icon, idx) => {
const card = document.createElement('div');
card.className = 'icon-card';
const previewSrc = icon.src; // supporta sia /icons/... sia http(s)://...
card.innerHTML = `
<div class="row">
<img class="thumb" src="sites/${dir}/${previewSrc}" alt="${icon.sizes || ''}">
<div class="meta">
<div><strong>src:</strong> ${icon.src}</div>
<div><strong>sizes:</strong> ${icon.sizes || ''}</div>
<div><strong>type:</strong> ${icon.type || ''}</div>
<div><strong>purpose:</strong> ${icon.purpose || ''}</div>
</div>
</div>
<div class="row" style="margin-top:.5rem">
<button type="button" data-idx="${idx}" class="removeIconBtn">Rimuovi dal manifest</button>
<button type="button" data-src="${icon.src}" class="deleteIconFileBtn danger">Elimina file + manifest</button>
</div>
`;
iconsContainer.appendChild(card);
});
// Rimuovi solo dal manifest
document.querySelectorAll('.removeIconBtn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const idx = parseInt(e.target.dataset.idx, 10);
manifest.icons.splice(idx, 1);
try {
await saveManifest(false);
renderIcons();
statusEl.textContent = 'Icona rimossa dal manifest';
bumpManifestLink();
} catch (err) {
console.error(err);
statusEl.textContent = 'Errore rimozione icona dal manifest';
}
});
});
// Elimina file (se sotto /icons) e rimuovi dal manifest lato server
document.querySelectorAll('.deleteIconFileBtn').forEach(btn => {
btn.addEventListener('click', async (e) => {
const src = e.target.dataset.src;
statusEl.textContent = 'Elimino icona...';
try {
const res = await fetch(`/api/icons?src=${encodeURIComponent(src)}&removeFile=true&myDir=${dir}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Delete failed');
await loadManifest();
statusEl.textContent = 'Icona eliminata (file + manifest)';
bumpManifestLink();
} catch (err) {
console.error(err);
statusEl.textContent = 'Errore eliminazione icona';
}
});
});
}
async function saveManifest(bump = true) {
// Aggiorna manifest con i valori del form
manifest.name = document.getElementById('name').value;
manifest.short_name = document.getElementById('short_name').value;
manifest.description = document.getElementById('description').value;
manifest.start_url = document.getElementById('start_url').value || '/';
manifest.scope = document.getElementById('scope').value || '/';
manifest.display = document.getElementById('display').value;
manifest.background_color = document.getElementById('background_color').value;
manifest.theme_color = document.getElementById('theme_color').value;
const orientation = document.getElementById('orientation').value;
if (orientation) manifest.orientation = orientation; else delete manifest.orientation;
const lang = document.getElementById('lang').value;
if (lang) manifest.lang = lang; else delete manifest.lang;
const cats = document.getElementById('categories').value
.split(',')
.map(c => c.trim())
.filter(Boolean);
manifest.categories = cats;
const form = new FormData();
form.append('manifest', JSON.stringify(manifest));
form.append('dir', dir);
const res = await fetch('/api/manifest', {
method: 'PUT',
//headers: { 'Content-Type': 'application/json' },
//body: JSON.stringify({manifest , dir})
body: form
});
if (!res.ok) throw new Error('Salvataggio manifest fallito');
statusEl.textContent = 'Manifest salvato';
if (bump) bumpManifestLink();
}
function bumpManifestLink() {
const link = document.getElementById('manifestLink');
const url = new URL(link.href, location.origin);
url.searchParams.set('v', Date.now().toString());
link.href = url.toString();
}
document.getElementById('manifestForm').addEventListener('submit', async (e) => {
e.preventDefault();
try {
await saveManifest(true);
} catch (err) {
console.error(err);
statusEl.textContent = 'Errore salvataggio manifest';
}
});
document.getElementById('reloadManifestBtn').addEventListener('click', loadManifest);
document.getElementById('uploadIconBtn').addEventListener('click', async () => {
const fileInput = document.getElementById('iconFile');
const file = fileInput.files[0];
const purpose = document.getElementById('purpose').value;
if (!file) {
statusEl.textContent = 'Seleziona un file icona';
return;
}
statusEl.textContent = 'Caricamento icona...';
const formData = new FormData();
formData.append('icon', file);
formData.append('purpose', purpose);
formData.append('mydir', dir);
try {
const res = await fetch('/api/upload-icon', { method: 'POST', body: formData });
if (!res.ok) throw new Error('Upload fallito');
const data = await res.json(); // { ok, icons: [...] }
manifest.icons = mergeIcons(manifest.icons || [], data.icons);
await saveManifest(true);
await loadManifest();
statusEl.textContent = 'Icona caricata e manifest aggiornato';
} catch (err) {
console.error(err);
statusEl.textContent = 'Errore upload icona';
}
});
function mergeIcons(existing, added) {
const key = i => `${i.src}|${i.sizes}`;
const map = new Map(existing.map(i => [key(i), i]));
for (const a of added) map.set(key(a), a);
return Array.from(map.values());
}
// Avvio
loadManifest();
</script>
</body>
</html>

BIN
home/mod.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/myicons/favicon-70x70.png"/>
<square150x150logo src="/myicons/favicon-150x150.png"/>
<square310x310logo src="/myicons/favicon-310x310.png"/>
<TileColor>#ffffff</TileColor>
</tile>
</msapplication>
</browserconfig>

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
home/myicons/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

View file

@ -0,0 +1,60 @@
{
"name": "My Sites",
"description": "I miei Siti",
"short_name": "MySites",
"icons": [
{
"src": "/myicons/favicon-72x72.png",
"type": "image/png",
"sizes": "72x72",
"purpose": "any maskable"
},
{
"src": "/myicons/favicon-96x96.png",
"type": "image/png",
"sizes": "96x96",
"purpose": "any maskable"
},
{
"src": "/myicons/favicon-128x128.png",
"type": "image/png",
"sizes": "128x128",
"purpose": "any maskable"
},
{
"src": "/myicons/favicon-144x144.png",
"type": "image/png",
"sizes": "144x144",
"purpose": "any maskable"
},
{
"src": "/myicons/favicon-152x152.png",
"type": "image/png",
"sizes": "152x152",
"purpose": "any maskable"
},
{
"src": "/myicons/favicon-192x192.png",
"type": "image/png",
"sizes": "192x192",
"purpose": "any maskable"
},
{
"src": "/myicons/favicon-384x384.png",
"type": "image/png",
"sizes": "384x384",
"purpose": "any maskable"
},
{
"src": "/myicons/favicon-512x512.png",
"type": "image/png",
"sizes": "512x512",
"purpose": "any maskable"
}
],
"scope": "/",
"start_url": "/?source=pwa",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff"
}

242
home/settings.html Normal file
View file

@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<title>Sites Dashboard</title>
<link rel="stylesheet" href="style1.css">
</head>
<body>
<h1>Sites Settings</h1>
<div id="grid" class="grid"></div>
<!-- Modal: Aggiungi (esistente) -->
<div id="addModal" class="modal hidden">
<div class="modal-content">
<h2>Aggiungi nuovo sito</h2>
<form id="addForm">
<label>
URL:
<input type="text" id="siteUrl" required />
</label>
<label>
Dir:
<input type="text" id="siteDir" required />
</label>
<div class="actions">
<button type="submit">Salva</button>
<button type="button" id="closeModal">Chiudi</button>
</div>
</form>
</div>
</div>
<!-- Modal: Conferma cancellazione -->
<div id="delModal" class="modal hidden" data-modal>
<div class="modal-content">
<h2>Conferma cancellazione</h2>
<p id="delQuestion">Vuoi davvero cancellare questo sito?</p>
<div class="actions">
<button type="button" id="confirmDelete" class="danger">Sì, cancella</button>
<button type="button" data-close="delModal">Annulla</button>
</div>
</div>
</div>
<script type="module">
// ------------ Helpers: modal handling ------------
function openModal(id) {
const m = document.getElementById(id);
if (!m) return;
m.classList.remove('hidden');
}
function closeModal(id) {
const m = document.getElementById(id);
if (!m) return;
m.classList.add('hidden');
}
// Close modal when clicking on overlay
document.addEventListener('click', (e) => {
const modalEl = e.target.closest('[data-modal]');
if (!modalEl && e.target.classList.contains('modal')) {
e.target.classList.add('hidden');
}
});
// Close modal by buttons with data-close
document.addEventListener('click', (e) => {
const closeId = e.target.getAttribute('data-close');
if (closeId) closeModal(closeId);
});
// Close on Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden'));
}
});
// ------------ State for selected site ------------
let selectedSite = null; // the site object currently selected for delete/edit
// ------------ Load and render sites ------------
async function loadSites() {
const res = await fetch('./sites.json', { cache: 'no-store' });
const sites = await res.json();
const grid = document.getElementById('grid');
grid.innerHTML = '';
sites.forEach(site => {
// Expecting: site.name, site.icon, site.url (optional), site.dir, and maybe site.shortName/defaultFile
const card = document.createElement('div');
card.className = 'card';
const img = document.createElement('img');
img.src = site.icon;
img.alt = site.name;
// mostra mod.png quando sei sopra la card
const originalSrc = site.icon;
card.addEventListener('mouseenter', () => {
if (!card.classList.contains('add-card')) {
img.src = 'mod.png';
}
});
card.addEventListener('mouseleave', () => {
img.src = originalSrc;
});
const title = document.createElement('div');
title.className = 'title';
title.textContent = site.name;
card.appendChild(img);
card.appendChild(title);
// --- Interaction rules (excluding + card) ---
// 1) Click/tap → open Delete modal
// 2) Long-press → open Edit manifest modal
// Robust long-press with pointer events (mouse & touch)
let pressTimer = null;
let longPressed = false;
const LONG_PRESS_MS = 600;
const startPress = async (e) => {
longPressed = false;
clearTimeout(pressTimer);
pressTimer = setTimeout(async () => {
longPressed = true;
selectedSite = site;
const url = new URL('/manifest', window.location.origin);
url.searchParams.set('dir',selectedSite.dir);
window.location.assign(url.toString());
}, LONG_PRESS_MS);
};
const endPress = (e) => {
clearTimeout(pressTimer);
if (!longPressed) {
// treat as click → delete
selectedSite = site;
const q = document.getElementById('delQuestion');
q.textContent = `Vuoi davvero cancellare il sito "${site.name}" (dir: ${site.dir})?`;
openModal('delModal');
}
};
const cancelPress = () => {
clearTimeout(pressTimer);
};
card.addEventListener('pointerdown', startPress);
card.addEventListener('pointerup', endPress);
card.addEventListener('pointerleave', cancelPress);
card.addEventListener('pointercancel', cancelPress);
// prevent context menu from interfering on desktop long-press
card.addEventListener('contextmenu', (e) => e.preventDefault());
grid.appendChild(card);
});
// --- "+" Add card ---
const addCard = document.createElement('div');
addCard.className = 'card add-card';
const plus = document.createElement('div');
plus.className = 'plus-icon';
plus.textContent = '+';
const addTitle = document.createElement('div');
addTitle.className = 'title';
addTitle.textContent = 'Aggiungi';
addCard.appendChild(plus);
addCard.appendChild(addTitle);
addCard.addEventListener('click', () => {
document.getElementById('addModal').classList.remove('hidden');
});
grid.appendChild(addCard);
}
// Initial load
loadSites();
// ------------ Add-site modal handlers (existing) ------------
const addModal = document.getElementById('addModal');
const closeBtn = document.getElementById('closeModal');
closeBtn.addEventListener('click', () => addModal.classList.add('hidden'));
const addForm = document.getElementById('addForm');
addForm.addEventListener('submit', async (e) => {
e.preventDefault();
const url = document.getElementById('siteUrl').value.trim();
const dir = document.getElementById('siteDir').value.trim();
if (!url || !dir) return;
const res = await fetch('/add-site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, dir })
});
if (res.ok) {
alert('Sito aggiunto con successo!');
addModal.classList.add('hidden');
await loadSites();
//window.parent.refreshSideBar();
window.parent.postMessage("refreshSideBar", "*");
} else {
const msg = await safeText(res);
alert('Errore nell\'aggiunta del sito: ' + msg);
}
});
async function safeText(res) {
try { return await res.text(); } catch { return ''; }
}
// ------------ Delete confirmation handlers ------------
document.getElementById('confirmDelete').addEventListener('click', async () => {
if (!selectedSite || !selectedSite.dir) {
alert('Nessun sito selezionato o dir mancante.');
return;
}
const res = await fetch('/del-site', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dir: selectedSite.dir })
});
if (res.ok) {
closeModal('delModal');
selectedSite = null;
await loadSites();
//window.parent.refreshSideBar();
window.parent.postMessage("refreshSideBar", "*");
} else {
const msg = await safeText(res);
alert('Errore nella cancellazione: ' + msg);
}
});
</script>
</body>

456
home/sidebar.html Normal file
View file

@ -0,0 +1,456 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Siti vari</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
<link rel="apple-touch-icon" sizes="57x57" href="/myicons/favicon-57x57.png">
<link rel="apple-touch-icon" sizes="60x60" href="/myicons/favicon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="/myicons/favicon-72x72.png">
<link rel="apple-touch-icon" sizes="76x76" href="/myicons/favicon-76x76.png">
<link rel="apple-touch-icon" sizes="114x114" href="/myicons/favicon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="/myicons/favicon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="/myicons/favicon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="/myicons/favicon-152x152.png">
<link rel="apple-touch-icon" sizes="180x180" href="/myicons/favicon-180x180.png">
<link rel="icon" type="image/png" sizes="16x16" href="/myicons/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="/myicons/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="/myicons/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="192x192" href="/myicons/favicon-192x192.png">
<link rel="shortcut icon" type="image/x-icon" href="/myicons/favicon.ico">
<link rel="icon" type="image/x-icon" href="/myicons/favicon.ico">
<meta name="msapplication-TileColor" content="#ffffff">
<meta name="msapplication-TileImage" content="/myicons/favicon-144x144.png">
<meta name="msapplication-config" content="/myicons/browserconfig.xml">
<link rel="manifest" href="/myicons/manifest.json">
<meta name="theme-color" content="#ffffff">
<style>
:root {
--sidebar-w: 260px;
--sidebar-bg: #0f172a;
--sidebar-fg: #e2e8f0;
--border: #1f2937;
--overlay-bg: rgba(0,0,0,0.5);
--transition: 250ms ease;
--focus: #f59e0b;
}
/* Base */
html, body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
font-family: system-ui, sans-serif;
background: radial-gradient(1200px 700px at 20% 10%, #0b1220 0%, #070c1a 50%, #050a16 100%);
color: #cbd5e1;
}
/* Icona menù */
.menu-btn {
position: fixed;
top: 12px;
left: 12px;
z-index: 100;
background: transparent;
border: none;
cursor: pointer;
color: #e2e8f0;
padding: 4px;
transition: opacity var(--transition);
}
.menu-btn.hidden { opacity: 0; pointer-events: none; }
.menu-btn.dragging { cursor: grabbing; }
.menu-btn svg { pointer-events: none; }
/* Sidebar */
.sidebar {
position: fixed;
top: 0;
left: 0;
width: var(--sidebar-w);
height: 100%;
background: linear-gradient(180deg, #0f172a 0%, #0c1423 100%);
color: var(--sidebar-fg);
transform: translateX(-100%);
transition: transform var(--transition);
z-index: 90;
padding: 16px 12px;
border-right: 1px solid var(--border);
}
.sidebar.open { transform: translateX(0); }
.nav-header { display: flex; align-items: center; }
.nav-title {
font-size: .8rem;
text-transform: uppercase;
letter-spacing: .08em;
color: #94a3b8;
margin: 8px 8px 4px;
}
.nav-header {
display: flex;
align-items: center;
justify-content: space-between; /* titolo a sinistra, bottone a destra */
padding: 0 8px;
margin-bottom: 8px;
}
.nav-title {
margin: 0; /* reset margini per allineamento */
}
.refresh-btn {
background: transparent;
border: none;
cursor: pointer;
color: var(--sidebar-fg);
padding: 4px;
transition: transform var(--transition), color var(--transition);
}
.refresh-btn:hover {
transform: rotate(90deg);
color: var(--focus);
}
.edit-btn {
background: transparent;
border: none;
cursor: pointer;
color: var(--sidebar-fg);
padding: 4px;
transition: transform var(--transition), color var(--transition);
}
.edit-btn:hover {
transform: rotate(90deg);
color: var(--focus);
}
.nav-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.nav-link {
display: block;
width: 100%;
text-align: left;
padding: 10px 12px;
border-radius: 8px;
color: var(--sidebar-fg);
background: rgba(255,255,255,0.02);
border: 1px solid transparent;
transition: background var(--transition), transform var(--transition), border-color var(--transition);
cursor: pointer;
}
.nav-link:hover {
background: rgba(255,255,255,0.06);
transform: translateX(2px);
border-color: #263145;
}
.nav-link:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 3px;
}
/* Overlay */
.overlay {
position: fixed;
inset: 0;
background: var(--overlay-bg);
backdrop-filter: blur(2px);
z-index: 80;
display: none;
}
.overlay.show { display: block; }
/* Contenuto principale */
.content {
margin: 0;
padding: 0;
height: 100dvh; /* viewport dinamico, elimina banda nera su mobile */
}
.frame-wrap {
position: relative;
width: 100%;
height: 100%;
}
iframe {
width: 100%;
height: 100%;
border: none;
margin: 0;
display: block;
background: #fff; /* evita bleed del gradiente */
}
/* Fallback per browser che non supportano 100dvh */
@supports not (height: 100dvh) {
.content { height: 100vh; }
}
.frame-error {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 20px;
color: #ef4444;
background: rgba(0,0,0,0.25);
backdrop-filter: blur(2px);
text-align: center;
}
.frame-error.show { display: flex; }
</style>
</head>
<body>
<!-- Icona menù -->
<button id="menuBtn" class="menu-btn" aria-label="Apri menù">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="3" width="20" height="2" fill="currentColor"/>
<rect x="2" y="7" width="20" height="2" fill="currentColor"/>
<rect x="2" y="11" width="20" height="2" fill="currentColor"/>
<rect x="2" y="15" width="20" height="2" fill="currentColor"/>
<rect x="2" y="19" width="20" height="2" fill="currentColor"/>
</svg>
</button>
<!-- Sidebar
<aside id="sidebar" class="sidebar" aria-hidden="true">
<nav class="nav" id="navRoot">
<h2 class="nav-title">Sites</h2>
<ul id="siteList" class="nav-list"></ul>
</nav>
</aside> -->
<!-- Sidebar -->
<aside id="sidebar" class="sidebar" aria-hidden="true">
<nav class="nav" id="navRoot">
<div class="nav-header">
<h2 class="nav-title">Sites</h2>
<div class="nav-actions">
<button id="refreshBtn" class="refresh-btn" aria-label="Ricarica">
<i class="fas fa-sync-alt"></i>
</button>
<button id="editBtn" class="edit-btn" aria-label="Edit">
<i class="fa-solid fa-gear"></i>
</button>
</div>
</div>
<ul id="siteList" class="nav-list"></ul>
</nav>
</aside>
<!-- Overlay -->
<div id="overlay" class="overlay"></div>
<!-- Contenuto principale -->
<main class="content">
<div class="frame-wrap">
<iframe id="contentFrame" referrerpolicy="no-referrer"></iframe>
<div id="frameError" class="frame-error">
<div>
<strong>Il sito non può essere caricato in iframe.</strong><br />
Potrebbe avere X-Frame-Options o Content-Security-Policy che ne impediscono lincorporamento.
</div>
</div>
</div>
</main>
<script>
const menuBtn = document.getElementById('menuBtn');
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('overlay');
const siteList = document.getElementById('siteList');
const iframe = document.getElementById('contentFrame');
const frameErr = document.getElementById('frameError');
const navRoot = document.getElementById('navRoot');
const refreshBtn = document.getElementById('refreshBtn');
function toggleSidebar() {
const isOpen = sidebar.classList.toggle('open');
overlay.classList.toggle('show', isOpen);
sidebar.setAttribute('aria-hidden', String(!isOpen));
menuBtn.classList.toggle('hidden', isOpen);
if (!isOpen) {
document.activeElement.blur();
}
}
overlay.addEventListener('click', toggleSidebar);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sidebar.classList.contains('open')) toggleSidebar();
});
/* Evita che i click dentro la sidebar propaghino al document (prevenendo chiusure involontarie) */
navRoot.addEventListener('click', (e) => e.stopPropagation());
navRoot.addEventListener('mousedown', (e) => e.stopPropagation());
navRoot.addEventListener('touchstart', (e) => e.stopPropagation(), { passive: false });
// Long press drag&drop — isolato SOLO al pulsante menù
let pressTimer, isDragging = false, offsetX, offsetY, pressActive = false;
function startPress(e) {
e.preventDefault();
pressActive = true;
const point = e.touches ? e.touches[0] : e;
pressTimer = setTimeout(() => {
isDragging = true;
menuBtn.classList.add('dragging');
offsetX = point.clientX - menuBtn.offsetLeft;
offsetY = point.clientY - menuBtn.offsetTop;
}, 400);
/* Attiva listeners di fine solo quando la press parte sul pulsante */
window.addEventListener('mouseup', endPressOnce);
window.addEventListener('touchend', endPressOnce);
window.addEventListener('mousemove', moveBtn);
window.addEventListener('touchmove', moveBtn, { passive: false });
}
function endPressCore() {
clearTimeout(pressTimer);
if (isDragging) {
isDragging = false;
menuBtn.classList.remove('dragging');
} else if (pressActive) {
// click breve sul pulsante → toggle menù
toggleSidebar();
}
pressActive = false;
}
function endPressOnce(e) {
endPressCore();
// pulizia listeners temporanei
window.removeEventListener('mouseup', endPressOnce);
window.removeEventListener('touchend', endPressOnce);
window.removeEventListener('mousemove', moveBtn);
window.removeEventListener('touchmove', moveBtn);
}
function moveBtn(e) {
if (!isDragging) return;
const point = e.touches ? e.touches[0] : e;
menuBtn.style.left = (point.clientX - offsetX) + 'px';
menuBtn.style.top = (point.clientY - offsetY) + 'px';
}
menuBtn.addEventListener('mousedown', startPress);
menuBtn.addEventListener('touchstart', startPress, { passive: false });
// Helper per URL pulite
function joinUrl(base, path) {
const cleanBase = String(base).replace(/\/+$/,'');
const cleanPath = String(path).replace(/^\/+/,'');
return cleanBase + '/' + cleanPath;
}
async function refreshSideBar() {
//if (doRefresh) {
try {
const res = await fetch('/config.json', { cache: 'no-cache' });
if (!res.ok) throw new Error('config.json non raggiungibile: ' + res.status);
const cfg = await res.json();
if (!cfg || !Array.isArray(cfg.s) || !cfg.url) {
throw new Error('Struttura config non valida. Attesi: { url: string, sites: string[] }');
}
siteList.innerHTML = '';
cfg.s.forEach(site => {
const li = document.createElement('li');
const btn = document.createElement('button');
btn.type = 'button';
btn.textContent = site.name;
btn.className = 'nav-link';
btn.addEventListener('click', (e) => {
e.stopPropagation(); /* il click resta nel menù */
const target = joinUrl(cfg.url, site.dir);
openInFrame(target);
toggleSidebar();
});
li.appendChild(btn);
siteList.appendChild(li);
});
//doRefresh =false;
return cfg;
} catch (err) {
//doRefresh =false;
frameErr.classList.add('show');
frameErr.innerHTML = '<div><strong>Errore di configurazione.</strong><br />' +
(err && err.message ? err.message : 'Impossibile caricare config.json') + '</div>';
}
//}
}
window.addEventListener("message", (event) => {
if (event.data === "refreshSideBar") {
refreshSideBar();
}
});
// Caricamento sites da config.json
(async function loadConfig() {
try {
let cfg = await refreshSideBar();
refreshBtn.addEventListener('click', () => {
// ricarica liframe
iframe.src = iframe.src.split('?')[0] + '?t=' + Date.now();
toggleSidebar();
});
editBtn.addEventListener('click', () => {
// carica /config
const target = joinUrl(cfg.url, "settings");
openInFrame(target);
toggleSidebar();
doRefresh = true;
});
// Sito iniziale
openInFrame(joinUrl(cfg.url, cfg.s[0].dir));
} catch (err) {
frameErr.classList.add('show');
frameErr.innerHTML = '<div><strong>Errore di configurazione.</strong><br />' +
(err && err.message ? err.message : 'Impossibile caricare config.json') + '</div>';
}
})();
// Carica URL nelliframe e gestisce casi di blocco
function openInFrame(url) {
frameErr.classList.remove('show');
// Mixed content
const pageIsHttps = location.protocol === 'https:';
const urlIsHttp = /^http:/.test(url);
if (pageIsHttps && urlIsHttp) {
frameErr.classList.add('show');
frameErr.innerHTML = '<div><strong>Contenuto misto bloccato.</strong><br />Pagina HTTPS, sito HTTP. Usa HTTPS.</div>';
return;
}
// Caricamento con timeout (indicativo di X-Frame-Options/CSP)
iframe.src = url;
const timeout = setTimeout(() => {
frameErr.classList.add('show');
frameErr.innerHTML = '<div><strong>Il sito non può essere caricato in iframe.</strong><br />Verifica X-Frame-Options/CSP (frame-ancestors).</div>';
}, 6000);
iframe.onload = () => {
clearTimeout(timeout);
frameErr.classList.remove('show');
};
iframe.onerror = () => {
clearTimeout(timeout);
frameErr.classList.add('show');
frameErr.innerHTML = '<div><strong>Errore di caricamento.</strong><br />Verifica che lURL sia raggiungibile.</div>';
};
}
</script>
</body>
</html>

17
home/sites.json Normal file
View file

@ -0,0 +1,17 @@
[
{
"dir": "composerize",
"name": "Composerize",
"icon": "icons/composerize.png"
},
{
"dir": "composeverter",
"name": "Composeverter",
"icon": "icons/composeverter.png"
},
{
"dir": "decomposerize",
"name": "Decomposerize",
"icon": "icons/decomposerize.png"
}
]

63
home/style.css Normal file
View file

@ -0,0 +1,63 @@
body {
font-family: sans-serif;
margin: 20px;
background: #f5f5f5;
}
h1 {
text-align: center;
margin-bottom: 30px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 15px;
text-align: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
}
.card img {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 10px;
}
.title {
font-size: 14px;
font-weight: bold;
color: #333;
}
.card.add-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed #aaa;
cursor: pointer;
}
.plus-icon {
font-size: 2em;
font-weight: bold;
color: #333;
}
.modal.hidden { display: none; }
.modal {
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
}
.modal-content {
background: #fff; padding: 20px; border-radius: 8px;
width: 300px;
}
.actions { margin-top: 10px; display: flex; gap: 10px; }

134
home/style1.css Normal file
View file

@ -0,0 +1,134 @@
body {
font-family: sans-serif;
margin: 20px;
background: #f5f5f5;
}
h1 {
text-align: center;
margin-bottom: 30px;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 20px;
}
.card {
background: white;
border-radius: 8px;
padding: 15px;
text-align: center;
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
transition: transform 0.15s ease, box-shadow 0.15s ease;
cursor: pointer;
}
.card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 10px rgba(0,0,0,0.15);
}
.card img {
width: 64px;
height: 64px;
object-fit: contain;
margin-bottom: 10px;
}
.title {
font-size: 14px;
font-weight: bold;
color: #333;
}
.card.add-card {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
border: 2px dashed #aaa;
cursor: pointer;
}
.card.add-card:hover {
background: #fafafa;
border-color: #888;
}
.plus-icon {
font-size: 2em;
font-weight: bold;
color: #333;
}
/* Modali */
.modal.hidden { display: none; }
.modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #fff;
padding: 20px;
border-radius: 8px;
width: 300px;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.modal-content h2 {
margin-top: 0;
font-size: 18px;
margin-bottom: 15px;
}
.modal-content label {
display: block;
margin-bottom: 10px;
font-size: 14px;
}
.modal-content input {
width: 100%;
padding: 6px 8px;
margin-top: 4px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
}
.actions {
margin-top: 15px;
display: flex;
justify-content: flex-end;
gap: 10px;
}
.actions button {
padding: 8px 14px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.actions button:hover {
opacity: 0.9;
}
.actions .danger {
background: #e03a3a;
color: #fff;
}
.actions button:not(.danger) {
background: #eee;
color: #333;

5
make_server_docker.sh Executable file
View file

@ -0,0 +1,5 @@
#!/bin/bash
echo "commento queste righe"
grep -n "dotenv" server.js | grep -v "//"
sed '/dotenv/ s/^/\/\//' server.js > server_docker.js
echo "Creato server_docker.js con le righe commentate."

1628
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"type": "module",
"dependencies": {
"body-parser": "^2.2.1",
"child_process": "^1.0.2",
"dotenv": "^17.2.3",
"express": "^5.2.1",
"fs-extra": "^11.3.2",
"multer": "^2.0.2",
"sharp": "^0.34.5"
}
}

4
requirements.txt Normal file
View file

@ -0,0 +1,4 @@
requests~=2.32.4
beautifulsoup4~=4.13.4
wget~=3.2
urllib3~=2.5.0

330
server.js Normal file
View file

@ -0,0 +1,330 @@
import express from "express";
import dotenv from "dotenv";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import { generateSitesJson, delFieldDir, addFieldDir } from "./generateSitesJson.js";
import bodyParser from "body-parser";
import { execFile } from "child_process";
import fse from "fs-extra";
import multer from 'multer';
import sharp from 'sharp';
let sites = generateSitesJson();
//sites.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
//console.log(sites);
//dotenv.config();
dotenv.config({ path: './.env' });
const app = express();
app.use(bodyParser.json());
// ricostruisci __dirname in ambiente ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Root sicura per le cartelle dei siti: ./sites
const SITES_ROOT = path.resolve(__dirname,'home' , 'sites');
const HOME_DIR = path.resolve(__dirname, 'home');
const SITES_JSON = path.resolve(HOME_DIR, 'sites.json');
const DOWNLOAD_SITES = 'home/sites';
// Leggi variabili da .env
const HOST = process.env.HOST || "0.0.0.0";
const PORT = process.env.PORT || 3000;
const TYPE = process.env.TYPE || "http";
const URL = process.env.URL || "https://sites.patachina2.casacam.net";
/** crea la dir icons se non esiste **/
const iconsDir = path.join(__dirname, 'home', 'icons');
fs.mkdirSync(iconsDir, { recursive: true });
/** Util: carica il manifest con fallback **/
function readManifest(mPath) {
try {
const text = fs.readFileSync(mPath, 'utf8');
return JSON.parse(text);
} catch (e) {
return {};
}
}
/** Risolve un path del sito sotto SITES_ROOT, impedendo traversal. */
function resolveSitePathSafe(dir) {
//const clean = normalizeDirField(dir);
//const abs = path.resolve(SITES_ROOT, clean);
const abs = path.resolve(SITES_ROOT, dir);
const rootWithSep = SITES_ROOT.endsWith(path.sep) ? SITES_ROOT : SITES_ROOT + path.sep;
if (!(abs === SITES_ROOT || abs.startsWith(rootWithSep))) {
throw new Error('Percorso non consentito.');
}
return abs;
}
// -------------------- Utilities --------------------
async function readJsonSafe(file) {
try {
return await fse.readJson(file);
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
}
async function writeJsonAtomic(file, data) {
const tmp = file + '.tmp';
await fse.writeJson(tmp, data, { spaces: 2 });
await fse.move(tmp, file, { overwrite: true });
}
app.post('/del-site', async (req, res) => {
try {
let { dir } = req.body || {};
//console.log("dir da cancellare: ",dir);
const siteAbsPath = resolveSitePathSafe(dir);
//console.log("path assoluto: ", siteAbsPath);
if (await fse.pathExists(siteAbsPath)) {
await fse.remove(siteAbsPath);
}
sites = delFieldDir(dir);
//await deleteIcon("icons/composerize.ico");
console.log(`la dir=${dir} è stata cancellata`);
return res.json({
ok: true,
// removed: { name: removed?.name, dir: removed?.dir },
deletedPath: siteAbsPath
});
} catch (err) {
console.error('del-site error:', err);
const msg = err?.message || 'Errore interno nella cancellazione.';
return res.status(500).json({ error: msg });
}
});
app.use((req, res, next) => {
res.removeHeader("X-Frame-Options");
res.setHeader("Content-Security-Policy", "frame-ancestors *");
next();
});
// endpoint POST /add-site
app.post("/add-site", (req, res) => {
const { url, dir } = req.body;
const dir1 = `${DOWNLOAD_SITES}/${dir}`;
// 👉 qui esegui lo script Python
execFile("downloadsite.sh", [url, dir1], (error, stdout, stderr) => {
if (error) {
console.error("Errore:", error);
return res.status(500).json({ status: "error", message: stderr });
}
sites = addFieldDir(dir);
res.json({ status: "ok", output: stdout });
});
});
//const SITES = [];
// Monta le cartelle statiche in base alla lista SITES
sites.forEach(site => {
if (site) {
// directory reale: sites/<site>
const dirPath = path.join(SITES_ROOT, site.dir);
// URL pubblico: /<site>
app.use(`/${site.dir}`, express.static(dirPath));
}
});
// Cartella public come root
//app.use("/", express.static("public"));
app.use("/", express.static("home", { index: "sidebar.html" }));
//app.use("/root", express.static("public"));
app.use("/home", express.static("home"));
app.use("/settings", express.static("home", { index: "settings.html" }));
app.use("/manifest", express.static("home", { index: "manifest-editor.html" }));
/*
app.use('/manifest', express.static(path.join(process.cwd(), 'home'), {
index: 'manifest-editor.html',
redirect: false // opzionale: evita 301 /manifest -> /manifest/
}));
*/
// Endpoint per esporre la config al client
app.get("/config.json", (req, res) => {
res.json({
host: HOST,
port: PORT,
// sites: SITES,
s: sites,
url: URL
});
});
const upload1 = multer();
/** GET: manifest corrente **/
app.post('/api/manifest', upload1.none(), (req, res) => {
//app.get('/api/manifest', (req, res) => {
try {
//console.log(req.body.dir);
const mydir = req.body.dir;
const manifest = readManifest(path.join(SITES_ROOT, mydir , 'manifest.json'));
res.json(manifest);
} catch (e) {
res.status(500).json({ error: 'Impossibile leggere manifest.json' });
}
});
/** PUT: salva il manifest (campi + icone) **/
app.put('/api/manifest', upload1.none(), (req, res) => {
// Validazioni essenziali
const mydir = req.body.dir;
const incoming = JSON.parse(req.body.manifest);
//console.log("manifest put");
//console.log("incoming=",incoming);
//console.log("mydir=",mydir);
const manPath = path.join(SITES_ROOT, mydir , 'manifest.json');
//console.log("manPath",manPath);
if (!incoming.name || !incoming.short_name) {
return res.status(400).json({ error: 'name e short_name sono obbligatori' });
}
//console.log("step1");
if (!Array.isArray(incoming.icons)) incoming.icons = [];
//console.log("step2");
try {
fs.writeFileSync(manPath, JSON.stringify(incoming, null, 2), 'utf8');
console.log(`manifest di <${mydir}> salvato`);
generateSitesJson();
res.json({ ok: true });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Salvataggio manifest fallito' });
}
});
/** Multer: configurazione upload (in memoria) **/
const upload = multer({
storage: multer.memoryStorage(),
fileFilter: (req, file, cb) => {
// Accetta i tipi più comuni
const okTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/jpg'];
if (file && okTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Formato non supportato: ${file ? file.mimetype : 'Nessun file'}`));
}
},
limits: { fileSize: 8 * 1024 * 1024 } // 8 MB
});
/** POST: upload icona + generazione 192 e 512 **/
app.post('/api/upload-icon', upload.single('icon'), async (req, res) => {
try {
// Log diagnostico utile
/* console.log('Upload Content-Type:', req.headers['content-type']);
console.log('Upload body keys:', Object.keys(req.body || {}));
console.log('Upload file:', req.file && {
fieldname: req.file.fieldname,
mimetype: req.file.mimetype,
size: req.file.size,
originalname: req.file.originalname
});
*/
const mydir = req.body.mydir;
//console.log("dir=",mydir);
const iconsMyDir = path.join(SITES_ROOT,mydir , 'icons');
fs.mkdirSync(iconsMyDir, { recursive: true });
if (!req.file) {
return res.status(400).json({
error: 'Nessun file ricevuto nel campo "icon". Assicurati che linput abbia name="icon" e che il JS usi FormData.append("icon", file).'
});
}
const purpose = (req.body.purpose || 'any').trim();
const baseNameSafe = (req.file.originalname || 'icon')
.replace(/\.[^.]+$/, '') // togli estensione
.replace(/[^a-zA-Z0-9-_]/g, ''); // pulisci caratteri strani
// Puoi aggiungere altre dimensioni se vuoi
const sizes = [192, 512];
const created = [];
for (const size of sizes) {
const filename = `${baseNameSafe}-${size}.png`;
const outPath = path.join(path.join(SITES_ROOT, mydir, 'icons'), filename);
await sharp(req.file.buffer)
.resize(size, size, { fit: 'cover' })
.png({ quality: 90 })
.toFile(outPath);
created.push({
src: `/icons/${filename}`,
sizes: `${size}x${size}`,
type: 'image/png',
purpose
});
}
generateSitesJson();
res.json({ ok: true, icons: created });
} catch (e) {
console.error('Errore /api/upload-icon:', e);
res.status(500).json({ error: 'Elaborazione icona fallita' });
}
});
/**
* DELETE: rimuove icona dal manifest e opzionalmente elimina il file
* query:
* - src (richiesto): percorso icona (es. /icons/icon-192.png)
* - removeFile=true|false (opzionale): se true elimina anche il file se sotto /public/icons
*/
app.delete('/api/icons', (req, res) => {
const src = req.query.src;
const removeFile = (req.query.removeFile || 'false') === 'true';
const mydir = req.query.myDir;
const manPath = path.join(SITES_ROOT, mydir , 'manifest.json');
//console.log("delete icon:");
//console.log("src=",src);
//console.log("mydir=",mydir);
if (!src) return res.status(400).json({ error: 'Parametro src mancante' });
try {
const manifest = readManifest(manPath);
manifest.icons = (manifest.icons || []).filter(i => i.src !== src);
if (removeFile) {
const absFilePath = path.join(SITES_ROOT, mydir, src.replace(/^\//, ''));
const isUnderIcons = absFilePath.startsWith(iconsDir + path.sep);
if (isUnderIcons && fs.existsSync(absFilePath)) {
fs.unlinkSync(absFilePath);
}
}
fs.writeFileSync(manPath, JSON.stringify(manifest, null, 2), 'utf8');
generateSitesJson();
res.json({ ok: true, removedFromManifest: true, fileDeleted: removeFile });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Eliminazione icona fallita' });
}
});
app.listen(PORT, HOST, () => {
console.log(`✅ Server pronto su http://${HOST}:${PORT}`);
});

330
server_docker.js Normal file
View file

@ -0,0 +1,330 @@
import express from "express";
//import dotenv from "dotenv";
import path from "path";
import { fileURLToPath } from "url";
import fs from "fs";
import { generateSitesJson, delFieldDir, addFieldDir } from "./generateSitesJson.js";
import bodyParser from "body-parser";
import { execFile } from "child_process";
import fse from "fs-extra";
import multer from 'multer';
import sharp from 'sharp';
let sites = generateSitesJson();
//sites.unshift({ dir: 'home', name: 'Home', icon: 'home.ico' });
//console.log(sites);
////dotenv.config();
//dotenv.config({ path: './.env' });
const app = express();
app.use(bodyParser.json());
// ricostruisci __dirname in ambiente ESM
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Root sicura per le cartelle dei siti: ./sites
const SITES_ROOT = path.resolve(__dirname,'home' , 'sites');
const HOME_DIR = path.resolve(__dirname, 'home');
const SITES_JSON = path.resolve(HOME_DIR, 'sites.json');
const DOWNLOAD_SITES = 'home/sites';
// Leggi variabili da .env
const HOST = process.env.HOST || "0.0.0.0";
const PORT = process.env.PORT || 3000;
const TYPE = process.env.TYPE || "http";
const URL = process.env.URL || "https://sites.patachina2.casacam.net";
/** crea la dir icons se non esiste **/
const iconsDir = path.join(__dirname, 'home', 'icons');
fs.mkdirSync(iconsDir, { recursive: true });
/** Util: carica il manifest con fallback **/
function readManifest(mPath) {
try {
const text = fs.readFileSync(mPath, 'utf8');
return JSON.parse(text);
} catch (e) {
return {};
}
}
/** Risolve un path del sito sotto SITES_ROOT, impedendo traversal. */
function resolveSitePathSafe(dir) {
//const clean = normalizeDirField(dir);
//const abs = path.resolve(SITES_ROOT, clean);
const abs = path.resolve(SITES_ROOT, dir);
const rootWithSep = SITES_ROOT.endsWith(path.sep) ? SITES_ROOT : SITES_ROOT + path.sep;
if (!(abs === SITES_ROOT || abs.startsWith(rootWithSep))) {
throw new Error('Percorso non consentito.');
}
return abs;
}
// -------------------- Utilities --------------------
async function readJsonSafe(file) {
try {
return await fse.readJson(file);
} catch (err) {
if (err.code === 'ENOENT') return [];
throw err;
}
}
async function writeJsonAtomic(file, data) {
const tmp = file + '.tmp';
await fse.writeJson(tmp, data, { spaces: 2 });
await fse.move(tmp, file, { overwrite: true });
}
app.post('/del-site', async (req, res) => {
try {
let { dir } = req.body || {};
//console.log("dir da cancellare: ",dir);
const siteAbsPath = resolveSitePathSafe(dir);
//console.log("path assoluto: ", siteAbsPath);
if (await fse.pathExists(siteAbsPath)) {
await fse.remove(siteAbsPath);
}
sites = delFieldDir(dir);
//await deleteIcon("icons/composerize.ico");
console.log(`la dir=${dir} è stata cancellata`);
return res.json({
ok: true,
// removed: { name: removed?.name, dir: removed?.dir },
deletedPath: siteAbsPath
});
} catch (err) {
console.error('del-site error:', err);
const msg = err?.message || 'Errore interno nella cancellazione.';
return res.status(500).json({ error: msg });
}
});
app.use((req, res, next) => {
res.removeHeader("X-Frame-Options");
res.setHeader("Content-Security-Policy", "frame-ancestors *");
next();
});
// endpoint POST /add-site
app.post("/add-site", (req, res) => {
const { url, dir } = req.body;
const dir1 = `${DOWNLOAD_SITES}/${dir}`;
// 👉 qui esegui lo script Python
execFile("downloadsite.sh", [url, dir1], (error, stdout, stderr) => {
if (error) {
console.error("Errore:", error);
return res.status(500).json({ status: "error", message: stderr });
}
sites = addFieldDir(dir);
res.json({ status: "ok", output: stdout });
});
});
//const SITES = [];
// Monta le cartelle statiche in base alla lista SITES
sites.forEach(site => {
if (site) {
// directory reale: sites/<site>
const dirPath = path.join(SITES_ROOT, site.dir);
// URL pubblico: /<site>
app.use(`/${site.dir}`, express.static(dirPath));
}
});
// Cartella public come root
//app.use("/", express.static("public"));
app.use("/", express.static("home", { index: "sidebar.html" }));
//app.use("/root", express.static("public"));
app.use("/home", express.static("home"));
app.use("/settings", express.static("home", { index: "settings.html" }));
app.use("/manifest", express.static("home", { index: "manifest-editor.html" }));
/*
app.use('/manifest', express.static(path.join(process.cwd(), 'home'), {
index: 'manifest-editor.html',
redirect: false // opzionale: evita 301 /manifest -> /manifest/
}));
*/
// Endpoint per esporre la config al client
app.get("/config.json", (req, res) => {
res.json({
host: HOST,
port: PORT,
// sites: SITES,
s: sites,
url: URL
});
});
const upload1 = multer();
/** GET: manifest corrente **/
app.post('/api/manifest', upload1.none(), (req, res) => {
//app.get('/api/manifest', (req, res) => {
try {
//console.log(req.body.dir);
const mydir = req.body.dir;
const manifest = readManifest(path.join(SITES_ROOT, mydir , 'manifest.json'));
res.json(manifest);
} catch (e) {
res.status(500).json({ error: 'Impossibile leggere manifest.json' });
}
});
/** PUT: salva il manifest (campi + icone) **/
app.put('/api/manifest', upload1.none(), (req, res) => {
// Validazioni essenziali
const mydir = req.body.dir;
const incoming = JSON.parse(req.body.manifest);
//console.log("manifest put");
//console.log("incoming=",incoming);
//console.log("mydir=",mydir);
const manPath = path.join(SITES_ROOT, mydir , 'manifest.json');
//console.log("manPath",manPath);
if (!incoming.name || !incoming.short_name) {
return res.status(400).json({ error: 'name e short_name sono obbligatori' });
}
//console.log("step1");
if (!Array.isArray(incoming.icons)) incoming.icons = [];
//console.log("step2");
try {
fs.writeFileSync(manPath, JSON.stringify(incoming, null, 2), 'utf8');
console.log(`manifest di <${mydir}> salvato`);
generateSitesJson();
res.json({ ok: true });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Salvataggio manifest fallito' });
}
});
/** Multer: configurazione upload (in memoria) **/
const upload = multer({
storage: multer.memoryStorage(),
fileFilter: (req, file, cb) => {
// Accetta i tipi più comuni
const okTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/jpg'];
if (file && okTypes.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`Formato non supportato: ${file ? file.mimetype : 'Nessun file'}`));
}
},
limits: { fileSize: 8 * 1024 * 1024 } // 8 MB
});
/** POST: upload icona + generazione 192 e 512 **/
app.post('/api/upload-icon', upload.single('icon'), async (req, res) => {
try {
// Log diagnostico utile
/* console.log('Upload Content-Type:', req.headers['content-type']);
console.log('Upload body keys:', Object.keys(req.body || {}));
console.log('Upload file:', req.file && {
fieldname: req.file.fieldname,
mimetype: req.file.mimetype,
size: req.file.size,
originalname: req.file.originalname
});
*/
const mydir = req.body.mydir;
//console.log("dir=",mydir);
const iconsMyDir = path.join(SITES_ROOT,mydir , 'icons');
fs.mkdirSync(iconsMyDir, { recursive: true });
if (!req.file) {
return res.status(400).json({
error: 'Nessun file ricevuto nel campo "icon". Assicurati che linput abbia name="icon" e che il JS usi FormData.append("icon", file).'
});
}
const purpose = (req.body.purpose || 'any').trim();
const baseNameSafe = (req.file.originalname || 'icon')
.replace(/\.[^.]+$/, '') // togli estensione
.replace(/[^a-zA-Z0-9-_]/g, ''); // pulisci caratteri strani
// Puoi aggiungere altre dimensioni se vuoi
const sizes = [192, 512];
const created = [];
for (const size of sizes) {
const filename = `${baseNameSafe}-${size}.png`;
const outPath = path.join(path.join(SITES_ROOT, mydir, 'icons'), filename);
await sharp(req.file.buffer)
.resize(size, size, { fit: 'cover' })
.png({ quality: 90 })
.toFile(outPath);
created.push({
src: `/icons/${filename}`,
sizes: `${size}x${size}`,
type: 'image/png',
purpose
});
}
generateSitesJson();
res.json({ ok: true, icons: created });
} catch (e) {
console.error('Errore /api/upload-icon:', e);
res.status(500).json({ error: 'Elaborazione icona fallita' });
}
});
/**
* DELETE: rimuove icona dal manifest e opzionalmente elimina il file
* query:
* - src (richiesto): percorso icona (es. /icons/icon-192.png)
* - removeFile=true|false (opzionale): se true elimina anche il file se sotto /public/icons
*/
app.delete('/api/icons', (req, res) => {
const src = req.query.src;
const removeFile = (req.query.removeFile || 'false') === 'true';
const mydir = req.query.myDir;
const manPath = path.join(SITES_ROOT, mydir , 'manifest.json');
//console.log("delete icon:");
//console.log("src=",src);
//console.log("mydir=",mydir);
if (!src) return res.status(400).json({ error: 'Parametro src mancante' });
try {
const manifest = readManifest(manPath);
manifest.icons = (manifest.icons || []).filter(i => i.src !== src);
if (removeFile) {
const absFilePath = path.join(SITES_ROOT, mydir, src.replace(/^\//, ''));
const isUnderIcons = absFilePath.startsWith(iconsDir + path.sep);
if (isUnderIcons && fs.existsSync(absFilePath)) {
fs.unlinkSync(absFilePath);
}
}
fs.writeFileSync(manPath, JSON.stringify(manifest, null, 2), 'utf8');
generateSitesJson();
res.json({ ok: true, removedFromManifest: true, fileDeleted: removeFile });
} catch (e) {
console.error(e);
res.status(500).json({ error: 'Eliminazione icona fallita' });
}
});
app.listen(PORT, HOST, () => {
console.log(`✅ Server pronto su http://${HOST}:${PORT}`);
});

219
web_scraper.log Normal file
View file

@ -0,0 +1,219 @@
07:57:22 | INFO | MainThread | [1/100] https://www.composerize.com
07:57:22 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
07:57:22 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
07:57:22 | DEBUG | MainThread | Created directory sites/mio
07:57:22 | DEBUG | MainThread | Saved page sites/mio/index.html
07:57:22 | DEBUG | DL-2 | Starting new HTTPS connection (2): www.composerize.com:443
07:57:22 | DEBUG | DL-1 | Starting new HTTPS connection (3): www.composerize.com:443
07:57:22 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
07:57:22 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 None
07:57:22 | DEBUG | DL-3 | Saved resource -> sites/mio/favicon.ico
07:57:22 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
07:57:22 | DEBUG | DL-4 | Saved resource -> sites/mio/manifest.json
07:57:22 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 None
07:57:22 | DEBUG | DL-1 | Created directory sites/mio/static/css
07:57:22 | DEBUG | DL-1 | Saved resource -> sites/mio/static/css/main.757d3484.css
07:57:22 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 None
07:57:22 | DEBUG | DL-2 | Created directory sites/mio/static/js
07:57:23 | DEBUG | DL-2 | Saved resource -> sites/mio/static/js/main.623047e0.js
07:57:23 | INFO | MainThread | Crawl finished: 1 pages in 1.03s (1.03s avg)
08:00:47 | INFO | MainThread | [1/100] https://www.composerize.com
08:00:47 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
08:00:47 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
08:00:47 | DEBUG | MainThread | Saved page sites/mio/index.html
08:00:47 | INFO | MainThread | Crawl finished: 1 pages in 0.14s (0.14s avg)
08:11:57 | INFO | MainThread | [1/100] https://www.composerize.com
08:11:57 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
08:11:57 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
08:11:57 | DEBUG | MainThread | Saved page sites/mio/index.html
08:11:57 | INFO | MainThread | Crawl finished: 1 pages in 0.12s (0.12s avg)
08:23:16 | INFO | MainThread | [1/100] https://www.composerize.com
08:23:16 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
08:23:16 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
08:23:16 | DEBUG | MainThread | Created directory home/sites/mio
08:23:16 | DEBUG | MainThread | Saved page home/sites/mio/index.html
08:23:16 | DEBUG | DL-3 | Starting new HTTPS connection (2): www.composerize.com:443
08:23:16 | DEBUG | DL-2 | Starting new HTTPS connection (3): www.composerize.com:443
08:23:16 | DEBUG | DL-1 | Starting new HTTPS connection (4): www.composerize.com:443
08:23:16 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
08:23:16 | DEBUG | DL-4 | Saved resource -> home/sites/mio/favicon.ico
08:23:16 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
08:23:16 | DEBUG | DL-1 | Saved resource -> home/sites/mio/manifest.json
08:23:16 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
08:23:16 | DEBUG | DL-3 | Created directory home/sites/mio/static/css
08:23:16 | DEBUG | DL-3 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
08:23:16 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
08:23:16 | DEBUG | DL-2 | Created directory home/sites/mio/static/js
08:23:16 | DEBUG | DL-2 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
08:23:16 | INFO | MainThread | Crawl finished: 1 pages in 0.39s (0.39s avg)
08:25:06 | INFO | MainThread | [1/100] https://www.composerize.com
08:25:06 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
08:25:06 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
08:25:06 | DEBUG | MainThread | Created directory home/sites/mio
08:25:06 | DEBUG | MainThread | Saved page home/sites/mio/index.html
08:25:06 | DEBUG | DL-1 | Starting new HTTPS connection (2): www.composerize.com:443
08:25:06 | DEBUG | DL-2 | Starting new HTTPS connection (3): www.composerize.com:443
08:25:06 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
08:25:06 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
08:25:06 | DEBUG | DL-3 | Saved resource -> home/sites/mio/manifest.json
08:25:06 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
08:25:06 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
08:25:06 | DEBUG | DL-2 | Created directory home/sites/mio/static/js
08:25:06 | DEBUG | DL-1 | Saved resource -> home/sites/mio/favicon.ico
08:25:06 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
08:25:06 | DEBUG | DL-4 | Created directory home/sites/mio/static/css
08:25:06 | DEBUG | DL-4 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
08:25:07 | DEBUG | DL-2 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
08:25:07 | INFO | MainThread | Crawl finished: 1 pages in 0.30s (0.30s avg)
08:54:46 | INFO | MainThread | [1/100] https://www.composerize.com
08:54:46 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
08:54:46 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
08:54:46 | DEBUG | MainThread | Created directory home/sites/mio
08:54:46 | DEBUG | DL-3 | Starting new HTTPS connection (2): www.composerize.com:443
08:54:46 | DEBUG | DL-1 | Starting new HTTPS connection (3): www.composerize.com:443
08:54:46 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
08:54:46 | DEBUG | MainThread | Saved page home/sites/mio/index.html
08:54:46 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
08:54:46 | DEBUG | DL-2 | Saved resource -> home/sites/mio/manifest.json
08:54:46 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
08:54:46 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
08:54:46 | DEBUG | DL-4 | Created directory home/sites/mio/static/js
08:54:46 | DEBUG | DL-1 | Saved resource -> home/sites/mio/favicon.ico
08:54:46 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
08:54:46 | DEBUG | DL-3 | Created directory home/sites/mio/static/css
08:54:46 | DEBUG | DL-3 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
08:54:46 | DEBUG | DL-4 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
08:54:46 | INFO | MainThread | Crawl finished: 1 pages in 0.49s (0.49s avg)
08:57:24 | INFO | MainThread | [1/100] https://www.composerize.com
08:57:24 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
08:57:24 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
08:57:24 | DEBUG | MainThread | Created directory home/sites/mio
08:57:24 | DEBUG | MainThread | Saved page home/sites/mio/index.html
08:57:24 | DEBUG | DL-3 | Starting new HTTPS connection (2): www.composerize.com:443
08:57:24 | DEBUG | DL-4 | Starting new HTTPS connection (3): www.composerize.com:443
08:57:24 | DEBUG | DL-1 | Starting new HTTPS connection (4): www.composerize.com:443
08:57:24 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
08:57:24 | DEBUG | DL-2 | Created directory home/sites/mio/static/js
08:57:24 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
08:57:24 | DEBUG | DL-3 | Saved resource -> home/sites/mio/favicon.ico
08:57:24 | DEBUG | DL-2 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
08:57:24 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
08:57:24 | DEBUG | DL-4 | Saved resource -> home/sites/mio/manifest.json
08:57:24 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
08:57:24 | DEBUG | DL-1 | Created directory home/sites/mio/static/css
08:57:24 | DEBUG | DL-1 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
08:57:24 | INFO | MainThread | Crawl finished: 1 pages in 0.30s (0.30s avg)
09:00:47 | INFO | MainThread | [1/100] https://www.composerize.com
09:00:47 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
09:00:47 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
09:00:47 | DEBUG | DL-4 | Starting new HTTPS connection (2): www.composerize.com:443
09:00:47 | DEBUG | DL-2 | Starting new HTTPS connection (3): www.composerize.com:443
09:00:47 | DEBUG | MainThread | Created directory home/sites/mio
09:00:47 | DEBUG | MainThread | Saved page home/sites/mio/index.html
09:00:47 | DEBUG | DL-1 | Starting new HTTPS connection (4): www.composerize.com:443
09:00:47 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
09:00:47 | DEBUG | DL-3 | Saved resource -> home/sites/mio/favicon.ico
09:00:47 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
09:00:47 | DEBUG | DL-4 | Created directory home/sites/mio/static/css
09:00:47 | DEBUG | DL-4 | Saved resource -> home/sites/mio/static/css/main.757d3484.css
09:00:47 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
09:00:47 | DEBUG | DL-2 | Saved resource -> home/sites/mio/manifest.json
09:00:47 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
09:00:47 | DEBUG | DL-1 | Created directory home/sites/mio/static/js
09:00:47 | DEBUG | DL-1 | Saved resource -> home/sites/mio/static/js/main.623047e0.js
09:00:47 | INFO | MainThread | Crawl finished: 1 pages in 0.40s (0.40s avg)
09:41:16 | INFO | MainThread | [1/100] https://www.composerize.com
09:41:16 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
09:41:16 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
09:41:16 | DEBUG | MainThread | Created directory home/sites/composterize
09:41:16 | DEBUG | MainThread | Saved page home/sites/composterize/index.html
09:41:16 | DEBUG | DL-4 | Starting new HTTPS connection (2): www.composerize.com:443
09:41:16 | DEBUG | DL-1 | Starting new HTTPS connection (3): www.composerize.com:443
09:41:16 | DEBUG | DL-2 | Starting new HTTPS connection (4): www.composerize.com:443
09:41:16 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
09:41:16 | DEBUG | DL-3 | Saved resource -> home/sites/composterize/favicon.ico
09:41:16 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
09:41:16 | DEBUG | DL-4 | Saved resource -> home/sites/composterize/manifest.json
09:41:16 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
09:41:16 | DEBUG | DL-2 | Created directory home/sites/composterize/static/js
09:41:16 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
09:41:16 | DEBUG | DL-1 | Created directory home/sites/composterize/static/css
09:41:16 | DEBUG | DL-1 | Saved resource -> home/sites/composterize/static/css/main.757d3484.css
09:41:16 | DEBUG | DL-2 | Saved resource -> home/sites/composterize/static/js/main.623047e0.js
09:41:16 | INFO | MainThread | Crawl finished: 1 pages in 0.44s (0.44s avg)
09:46:06 | INFO | MainThread | [1/100] https://www.composerize.com
09:46:06 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
09:46:06 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
09:46:06 | DEBUG | MainThread | Created directory home/sites/composerize
09:46:06 | DEBUG | MainThread | Saved page home/sites/composerize/index.html
09:46:06 | DEBUG | DL-2 | Starting new HTTPS connection (2): www.composerize.com:443
09:46:06 | DEBUG | DL-1 | Starting new HTTPS connection (3): www.composerize.com:443
09:46:06 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
09:46:06 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
09:46:06 | DEBUG | DL-3 | Saved resource -> home/sites/composerize/manifest.json
09:46:06 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
09:46:06 | DEBUG | DL-4 | Created directory home/sites/composerize/static/js
09:46:06 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
09:46:06 | DEBUG | DL-2 | Saved resource -> home/sites/composerize/favicon.ico
09:46:06 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
09:46:06 | DEBUG | DL-1 | Created directory home/sites/composerize/static/css
09:46:06 | DEBUG | DL-1 | Saved resource -> home/sites/composerize/static/css/main.757d3484.css
09:46:06 | DEBUG | DL-4 | Saved resource -> home/sites/composerize/static/js/main.623047e0.js
09:46:06 | INFO | MainThread | Crawl finished: 1 pages in 0.31s (0.31s avg)
10:17:29 | INFO | MainThread | [1/100] https://www.composerize.com
10:17:29 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
10:17:29 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
10:17:29 | DEBUG | MainThread | Created directory home/sites/composerize
10:17:29 | DEBUG | MainThread | Saved page home/sites/composerize/index.html
10:17:29 | DEBUG | DL-1 | Starting new HTTPS connection (2): www.composerize.com:443
10:17:29 | DEBUG | DL-3 | Starting new HTTPS connection (3): www.composerize.com:443
10:17:29 | DEBUG | DL-2 | Starting new HTTPS connection (4): www.composerize.com:443
10:17:29 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
10:17:29 | DEBUG | DL-4 | Saved resource -> home/sites/composerize/favicon.ico
10:17:29 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
10:17:29 | DEBUG | DL-3 | Saved resource -> home/sites/composerize/manifest.json
10:17:29 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
10:17:29 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
10:17:29 | DEBUG | DL-2 | Created directory home/sites/composerize/static/js
10:17:29 | DEBUG | DL-1 | Created directory home/sites/composerize/static/css
10:17:29 | DEBUG | DL-1 | Saved resource -> home/sites/composerize/static/css/main.757d3484.css
10:17:30 | DEBUG | DL-2 | Saved resource -> home/sites/composerize/static/js/main.623047e0.js
10:17:30 | INFO | MainThread | Crawl finished: 1 pages in 0.41s (0.41s avg)
10:23:58 | INFO | MainThread | [1/100] https://www.composerize.com
10:23:58 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
10:23:58 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
10:23:58 | DEBUG | DL-3 | Starting new HTTPS connection (2): www.composerize.com:443
10:23:58 | DEBUG | MainThread | Created directory home/sites/composerize
10:23:58 | DEBUG | MainThread | Saved page home/sites/composerize/index.html
10:23:58 | DEBUG | DL-4 | Starting new HTTPS connection (3): www.composerize.com:443
10:23:58 | DEBUG | DL-1 | Starting new HTTPS connection (4): www.composerize.com:443
10:23:58 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
10:23:58 | DEBUG | DL-2 | Saved resource -> home/sites/composerize/manifest.json
10:23:58 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
10:23:58 | DEBUG | DL-3 | Saved resource -> home/sites/composerize/favicon.ico
10:23:59 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
10:23:59 | DEBUG | DL-4 | Created directory home/sites/composerize/static/js
10:23:59 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
10:23:59 | DEBUG | DL-1 | Created directory home/sites/composerize/static/css
10:23:59 | DEBUG | DL-1 | Saved resource -> home/sites/composerize/static/css/main.757d3484.css
10:23:59 | DEBUG | DL-4 | Saved resource -> home/sites/composerize/static/js/main.623047e0.js
10:23:59 | INFO | MainThread | Crawl finished: 1 pages in 0.31s (0.31s avg)
10:27:36 | INFO | MainThread | [1/100] https://www.composerize.com
10:27:36 | DEBUG | MainThread | Starting new HTTPS connection (1): www.composerize.com:443
10:27:36 | DEBUG | MainThread | https://www.composerize.com:443 "GET / HTTP/1.1" 200 867
10:27:36 | DEBUG | MainThread | Created directory home/sites/composerize
10:27:36 | DEBUG | MainThread | Saved page home/sites/composerize/index.html
10:27:36 | DEBUG | DL-1 | Starting new HTTPS connection (2): www.composerize.com:443
10:27:36 | DEBUG | DL-2 | Starting new HTTPS connection (3): www.composerize.com:443
10:27:36 | DEBUG | DL-4 | Starting new HTTPS connection (4): www.composerize.com:443
10:27:37 | DEBUG | DL-3 | https://www.composerize.com:443 "GET /manifest.json HTTP/1.1" 200 288
10:27:37 | DEBUG | DL-3 | Saved resource -> home/sites/composerize/manifest.json
10:27:37 | DEBUG | DL-4 | https://www.composerize.com:443 "GET /static/css/main.757d3484.css HTTP/1.1" 200 1761
10:27:37 | DEBUG | DL-4 | Created directory home/sites/composerize/static/css
10:27:37 | DEBUG | DL-4 | Saved resource -> home/sites/composerize/static/css/main.757d3484.css
10:27:37 | DEBUG | DL-1 | https://www.composerize.com:443 "GET /favicon.ico HTTP/1.1" 200 3430
10:27:37 | DEBUG | DL-1 | Saved resource -> home/sites/composerize/favicon.ico
10:27:37 | DEBUG | DL-2 | https://www.composerize.com:443 "GET /static/js/main.623047e0.js HTTP/1.1" 200 176652
10:27:37 | DEBUG | DL-2 | Created directory home/sites/composerize/static/js
10:27:37 | DEBUG | DL-2 | Saved resource -> home/sites/composerize/static/js/main.623047e0.js
10:27:37 | INFO | MainThread | Crawl finished: 1 pages in 0.39s (0.39s avg)

406
website-downloader.py Executable file
View file

@ -0,0 +1,406 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import logging
import os
import queue
import sys
import threading
import time
from hashlib import sha256
from pathlib import Path
from typing import Optional
from urllib.parse import urljoin, urlparse
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
# ---------------------------------------------------------------------------
# Config / constants
# ---------------------------------------------------------------------------
LOG_FMT = "%(asctime)s | %(levelname)-8s | %(threadName)s | %(message)s"
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64; rv:128.0) "
"Gecko/20100101 Firefox/128.0"
}
TIMEOUT = 15 # seconds
CHUNK_SIZE = 8192 # bytes
# Conservative margins under common OS limits (~255260 bytes)
MAX_PATH_LEN = 240
MAX_SEG_LEN = 120
# ---------------------------------------------------------------------------
# Logging
# ---------------------------------------------------------------------------
logging.basicConfig(
filename="web_scraper.log",
level=logging.DEBUG,
format=LOG_FMT,
datefmt="%H:%M:%S",
force=True,
)
_console = logging.StreamHandler(sys.stdout)
_console.setLevel(logging.INFO)
_console.setFormatter(logging.Formatter(LOG_FMT, datefmt="%H:%M:%S"))
logging.getLogger().addHandler(_console)
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# HTTP session (retry, timeouts, custom UA)
# ---------------------------------------------------------------------------
SESSION = requests.Session()
RETRY_STRAT = Retry(
total=5,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET", "HEAD"],
)
SESSION.mount("http://", HTTPAdapter(max_retries=RETRY_STRAT))
SESSION.mount("https://", HTTPAdapter(max_retries=RETRY_STRAT))
SESSION.headers.update(DEFAULT_HEADERS)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def create_dir(path: Path) -> None:
"""Create path (and parents) if it does not already exist."""
if not path.exists():
path.mkdir(parents=True, exist_ok=True)
log.debug("Created directory %s", path)
def sanitize(url_fragment: str) -> str:
"""Strip back-references and Windows backslashes."""
return url_fragment.replace("\\", "/").replace("..", "").strip()
NON_FETCHABLE_SCHEMES = {"mailto", "tel", "sms", "javascript", "data", "geo", "blob"}
def is_httpish(u: str) -> bool:
"""True iff the URL is http(s) or relative (no scheme)."""
p = urlparse(u)
return (p.scheme in ("http", "https")) or (p.scheme == "")
def is_non_fetchable(u: str) -> bool:
"""True iff the URL clearly shouldn't be fetched (mailto:, tel:, data:, ...)."""
p = urlparse(u)
return p.scheme in NON_FETCHABLE_SCHEMES
def is_internal(link: str, root_netloc: str) -> bool:
"""Return True if link belongs to root_netloc (or is protocol-relative)."""
parsed = urlparse(link)
return not parsed.netloc or parsed.netloc == root_netloc
def _shorten_segment(segment: str, limit: int = MAX_SEG_LEN) -> str:
"""
Shorten a single path segment if over limit.
Preserve extension; append a short hash to keep it unique.
"""
if len(segment) <= limit:
return segment
p = Path(segment)
stem, suffix = p.stem, p.suffix
h = sha256(segment.encode("utf-8")).hexdigest()[:12]
# leave room for '-' + hash + suffix
keep = max(0, limit - len(suffix) - 13)
return f"{stem[:keep]}-{h}{suffix}"
def to_local_path(parsed: urlparse, site_root: Path) -> Path:
"""
Map an internal URL to a local file path under site_root.
- Adds 'index.html' where appropriate.
- Converts extensionless paths to '.html'.
- Appends a short query-hash when ?query is present to avoid collisions.
- Enforces per-segment and overall path length limits. If still too long,
hashes the leaf name.
"""
rel = parsed.path.lstrip("/")
if not rel:
rel = "index.html"
elif rel.endswith("/"):
rel += "index.html"
elif not Path(rel).suffix:
rel += ".html"
if parsed.query:
qh = sha256(parsed.query.encode("utf-8")).hexdigest()[:10]
p = Path(rel)
rel = str(p.with_name(f"{p.stem}-q{qh}{p.suffix}"))
# Shorten individual segments
parts = Path(rel).parts
parts = tuple(_shorten_segment(seg, MAX_SEG_LEN) for seg in parts)
local_path = site_root / Path(*parts)
# If full path is still too long, hash the leaf
if len(str(local_path)) > MAX_PATH_LEN:
p = local_path
h = sha256(parsed.geturl().encode("utf-8")).hexdigest()[:16]
leaf = _shorten_segment(f"{p.stem}-{h}{p.suffix}", MAX_SEG_LEN)
local_path = p.with_name(leaf)
return local_path
def safe_write_text(path: Path, text: str, encoding: str = "utf-8") -> Path:
"""
Write text to path, falling back to a hashed filename if OS rejects it
(e.g., filename too long). Returns the final path used.
"""
try:
path.write_text(text, encoding=encoding)
return path
except OSError as exc:
log.warning("Write failed for %s: %s. Falling back to hashed leaf.", path, exc)
p = path
h = sha256(str(p).encode("utf-8")).hexdigest()[:16]
fallback = p.with_name(_shorten_segment(f"{p.stem}-{h}{p.suffix}", MAX_SEG_LEN))
create_dir(fallback.parent)
fallback.write_text(text, encoding=encoding)
return fallback
# ---------------------------------------------------------------------------
# Fetchers
# ---------------------------------------------------------------------------
def fetch_html(url: str) -> Optional[BeautifulSoup]:
"""Download url and return a BeautifulSoup tree (or None on error)."""
try:
resp = SESSION.get(url, timeout=TIMEOUT)
resp.raise_for_status()
return BeautifulSoup(resp.text, "html.parser")
except Exception as exc: # noqa: BLE001
log.warning("HTTP error for %s %s", url, exc)
return None
def fetch_binary(url: str, dest: Path) -> None:
"""Stream url to dest unless it already exists. Safe against long paths."""
if dest.exists():
return
try:
resp = SESSION.get(url, timeout=TIMEOUT, stream=True)
resp.raise_for_status()
create_dir(dest.parent)
try:
with dest.open("wb") as fh:
for chunk in resp.iter_content(CHUNK_SIZE):
fh.write(chunk)
log.debug("Saved resource -> %s", dest)
except OSError as exc:
# Fallback to hashed leaf if OS rejects path
log.warning("Binary write failed for %s: %s. Using fallback.", dest, exc)
p = dest
h = sha256(str(p).encode("utf-8")).hexdigest()[:16]
fallback = p.with_name(
_shorten_segment(f"{p.stem}-{h}{p.suffix}", MAX_SEG_LEN)
)
create_dir(fallback.parent)
with fallback.open("wb") as fh:
for chunk in resp.iter_content(CHUNK_SIZE):
fh.write(chunk)
log.debug("Saved resource (fallback) -> %s", fallback)
except Exception as exc: # noqa: BLE001
log.error("Failed to save %s %s", url, exc)
# ---------------------------------------------------------------------------
# Link rewriting
# ---------------------------------------------------------------------------
def rewrite_links(
soup: BeautifulSoup, page_url: str, site_root: Path, page_dir: Path
) -> None:
"""Rewrite internal links to local relative paths under site_root."""
root_netloc = urlparse(page_url).netloc
for tag in soup.find_all(["a", "img", "script", "link"]):
attr = "href" if tag.name in {"a", "link"} else "src"
if not tag.has_attr(attr):
continue
original = sanitize(tag[attr])
if (
original.startswith("#")
or is_non_fetchable(original)
or not is_httpish(original)
):
continue
abs_url = urljoin(page_url, original)
if not is_internal(abs_url, root_netloc):
continue # external leave untouched
local_path = to_local_path(urlparse(abs_url), site_root)
try:
tag[attr] = os.path.relpath(local_path, page_dir)
except ValueError:
# Different drives on Windows, etc.
tag[attr] = str(local_path)
# ---------------------------------------------------------------------------
# Crawl coordinator
# ---------------------------------------------------------------------------
def crawl_site(start_url: str, root: Path, max_pages: int, threads: int) -> None:
"""Breadth-first crawl limited to max_pages. Downloads assets via workers."""
q_pages: queue.Queue[str] = queue.Queue()
q_pages.put(start_url)
seen_pages: set[str] = set()
download_q: queue.Queue[tuple[str, Path]] = queue.Queue()
def worker() -> None:
while True:
try:
url, dest = download_q.get(timeout=3)
except queue.Empty:
return
if is_non_fetchable(url) or not is_httpish(url):
log.debug("Skip non-fetchable: %s", url)
download_q.task_done()
continue
fetch_binary(url, dest)
download_q.task_done()
workers: list[threading.Thread] = []
for i in range(max(1, threads)):
t = threading.Thread(target=worker, name=f"DL-{i+1}", daemon=True)
t.start()
workers.append(t)
start_time = time.time()
root_netloc = urlparse(start_url).netloc
while not q_pages.empty() and len(seen_pages) < max_pages:
page_url = q_pages.get()
if page_url in seen_pages:
continue
seen_pages.add(page_url)
log.info("[%s/%s] %s", len(seen_pages), max_pages, page_url)
soup = fetch_html(page_url)
if soup is None:
continue
# Gather links & assets
for tag in soup.find_all(["img", "script", "link", "a"]):
link = tag.get("src") or tag.get("href")
if not link:
continue
link = sanitize(link)
if link.startswith("#") or is_non_fetchable(link) or not is_httpish(link):
continue
abs_url = urljoin(page_url, link)
parsed = urlparse(abs_url)
if not is_internal(abs_url, root_netloc):
continue
dest_path = to_local_path(parsed, root)
# HTML?
if parsed.path.endswith("/") or not Path(parsed.path).suffix:
if abs_url not in seen_pages and abs_url not in list(
q_pages.queue
): # type: ignore[arg-type]
q_pages.put(abs_url)
else:
download_q.put((abs_url, dest_path))
# Save current page
local_path = to_local_path(urlparse(page_url), root)
create_dir(local_path.parent)
rewrite_links(soup, page_url, root, local_path.parent)
html = soup.prettify()
final_path = safe_write_text(local_path, html, encoding="utf-8")
log.debug("Saved page %s", final_path)
download_q.join()
elapsed = time.time() - start_time
if seen_pages:
log.info(
"Crawl finished: %s pages in %.2fs (%.2fs avg)",
len(seen_pages),
elapsed,
elapsed / len(seen_pages),
)
else:
log.warning("Nothing downloaded check URL or connectivity")
# ---------------------------------------------------------------------------
# Helper function for output folder
# ---------------------------------------------------------------------------
def make_root(url: str, custom: Optional[str]) -> Path:
"""Derive output folder from URL if custom not supplied."""
return Path(custom) if custom else Path(urlparse(url).netloc.replace(".", "_"))
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description="Recursively mirror a website for offline use.",
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
p.add_argument(
"--url",
required=True,
help="Starting URL to crawl (e.g., https://example.com/).",
)
p.add_argument(
"--destination",
default=None,
help="Output folder (defaults to a folder derived from the URL).",
)
p.add_argument(
"--max-pages",
type=int,
default=50,
help="Maximum number of HTML pages to crawl.",
)
p.add_argument(
"--threads",
type=int,
default=6,
help="Number of concurrent download workers.",
)
return p.parse_args()
if __name__ == "__main__":
args = parse_args()
if args.max_pages < 1:
log.error("--max-pages must be >= 1")
sys.exit(2)
if args.threads < 1:
log.error("--threads must be >= 1")
sys.exit(2)
host = args.url
root = make_root(args.url, args.destination)
crawl_site(host, root, args.max_pages, args.threads)