Add live-site mirror, deleted-recipe recovery, and originalpaella.es recipes
lapaella.net is back online. Collected via the apex domain (www. host has a bad TLS cert): - live-mirror/: complete wget mirror of the current site (145 pages + requisites, links converted for offline use), covering all of sitemap_index.xml incl. the blog and its recipe posts. - wayback-mirror/: only the Spanish recipes no longer on the live site, recovered from the Wayback Machine (toolbar stripped, links de-waybacked): Pimientos rojos rellenos de arroz, plus c.2009 static-site recipes Arroz con pollo/pato/caracoles, Paella de habas, Paella mixta. - originalpaella-recetas/: originalpaella.es "Recetas de paellas y arroces" blog category (id 9) only - 103 posts, 12 listing pages, 1,255 photos. - inventory.md: folder map + apex-domain note. - Scripts: recover_deleted_recipes.py, mirror_originalpaella_recetas.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FAteBZwRGH7tcBWNptKFjs
This commit is contained in:
1 parent
aa662e7fdc
commit
75cd28ea8e
1665 files changed
+520157
No files matched your search
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mirror ONLY the "Recetas de paellas y arroces para preparar en casa" blog
|
||||
category from originalpaella.es (smartblog category id 9), plus every photo
|
||||
used by those recipe posts.
|
||||
|
||||
Scope guard: we only ever fetch
|
||||
- the category listing pages /blog/9-recetas-...?page=N
|
||||
- individual post pages /blog/<id>_<slug>.html linked from those listings
|
||||
- image files on originalpaella.es referenced by those pages
|
||||
No other links are followed.
|
||||
|
||||
Output: originalpaella-recetas/
|
||||
"""
|
||||
|
||||
import os, re, sys, subprocess, tempfile, urllib.parse
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from threading import Lock
|
||||
|
||||
BASE = "https://originalpaella.es"
|
||||
HOST = "originalpaella.es"
|
||||
CAT = "/blog/9-recetas-de-paellas-y-arroces-para-preparar-en-casa"
|
||||
OUT = Path(__file__).parent / "originalpaella-recetas"
|
||||
UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/125 Safari/537.36")
|
||||
IMG_EXT = (".jpg", ".jpeg", ".png", ".webp", ".gif", ".svg", ".avif")
|
||||
POST_RE = re.compile(r'https://originalpaella\.es/blog/(\d+)_([a-z0-9\-]+)\.html', re.I)
|
||||
LOCK = Lock()
|
||||
|
||||
def log(m):
|
||||
with LOCK:
|
||||
print(m, flush=True)
|
||||
|
||||
def enc(url):
|
||||
"""Percent-encode the path/query so curl accepts spaces & parens."""
|
||||
p = urllib.parse.urlsplit(url)
|
||||
path = urllib.parse.quote(urllib.parse.unquote(p.path), safe="/%")
|
||||
query = urllib.parse.quote(urllib.parse.unquote(p.query), safe="=&%")
|
||||
return urllib.parse.urlunsplit((p.scheme, p.netloc, path, query, ""))
|
||||
|
||||
def curl(url, dest=None):
|
||||
url = enc(url)
|
||||
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".tmp"); tmp.close()
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["/usr/bin/curl", "-s", "-L", "--max-time", "60",
|
||||
"--retry", "3", "--retry-delay", "3", "--retry-all-errors",
|
||||
"-A", UA, "-w", "%{http_code}", "-o", tmp.name, url],
|
||||
capture_output=True, text=True, timeout=120)
|
||||
code = (r.stdout or "").strip()[-3:]
|
||||
data = Path(tmp.name).read_bytes()
|
||||
if code == "200" and data:
|
||||
if dest:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(data)
|
||||
return data
|
||||
return None
|
||||
except Exception as e:
|
||||
log(f" curl err {e}"); return None
|
||||
finally:
|
||||
try: os.unlink(tmp.name)
|
||||
except OSError: pass
|
||||
|
||||
def local_for(url):
|
||||
"""Map an originalpaella.es URL to a path under OUT."""
|
||||
p = urllib.parse.urlparse(url)
|
||||
path = urllib.parse.unquote(p.path).lstrip("/")
|
||||
if not path:
|
||||
path = "index.html"
|
||||
if p.query:
|
||||
q = re.sub(r'[^\w.=-]+', "_", urllib.parse.unquote(p.query))
|
||||
base, dot, ext = path.rpartition(".")
|
||||
path = f"{base}__{q}.{ext}" if dot else f"{path}__{q}.html"
|
||||
if path.endswith("/"):
|
||||
path += "index.html"
|
||||
# sanitise
|
||||
path = "/".join(re.sub(r'[<>:"\\|?*]+', "_", seg).strip() or "_" for seg in path.split("/"))
|
||||
return OUT / path
|
||||
|
||||
def absolutise(src, page_url):
|
||||
src = src.strip().strip('"\'')
|
||||
if src.startswith("//"):
|
||||
return "https:" + src
|
||||
if src.startswith(("http://", "https://")):
|
||||
return src
|
||||
if src.startswith("/"):
|
||||
return BASE + src
|
||||
return urllib.parse.urljoin(page_url, src)
|
||||
|
||||
def extract_imgs(html, page_url):
|
||||
urls = set()
|
||||
for m in re.findall(r'(?:src|data-src|data-original|content|href)=["\']([^"\']+)["\']', html, re.I):
|
||||
u = absolutise(m, page_url)
|
||||
pu = urllib.parse.urlparse(u)
|
||||
if pu.netloc.endswith(HOST) and pu.path.lower().rsplit("?", 1)[0].endswith(IMG_EXT):
|
||||
urls.add(u.split("#")[0])
|
||||
for m in re.findall(r'srcset=["\']([^"\']+)["\']', html, re.I):
|
||||
for part in m.split(","):
|
||||
cand = part.strip().split(" ")[0]
|
||||
if not cand:
|
||||
continue
|
||||
u = absolutise(cand, page_url)
|
||||
pu = urllib.parse.urlparse(u)
|
||||
if pu.netloc.endswith(HOST) and pu.path.lower().endswith(IMG_EXT):
|
||||
urls.add(u.split("#")[0])
|
||||
return urls
|
||||
|
||||
def rewrite(html, page_file, asset_urls):
|
||||
"""Point originalpaella.es img/asset refs at local relative paths."""
|
||||
def repl(m):
|
||||
attr, q, val = m.group(1), m.group(2), m.group(3)
|
||||
u = absolutise(val, BASE + "/")
|
||||
if u.split("#")[0] in asset_urls:
|
||||
rel = os.path.relpath(local_for(u), page_file.parent)
|
||||
return f'{attr}={q}{rel}{q}'
|
||||
return m.group(0)
|
||||
html = re.sub(r'(src|data-src|href)=(["\'])([^"\']+)\2', repl, html)
|
||||
return html
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
# ---- 1. walk the category pagination ----
|
||||
post_urls = {} # id -> url
|
||||
listing_pages = {} # page_no -> html
|
||||
page = 1
|
||||
while True:
|
||||
url = f"{BASE}{CAT}?page={page}"
|
||||
html = curl(url)
|
||||
if not html:
|
||||
break
|
||||
html = html.decode("utf-8", "replace")
|
||||
found = {int(i): f"https://originalpaella.es/blog/{i}_{s}.html"
|
||||
for i, s in POST_RE.findall(html)}
|
||||
new = {k: v for k, v in found.items() if k not in post_urls}
|
||||
listing_pages[page] = html
|
||||
log(f"listing page {page}: {len(found)} posts ({len(new)} new)")
|
||||
if not new and page > 1:
|
||||
break
|
||||
post_urls.update(found)
|
||||
page += 1
|
||||
if page > 40:
|
||||
break
|
||||
|
||||
log(f"\nTotal recipe posts: {len(post_urls)}")
|
||||
(OUT / "_post_urls.txt").write_text("\n".join(v for _, v in sorted(post_urls.items())))
|
||||
|
||||
# ---- 2. fetch each post, collect images ----
|
||||
all_assets = set()
|
||||
page_assets = {} # local_file -> (html, asset_urls)
|
||||
|
||||
def handle_page(url, html=None):
|
||||
if html is None:
|
||||
data = curl(url)
|
||||
if not data:
|
||||
log(f" MISS {url}")
|
||||
return
|
||||
html = data.decode("utf-8", "replace")
|
||||
imgs = extract_imgs(html, url)
|
||||
lf = local_for(url)
|
||||
page_assets[lf] = (html, imgs)
|
||||
with LOCK:
|
||||
all_assets.update(imgs)
|
||||
log(f" post {url.rsplit('/',1)[-1]} ({len(imgs)} imgs)")
|
||||
|
||||
# listing pages
|
||||
for pno, html in listing_pages.items():
|
||||
u = f"{BASE}{CAT}?page={pno}"
|
||||
handle_page(u, html)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=4) as ex:
|
||||
list(as_completed([ex.submit(handle_page, u) for u in post_urls.values()]))
|
||||
|
||||
# ---- 3. download images ----
|
||||
log(f"\nDownloading {len(all_assets)} images ...")
|
||||
ok = fail = 0
|
||||
def dl(u):
|
||||
dest = local_for(u)
|
||||
if dest.exists() and dest.stat().st_size:
|
||||
return "skip"
|
||||
return "ok" if curl(u, dest) else "fail"
|
||||
with ThreadPoolExecutor(max_workers=6) as ex:
|
||||
futs = {ex.submit(dl, u): u for u in sorted(all_assets)}
|
||||
for f in as_completed(futs):
|
||||
r = f.result()
|
||||
if r == "ok": ok += 1
|
||||
elif r == "fail":
|
||||
fail += 1; log(f" img FAIL {futs[f]}")
|
||||
log(f"images: ok={ok} fail={fail} (skip={len(all_assets)-ok-fail})")
|
||||
|
||||
# ---- 4. write rewritten HTML ----
|
||||
for lf, (html, imgs) in page_assets.items():
|
||||
lf.parent.mkdir(parents=True, exist_ok=True)
|
||||
lf.write_text(rewrite(html, lf, all_assets), encoding="utf-8")
|
||||
log(f"\nWrote {len(page_assets)} HTML pages -> {OUT}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user