diff --git a/requirements.txt b/requirements.txt index f466d71..a68ceb1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ aiosqlite>=0.19.0 fastapi>=0.104.0 uvicorn>=0.24.0 python-multipart>=0.0.6 +jinja2>=3.1.0 # Progress bar tqdm>=4.66.0 diff --git a/serp.py b/serp.py index 283a479..be8b496 100644 --- a/serp.py +++ b/serp.py @@ -28,6 +28,7 @@ from typing import List, Dict, Any, Optional from fastapi import FastAPI, Query, HTTPException, BackgroundTasks, Header, Cookie, UploadFile, File, Request from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse, Response, StreamingResponse from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates from pydantic import BaseModel from sqlalchemy import text import uvicorn @@ -636,6 +637,191 @@ TRANSLATIONS = { "total_images": "Totalt bilder", "new_session": "Nya denna session", "per_minute": "Per minut", "all_domains": "Alla domäner", "download_phantom": "Ladda ner fantomwebbplats", "phantom_subtitle": "Exportera arkiverade sidor som statisk webbplats med lokal media", "max_pages": "Max sidor", "select_domain": "Välj domän", "sources": "källor", + "source_uri": "Käll-URI", "neopig_uri": "Neopig URI", "source_page": "Källsida", "neopig_page": "Neopig-sida", + "type_label": "Typ", "mime_label": "MIME", "size_label": "Storlek", "alt_label": "Alt", + "keywords_label": "Nyckelord", "description_label": "Beskrivning", "items": "objekt", "bytes": "bytes", + "used_on": "Används på", "page": "Sida", "discovered": "Upptäckt", + "console": "Konsol", "close": "Stäng", "no_logs": "Inga loggar ännu", + }, + "zh-tw": { + "search": "搜尋", "crawl": "爬取", "live": "即時動態", "about": "關於", + "random": "隨機", "phantom": "幻影", "loading": "載入中...", + "start_crawl": "開始爬取", "pause": "暫停", "resume": "繼續", "delete": "刪除", + "url": "網址", "mode": "模式", "depth": "深度", "keywords": "關鍵字", + "screenshots": "螢幕截圖", "fast_mode": "快速模式", "fresh_start": "全新開始", + "pages": "頁面", "found": "發現", "saved": "儲存", "dupes": "重複", + "running": "執行中", "completed": "已完成", "paused": "已暫停", + "no_jobs": "暫無爬取任務", "started": "開始於", "filter": "篩選", + "all_types": "所有類型", "images": "圖片", "videos": "影片", "audio": "音訊", + "everything": "全部(文字+媒體)", "all_media": "所有媒體", + "images_only": "僅圖片", "videos_only": "僅影片", "text_only": "僅文字", + "recent_jobs": "最近任務", "search_placeholder": "搜尋媒體...", + "media": "媒體", "pages_label": "頁面", "download": "下載", + "no_media": "未找到媒體", "no_more": "沒有更多結果", "load_error": "載入失敗", + "copy": "複製", "copied": "已複製!", "starting": "啟動中...", "started_ok": "已啟動!", + "error": "錯誤", "watching": "正在監視新圖片...", "content": "內容", + "previous": "上一頁", "next": "下一頁", "over_9000": "超過9000+!", + "loading_stats": "載入統計中...", "screenshot": "螢幕截圖", + "valid_uri": "請輸入至少一個有效的URI", "load_jobs_error": "載入任務失敗", + "hydrate_subtitle": "從網路取得媒體", "live_subtitle": "觀看正在爬取的圖片", + "total_images": "總圖片數", "new_session": "本次新增", "per_minute": "每分鐘", + "all_domains": "所有網域", "download_phantom": "下載幻影網站", + "phantom_subtitle": "將存檔頁面匯出為帶本地媒體的靜態網站", "max_pages": "最大頁數", "select_domain": "選擇網域", "sources": "來源", + "source_uri": "來源URI", "neopig_uri": "Neopig URI", "source_page": "來源頁面", "neopig_page": "Neopig頁面", + "type_label": "類型", "mime_label": "MIME", "size_label": "大小", "alt_label": "替代文字", + "keywords_label": "關鍵字", "description_label": "描述", "items": "項", "bytes": "位元組", + "used_on": "使用於", "page": "頁面", "discovered": "發現時間", + "console": "主控台", "close": "關閉", "no_logs": "暫無日誌", + }, + "bn": { + "search": "অনুসন্ধান", "crawl": "ক্রল", "live": "লাইভ ফিড", "about": "সম্পর্কে", + "random": "এলোমেলো", "phantom": "ফ্যান্টম", "loading": "লোড হচ্ছে...", + "start_crawl": "ক্রল শুরু করুন", "pause": "বিরতি", "resume": "পুনরায় শুরু", "delete": "মুছুন", + "url": "URL", "mode": "মোড", "depth": "গভীরতা", "keywords": "কীওয়ার্ড", + "screenshots": "স্ক্রিনশট", "fast_mode": "দ্রুত মোড", "fresh_start": "নতুন শুরু", + "pages": "পৃষ্ঠা", "found": "পাওয়া গেছে", "saved": "সংরক্ষিত", "dupes": "ডুপ্লিকেট", + "running": "চলছে", "completed": "সম্পন্ন", "paused": "বিরতি", + "no_jobs": "কোন ক্রল কাজ নেই", "started": "শুরু", "filter": "ফিল্টার", + "all_types": "সব ধরনের", "images": "ছবি", "videos": "ভিডিও", "audio": "অডিও", + "everything": "সবকিছু", "all_media": "সব মিডিয়া", + "images_only": "শুধু ছবি", "videos_only": "শুধু ভিডিও", "text_only": "শুধু টেক্সট", + "recent_jobs": "সাম্প্রতিক কাজ", "search_placeholder": "মিডিয়া অনুসন্ধান...", + "media": "মিডিয়া", "pages_label": "পৃষ্ঠা", "download": "ডাউনলোড", + "no_media": "কোন মিডিয়া পাওয়া যায়নি", "no_more": "আর কোন ফলাফল নেই", "load_error": "লোড ব্যর্থ", + "copy": "কপি", "copied": "কপি হয়েছে!", "starting": "শুরু হচ্ছে...", "started_ok": "শুরু হয়েছে!", + "error": "ত্রুটি", "watching": "নতুন ছবি দেখছি...", "content": "বিষয়বস্তু", + "previous": "পূর্ববর্তী", "next": "পরবর্তী", "over_9000": "৯,০০০+ এর বেশি!", + "loading_stats": "পরিসংখ্যান লোড হচ্ছে...", "screenshot": "স্ক্রিনশট", + "valid_uri": "অন্তত একটি বৈধ URI দিন", "load_jobs_error": "কাজ লোড ব্যর্থ", + "hydrate_subtitle": "ওয়েব থেকে মিডিয়া আনুন", "live_subtitle": "ক্রল হওয়া ছবি দেখুন", + "total_images": "মোট ছবি", "new_session": "এই সেশনে নতুন", "per_minute": "প্রতি মিনিটে", + "all_domains": "সব ডোমেইন", "download_phantom": "ফ্যান্টম সাইট ডাউনলোড", + "phantom_subtitle": "সংরক্ষিত পৃষ্ঠা স্থানীয় মিডিয়া সহ স্ট্যাটিক সাইট হিসেবে রপ্তানি", "max_pages": "সর্বোচ্চ পৃষ্ঠা", "select_domain": "ডোমেইন নির্বাচন", "sources": "সূত্র", + "source_uri": "উৎস URI", "neopig_uri": "Neopig URI", "source_page": "উৎস পৃষ্ঠা", "neopig_page": "Neopig পৃষ্ঠা", + "type_label": "ধরন", "mime_label": "MIME", "size_label": "আকার", "alt_label": "Alt", + "keywords_label": "কীওয়ার্ড", "description_label": "বিবরণ", "items": "আইটেম", "bytes": "বাইট", + "used_on": "ব্যবহৃত", "page": "পৃষ্ঠা", "discovered": "আবিষ্কৃত", + "console": "কনসোল", "close": "বন্ধ", "no_logs": "কোন লগ নেই", + }, + "ur": { + "search": "تلاش", "crawl": "کرال", "live": "لائیو فیڈ", "about": "کے بارے میں", + "random": "بے ترتیب", "phantom": "فینٹم", "loading": "لوڈ ہو رہا ہے...", + "start_crawl": "کرال شروع کریں", "pause": "روکیں", "resume": "جاری رکھیں", "delete": "حذف کریں", + "url": "یو آر ایل", "mode": "موڈ", "depth": "گہرائی", "keywords": "کلیدی الفاظ", + "screenshots": "اسکرین شاٹس", "fast_mode": "تیز موڈ", "fresh_start": "نئی شروعات", + "pages": "صفحات", "found": "ملے", "saved": "محفوظ", "dupes": "ڈپلیکیٹ", + "running": "چل رہا ہے", "completed": "مکمل", "paused": "روکا ہوا", + "no_jobs": "کوئی کرال کام نہیں", "started": "شروع", "filter": "فلٹر", + "all_types": "تمام اقسام", "images": "تصاویر", "videos": "ویڈیوز", "audio": "آڈیو", + "everything": "سب کچھ", "all_media": "تمام میڈیا", + "images_only": "صرف تصاویر", "videos_only": "صرف ویڈیوز", "text_only": "صرف متن", + "recent_jobs": "حالیہ کام", "search_placeholder": "میڈیا تلاش کریں...", + "media": "میڈیا", "pages_label": "صفحات", "download": "ڈاؤن لوڈ", + "no_media": "کوئی میڈیا نہیں ملا", "no_more": "مزید نتائج نہیں", "load_error": "لوڈ ناکام", + "copy": "کاپی", "copied": "کاپی ہو گیا!", "starting": "شروع ہو رہا ہے...", "started_ok": "شروع ہو گیا!", + "error": "خرابی", "watching": "نئی تصاویر دیکھ رہا ہے...", "content": "مواد", + "previous": "پچھلا", "next": "اگلا", "over_9000": "9,000+ سے زیادہ!", + "loading_stats": "اعدادوشمار لوڈ ہو رہے ہیں...", "screenshot": "اسکرین شاٹ", + "valid_uri": "کم از کم ایک درست URI درج کریں", "load_jobs_error": "کام لوڈ ناکام", + "hydrate_subtitle": "ویب سے میڈیا حاصل کریں", "live_subtitle": "کرال ہوتی تصاویر دیکھیں", + "total_images": "کل تصاویر", "new_session": "اس سیشن میں نئی", "per_minute": "فی منٹ", + "all_domains": "تمام ڈومینز", "download_phantom": "فینٹم سائٹ ڈاؤن لوڈ", + "phantom_subtitle": "آرکائیو شدہ صفحات کو مقامی میڈیا کے ساتھ جامد سائٹ کے طور پر برآمد کریں", "max_pages": "زیادہ سے زیادہ صفحات", "select_domain": "ڈومین منتخب کریں", "sources": "ذرائع", + "source_uri": "ماخذ URI", "neopig_uri": "Neopig URI", "source_page": "ماخذ صفحہ", "neopig_page": "Neopig صفحہ", + "type_label": "قسم", "mime_label": "MIME", "size_label": "سائز", "alt_label": "Alt", + "keywords_label": "کلیدی الفاظ", "description_label": "تفصیل", "items": "آئٹمز", "bytes": "بائٹس", + "used_on": "استعمال شدہ", "page": "صفحہ", "discovered": "دریافت", + "console": "کنسول", "close": "بند کریں", "no_logs": "ابھی کوئی لاگ نہیں", + }, + "sw": { + "search": "Tafuta", "crawl": "Tambaa", "live": "Moja kwa Moja", "about": "Kuhusu", + "random": "Nasibu", "phantom": "Phantom", "loading": "Inapakia...", + "start_crawl": "Anza Kutambaa", "pause": "Simamisha", "resume": "Endelea", "delete": "Futa", + "url": "URL", "mode": "Hali", "depth": "Kina", "keywords": "Maneno muhimu", + "screenshots": "Picha za skrini", "fast_mode": "Hali ya Haraka", "fresh_start": "Mwanzo Mpya", + "pages": "kurasa", "found": "zilizopatikana", "saved": "zilizohifadhiwa", "dupes": "nakala", + "running": "inaendelea", "completed": "imekamilika", "paused": "imesimamishwa", + "no_jobs": "Hakuna kazi", "started": "Ilianza", "filter": "Chuja", + "all_types": "Aina zote", "images": "Picha", "videos": "Video", "audio": "Sauti", + "everything": "Kila kitu", "all_media": "Media zote", + "images_only": "Picha tu", "videos_only": "Video tu", "text_only": "Maandishi tu", + "recent_jobs": "Kazi za Hivi Karibuni", "search_placeholder": "Tafuta media...", + "media": "Media", "pages_label": "Kurasa", "download": "Pakua", + "no_media": "Hakuna media iliyopatikana", "no_more": "Hakuna matokeo zaidi", "load_error": "Imeshindwa kupakia", + "copy": "Nakili", "copied": "Imenakiliwa!", "starting": "Inaanza...", "started_ok": "Imeanza!", + "error": "Hitilafu", "watching": "Inatazama picha mpya...", "content": "Maudhui", + "previous": "Iliyotangulia", "next": "Inayofuata", "over_9000": "Zaidi ya 9,000+!", + "loading_stats": "Inapakia takwimu...", "screenshot": "Picha ya skrini", + "valid_uri": "Tafadhali weka URI moja halali", "load_jobs_error": "Imeshindwa kupakia kazi", + "hydrate_subtitle": "Pata media kutoka wavuti", "live_subtitle": "Tazama picha zikitambaazwa", + "total_images": "Jumla ya Picha", "new_session": "Mpya Kipindi Hiki", "per_minute": "Kwa Dakika", + "all_domains": "Vikoa vyote", "download_phantom": "Pakua tovuti ya phantom", + "phantom_subtitle": "Hamisha kurasa zilizohifadhiwa kama tovuti tuli na media za ndani", "max_pages": "Kurasa za juu", "select_domain": "Chagua Kikoa", "sources": "vyanzo", + "source_uri": "URI Chanzo", "neopig_uri": "Neopig URI", "source_page": "Ukurasa Chanzo", "neopig_page": "Ukurasa Neopig", + "type_label": "Aina", "mime_label": "MIME", "size_label": "Ukubwa", "alt_label": "Alt", + "keywords_label": "Maneno muhimu", "description_label": "Maelezo", "items": "vitu", "bytes": "baiti", + "used_on": "Imetumika", "page": "Ukurasa", "discovered": "Iligunduliwa", + "console": "Konsoli", "close": "Funga", "no_logs": "Hakuna kumbukumbu bado", + }, + "mr": { + "search": "शोध", "crawl": "क्रॉल", "live": "थेट फीड", "about": "बद्दल", + "random": "यादृच्छिक", "phantom": "फँटम", "loading": "लोड होत आहे...", + "start_crawl": "क्रॉल सुरू करा", "pause": "थांबवा", "resume": "पुन्हा सुरू करा", "delete": "हटवा", + "url": "URL", "mode": "मोड", "depth": "खोली", "keywords": "कीवर्ड", + "screenshots": "स्क्रीनशॉट", "fast_mode": "जलद मोड", "fresh_start": "नवीन सुरुवात", + "pages": "पृष्ठे", "found": "सापडले", "saved": "जतन केले", "dupes": "डुप्लिकेट", + "running": "चालू आहे", "completed": "पूर्ण", "paused": "थांबवले", + "no_jobs": "कोणतेही क्रॉल काम नाही", "started": "सुरू", "filter": "फिल्टर", + "all_types": "सर्व प्रकार", "images": "प्रतिमा", "videos": "व्हिडिओ", "audio": "ऑडिओ", + "everything": "सर्वकाही", "all_media": "सर्व मीडिया", + "images_only": "फक्त प्रतिमा", "videos_only": "फक्त व्हिडिओ", "text_only": "फक्त मजकूर", + "recent_jobs": "अलीकडील कामे", "search_placeholder": "मीडिया शोधा...", + "media": "मीडिया", "pages_label": "पृष्ठे", "download": "डाउनलोड", + "no_media": "मीडिया सापडला नाही", "no_more": "आणखी परिणाम नाहीत", "load_error": "लोड अयशस्वी", + "copy": "कॉपी", "copied": "कॉपी झाले!", "starting": "सुरू होत आहे...", "started_ok": "सुरू झाले!", + "error": "त्रुटी", "watching": "नवीन प्रतिमा पाहत आहे...", "content": "सामग्री", + "previous": "मागील", "next": "पुढील", "over_9000": "9,000+ पेक्षा जास्त!", + "loading_stats": "आकडेवारी लोड होत आहे...", "screenshot": "स्क्रीनशॉट", + "valid_uri": "कृपया किमान एक वैध URI प्रविष्ट करा", "load_jobs_error": "कामे लोड अयशस्वी", + "hydrate_subtitle": "वेबवरून मीडिया मिळवा", "live_subtitle": "क्रॉल होताना प्रतिमा पहा", + "total_images": "एकूण प्रतिमा", "new_session": "या सत्रात नवीन", "per_minute": "प्रति मिनिट", + "all_domains": "सर्व डोमेन", "download_phantom": "फँटम साइट डाउनलोड करा", + "phantom_subtitle": "संग्रहित पृष्ठे स्थानिक मीडियासह स्थिर साइट म्हणून निर्यात करा", "max_pages": "जास्तीत जास्त पृष्ठे", "select_domain": "डोमेन निवडा", "sources": "स्रोत", + "source_uri": "स्रोत URI", "neopig_uri": "Neopig URI", "source_page": "स्रोत पृष्ठ", "neopig_page": "Neopig पृष्ठ", + "type_label": "प्रकार", "mime_label": "MIME", "size_label": "आकार", "alt_label": "Alt", + "keywords_label": "कीवर्ड", "description_label": "वर्णन", "items": "आयटम", "bytes": "बाइट्स", + "used_on": "वापरलेले", "page": "पृष्ठ", "discovered": "शोधले", + "console": "कन्सोल", "close": "बंद करा", "no_logs": "अद्याप कोणतेही लॉग नाहीत", + }, + "te": { + "search": "శోధన", "crawl": "క్రాల్", "live": "లైవ్ ఫీడ్", "about": "గురించి", + "random": "యాదృచ్ఛిక", "phantom": "ఫాంటమ్", "loading": "లోడ్ అవుతోంది...", + "start_crawl": "క్రాల్ ప్రారంభించు", "pause": "పాజ్", "resume": "పునఃప్రారంభించు", "delete": "తొలగించు", + "url": "URL", "mode": "మోడ్", "depth": "లోతు", "keywords": "కీవర్డ్‌లు", + "screenshots": "స్క్రీన్‌షాట్‌లు", "fast_mode": "వేగవంతమైన మోడ్", "fresh_start": "కొత్త ప్రారంభం", + "pages": "పేజీలు", "found": "కనుగొనబడింది", "saved": "సేవ్ చేయబడింది", "dupes": "నకిలీలు", + "running": "నడుస్తోంది", "completed": "పూర్తయింది", "paused": "పాజ్ చేయబడింది", + "no_jobs": "క్రాల్ జాబ్‌లు లేవు", "started": "ప్రారంభమైంది", "filter": "ఫిల్టర్", + "all_types": "అన్ని రకాలు", "images": "చిత్రాలు", "videos": "వీడియోలు", "audio": "ఆడియో", + "everything": "అన్నీ", "all_media": "అన్ని మీడియా", + "images_only": "చిత్రాలు మాత్రమే", "videos_only": "వీడియోలు మాత్రమే", "text_only": "వచనం మాత్రమే", + "recent_jobs": "ఇటీవలి జాబ్‌లు", "search_placeholder": "మీడియా శోధించండి...", + "media": "మీడియా", "pages_label": "పేజీలు", "download": "డౌన్‌లోడ్", + "no_media": "మీడియా కనుగొనబడలేదు", "no_more": "మరిన్ని ఫలితాలు లేవు", "load_error": "లోడ్ విఫలమైంది", + "copy": "కాపీ", "copied": "కాపీ అయింది!", "starting": "ప్రారంభమవుతోంది...", "started_ok": "ప్రారంభమైంది!", + "error": "లోపం", "watching": "కొత్త చిత్రాల కోసం చూస్తోంది...", "content": "కంటెంట్", + "previous": "మునుపటి", "next": "తదుపరి", "over_9000": "9,000+ కంటే ఎక్కువ!", + "loading_stats": "గణాంకాలు లోడ్ అవుతున్నాయి...", "screenshot": "స్క్రీన్‌షాట్", + "valid_uri": "దయచేసి కనీసం ఒక చెల్లుబాటు అయ్యే URI నమోదు చేయండి", "load_jobs_error": "జాబ్‌లు లోడ్ విఫలమైంది", + "hydrate_subtitle": "వెబ్ నుండి మీడియా పొందండి", "live_subtitle": "క్రాల్ అవుతున్న చిత్రాలను చూడండి", + "total_images": "మొత్తం చిత్రాలు", "new_session": "ఈ సెషన్‌లో కొత్తవి", "per_minute": "నిమిషానికి", + "all_domains": "అన్ని డొమైన్‌లు", "download_phantom": "ఫాంటమ్ సైట్ డౌన్‌లోడ్", + "phantom_subtitle": "ఆర్కైవ్ చేసిన పేజీలను స్థానిక మీడియాతో స్టాటిక్ సైట్‌గా ఎగుమతి చేయండి", "max_pages": "గరిష్ట పేజీలు", "select_domain": "డొమైన్ ఎంచుకోండి", "sources": "మూలాలు", + "source_uri": "మూల URI", "neopig_uri": "Neopig URI", "source_page": "మూల పేజీ", "neopig_page": "Neopig పేజీ", + "type_label": "రకం", "mime_label": "MIME", "size_label": "పరిమాణం", "alt_label": "Alt", + "keywords_label": "కీవర్డ్‌లు", "description_label": "వివరణ", "items": "అంశాలు", "bytes": "బైట్‌లు", + "used_on": "ఉపయోగించబడింది", "page": "పేజీ", "discovered": "కనుగొనబడింది", + "console": "కన్సోల్", "close": "మూసివేయి", "no_logs": "ఇంకా లాగ్‌లు లేవు", }, } @@ -656,10 +842,11 @@ def get_lang(lang_cookie: str = None, accept_language: str = None) -> str: # Language names for the selector dropdown LANG_NAMES = { - "en": "English", "zh": "中文", "es": "Español", "hi": "हिन्दी", "ar": "العربية", + "en": "English", "zh": "中文", "zh-tw": "繁體中文", "es": "Español", "hi": "हिन्दी", "ar": "العربية", "pt": "Português", "ru": "Русский", "ja": "日本語", "fr": "Français", "de": "Deutsch", "ko": "한국어", "it": "Italiano", "nl": "Nederlands", "pl": "Polski", "tr": "Türkçe", "vi": "Tiếng Việt", "th": "ไทย", "id": "Bahasa", "uk": "Українська", "sv": "Svenska", + "bn": "বাংলা", "ur": "اردو", "sw": "Kiswahili", "mr": "मराठी", "te": "తెలుగు", } def t(key: str, lang: str = "en") -> str: @@ -676,6 +863,18 @@ NAV_HTML = '''''' +# Single source of truth for search box +SEARCH_BOX_HTML = '''''' + def inject_i18n(html: str, lang: str) -> str: """Replace {key} placeholders and inject JS translations + language selector.""" trans = TRANSLATIONS.get(lang, TRANSLATIONS["en"]) @@ -686,6 +885,8 @@ def inject_i18n(html: str, lang: str) -> str: if IMPORT_MODE: nav = nav.replace('', 'Import\n ') html = html.replace('', nav) + # Replace search placeholder with search box + html = html.replace('', SEARCH_BOX_HTML) # Build language selector options lang_options = ''.join(f'' for code, name in LANG_NAMES.items()) @@ -708,6 +909,10 @@ function setLang(code) { app = FastAPI(title="neopig", description="Media crawler + SERP + Screenshot service") +# Set up Jinja2 templates +TEMPLATES_PATH = Path(__file__).parent / "templates" +templates = Jinja2Templates(directory=str(TEMPLATES_PATH)) if TEMPLATES_PATH.exists() else None + # Try to include uri2png screenshot router (optional dependency) try: from uri2png import get_screenshot_router @@ -785,457 +990,6 @@ TEMP_DB_PATH: str = None # Extracted database (SQLite needs real file) # Base CSS shared by all pages -BASE_CSS = """ -* { box-sizing: border-box; } -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - margin: 0; padding: 0; - background: #0a0a0a; color: #e0e0e0; -} -.nav { - background: #1a1a1a; padding: 10px 20px; - display: flex; gap: 20px; align-items: center; - border-bottom: 1px solid #333; - flex-wrap: wrap; -} -.nav a { color: #ff6b6b; text-decoration: none; padding: 5px 0; } -.nav a:hover { text-decoration: underline; } -.nav .brand { font-weight: bold; font-size: 18px; } -@media (max-width: 600px) { - .nav { gap: 12px; padding: 10px 15px; } - .nav a { font-size: 14px; } - .nav .brand { font-size: 16px; width: 100%; margin-bottom: 5px; } -} -.container { padding: 20px; } -a { color: #ff6b6b; } -input[type="text"], select { - padding: 12px 16px; font-size: 16px; - border: 2px solid #333; border-radius: 8px; - background: #1a1a1a; color: #fff; -} -input[type="text"]:focus { outline: none; border-color: #ff6b6b; } -button { - padding: 12px 24px; font-size: 16px; - background: #ff6b6b; color: #fff; - border: none; border-radius: 8px; cursor: pointer; -} -button:hover { background: #ff5252; } -.search-box { display: flex; gap: 10px; margin-bottom: 20px; flex-wrap: wrap; } -@media (max-width: 600px) { - .search-box input[type="text"] { width: 100%; flex: 1 1 100%; } - .search-box select, .search-box button { flex: 1 1 auto; } -} -""" - -def layout(title: str, content: str, extra_css: str = "", extra_head: str = "") -> str: - """Build a complete HTML page with consistent layout. - - Args: - title: Page title (will be appended with " - neopig") - content: HTML content for the page body - extra_css: Additional CSS to include - extra_head: Additional head elements (scripts, links, etc.) - - Returns: - Complete HTML document string - """ - return f""" - - - - {title} - neopig - - {extra_head} - - - - {content} - - -""" - -# Extended CSS for view/detail pages (adds to BASE_CSS) -VIEW_CSS = """ -h1 { color: #ff6b6b; font-size: 20px; margin: 0 0 10px 0; } -h3 { color: #ff6b6b; font-size: 16px; margin: 30px 0 15px 0; border-bottom: 1px solid #333; padding-bottom: 8px; } -.meta { background: #1a1a1a; padding: 15px; border-radius: 8px; margin: 15px 0; } -.meta-row { display: flex; margin: 8px 0; } -.meta-label { width: 100px; color: #888; font-size: 13px; } -.meta-value { flex: 1; word-break: break-all; font-size: 13px; } -.meta-value a { color: #4ade80; } -.hero { text-align: center; margin-bottom: 20px; } -.hero img, .hero video { max-width: 100%; max-height: 60vh; border-radius: 8px; } -.content-rendered { - background: #1a1a1a; padding: 20px; border-radius: 8px; - line-height: 1.7; font-size: 14px; color: #ccc; text-align: left; -} -.content-rendered img { max-width: 100%; height: auto; margin: 10px 0; } -.content-rendered img.avatar, -.content-rendered img[alt="avatar"] { - display: inline-block; vertical-align: middle; - width: 40px; height: 40px; border-radius: 50%; - margin: 0 10px 0 0; object-fit: cover; -} -.content-rendered img.emoji, -.content-rendered img[alt^=":"][alt$=":"], -.content-rendered img[src*="emoji"] { - display: inline; width: 20px; height: 20px; - max-width: 20px; margin: 0 2px; vertical-align: text-bottom; -} -.content-rendered a { color: #ff6b6b; } -.content-rendered pre, .content-rendered code { - background: #252525; padding: 2px 6px; - border-radius: 4px; font-family: monospace; font-size: 13px; -} -.content-rendered pre { - padding: 15px; display: block; - white-space: pre-wrap; overflow-x: auto; -} -.content-rendered blockquote { - background: #151520; border-left: 3px solid #4a9eff; - padding: 12px 16px; margin: 16px 0; - border-radius: 0 6px 6px 0; color: #aaa; font-style: italic; -} -.content-rendered hr { border: none; border-top: 1px solid #333; margin: 24px 0; } -.content-rendered p { margin: 0 0 16px 0; line-height: 1.7; } -.content-rendered h1, .content-rendered h2, .content-rendered h3, .content-rendered h4, .content-rendered h5, .content-rendered h6 { - display: block; color: #ff6b6b; margin-top: 28px; margin-bottom: 12px; -} -.content-rendered h1 { font-size: 1.8em; } -.content-rendered h2 { font-size: 1.5em; } -.content-rendered h3 { font-size: 1.25em; } -.media-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - gap: 10px; margin-top: 15px; -} -.media-card { - background: #1a1a1a; border-radius: 8px; - overflow: hidden; display: block; transition: transform 0.2s; - text-decoration: none; -} -.media-card:hover { transform: scale(1.02); } -.media-card img, .media-card video, .media-card svg { - width: 100%; height: 120px; - object-fit: contain; background: #0a0a0a; -} -.media-card-name { - padding: 4px 8px; font-size: 11px; color: #888; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - background: #111; text-align: left; text-decoration: none; -} -.tag { background: #333; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-right: 5px; } -""" - - -def render_detail_page( - title: str, - hero_html: str, - meta_rows: list, - media_items: list, - content_html: str, - screenshot_hashes: list, - source_domain: str, - page_uri: str, - download_btn_html: str = "", - noai: bool = False, - lang: str = "en", - sources_html: str = "", -) -> str: - """ - Shared template for media view and page view. - - Args: - title: Page title - hero_html: HTML for hero section (media element or empty) - meta_rows: List of (label, value_html) tuples for metadata - media_items: List of media dicts with md5_hash, media_type - content_html: Rendered markdown/content HTML - screenshot_hashes: List of screenshot md5 hashes - source_domain: Domain for AI prompt - page_uri: Page URI for AI prompt - download_btn_html: Optional download button HTML - noai: Disable AI assistant - sources_html: Optional HTML for "Used on X pages" section (reverse image search) - """ - import html as html_module - - # Build metadata section - meta_html = ''.join( - f'
{label}:{value}
' - for label, value in meta_rows - ) - - # Build media gallery - media_grid = "" - if media_items: - media_cards = [] - # MIME to extension mapping for non-renderable files - mime_to_ext = { - 'text/javascript': '.js', 'application/javascript': '.js', - 'text/css': '.css', 'text/html': '.html', - 'text/xml': '.xml', 'application/xml': '.xml', - 'application/rss+xml': '.rss', 'application/atom+xml': '.atom', - 'application/json': '.json', 'text/plain': '.txt', - 'font/woff': '.woff', 'font/woff2': '.woff2', - 'font/ttf': '.ttf', 'font/otf': '.otf', - 'application/font-woff': '.woff', 'application/font-woff2': '.woff2', - } - def placeholder_svg(ext, icon, color): - return f'{icon}{ext}' - def get_filename(uri): - if not uri: return "" - # Extract filename from URI path - from urllib.parse import urlparse, unquote - path = urlparse(uri).path - name = unquote(path.split("/")[-1]) if path else "" - # Truncate long names - return name[:30] + "..." if len(name) > 33 else name - for m in media_items: - mtype = m.get("media_type", "") - mime = m.get("mime_type", "") - filename = get_filename(m.get("media_uri", "")) - # For placeholder, use filename if available, otherwise extension - display_name = filename[:20] if filename else mime_to_ext.get(mime, "") - if mtype == "video": - el = f'' - elif mtype == "code": - ext = mime_to_ext.get(mime, m.get("alt_text", "")) - if ext and not ext.startswith("."): ext = "." + ext - el = placeholder_svg(display_name or ext, "{ }", "#6af") - elif mtype == "style": - el = placeholder_svg(display_name or ".css", "#", "#f6a") - elif mtype == "font": - ext = mime_to_ext.get(mime, ".font") - el = placeholder_svg(display_name or ext, "Aa", "#af6") - else: - el = f'' - filename_el = f'
{html_module.escape(filename)}
' if filename else "" - media_cards.append(f'''
{el}{filename_el}''') - media_count = len(media_items) - media_grid = ''' -
-

{{media}} (''' + str(media_count) + ''')

-
''' + ''.join(media_cards) + '''
-
''' - - escaped_title = html_module.escape(title) - escaped_uri = html_module.escape(page_uri or "") - - html = f""" - - - - {escaped_title} - neopig - - - - - - -
- -
- {f'
{hero_html}
' if hero_html else ''} -
-

{escaped_title}

-
{meta_html}
- {download_btn_html} -
-
- {media_grid} -
-
- {'

{{content}}

' + content_html + '
' if content_html else ''} -
- {('

{{screenshot}}

' + ''.join('Screenshot' for h in screenshot_hashes) + '
') if screenshot_hashes else ''} -
- - {sources_html} - {'' if noai else f''' - '''} - -""" - return inject_i18n(html, lang) - - def _open_tarball(): """Open a fresh tarball handle for this thread.""" if TAR_OFFSET > 0: @@ -1384,1390 +1138,72 @@ class CrawlRequest(BaseModel): @app.get("/", response_class=HTMLResponse) -async def index(lang: str = Cookie(None), accept_language: str = Header(None)): +async def index(request: Request, lang: str = Cookie(None), accept_language: str = Header(None)): """Simple search UI.""" language = get_lang(lang, accept_language) - return inject_i18n(get_search_html(), language) + t = TRANSLATIONS.get(language, TRANSLATIONS["en"]) + + if templates: + return templates.TemplateResponse("search.html.j2", { + "request": request, + "t": t, + "t_json": json.dumps(t), + "lang": language, + "langs": LANG_NAMES, + "import_mode": IMPORT_MODE, + }) + + raise HTTPException(status_code=500, detail="Templates required") @app.get("/crawl", response_class=HTMLResponse) -async def crawl_page(lang: str = Cookie(None), accept_language: str = Header(None)): +async def crawl_page(request: Request, lang: str = Cookie(None), accept_language: str = Header(None)): """Crawler command page.""" language = get_lang(lang, accept_language) - html = CRAWL_HTML - if CRAWL_DISABLED: - # Inject disabled state into the page - html = html.replace('const CRAWL_DISABLED = false;', 'const CRAWL_DISABLED = true;') - return inject_i18n(html, language) + t = TRANSLATIONS.get(language, TRANSLATIONS["en"]) + + # Use Jinja2 template if available, otherwise fall back to inline HTML + if templates: + return templates.TemplateResponse("crawl.html.j2", { + "request": request, + "t": t, + "t_json": json.dumps(t), + "lang": language, + "langs": LANG_NAMES, + "import_mode": IMPORT_MODE, + "crawl_disabled": CRAWL_DISABLED, + }) + raise HTTPException(status_code=500, detail="Templates required") # Page-specific CSS for Search page -SEARCH_CSS = """ -h1 { color: #ff6b6b; margin-bottom: 5px; } -.subtitle { color: #666; margin-bottom: 20px; } -.stats { padding: 10px 15px; background: #1a1a1a; border-radius: 8px; margin-bottom: 20px; font-size: 14px; color: #888; } -.section-title { color: #ff6b6b; font-size: 18px; margin: 25px 0 15px 0; border-bottom: 1px solid #333; padding-bottom: 8px; } -.results { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; } -.result { background: #1a1a1a; border-radius: 8px; overflow: hidden; transition: transform 0.2s; } -.result:hover { transform: scale(1.02); } -.result img, .result video { width: 100%; height: 200px; object-fit: contain; background: #1a1a1a; } -.result-info { padding: 10px; } -.result-filename { font-size: 12px; color: #aaa; margin-bottom: 4px; word-break: break-all; } -.result-hash { font-family: monospace; font-size: 11px; color: #ff6b6b; text-decoration: none; word-break: break-all; } -.result-hash:hover { text-decoration: underline; } -.result-meta { font-size: 12px; color: #888; margin-top: 5px; } -.result-keywords { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } -.tag { background: #333; padding: 2px 8px; border-radius: 4px; font-size: 11px; color: #aaa; } -.no-results { text-align: center; padding: 60px; color: #666; } -.result-count { padding: 10px 15px; color: #888; font-size: 14px; } -.result-count.over-9000 { color: #ff6b6b; font-weight: bold; font-size: 18px; text-shadow: 0 0 10px rgba(255,107,107,0.5); animation: powerUp 0.5s ease-out; } -@keyframes powerUp { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } } -.pagination { display: flex; gap: 15px; justify-content: center; padding: 20px; margin-top: 20px; border-top: 1px solid #333; } -.pagination button { padding: 10px 20px; background: #4ecdc4; color: #1a1a2e; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; } -.pagination button:hover { background: #7bed72; } -.media-link { display: block; cursor: pointer; } -.media-link:hover img, .media-link:hover video { opacity: 0.8; } -.page-results { display: flex; flex-direction: column; gap: 10px; } -.page-result { background: #1a1a1a; border-radius: 8px; padding: 15px; transition: background 0.2s; } -.page-result:hover { background: #252525; } -.page-result a { color: #ff6b6b; text-decoration: none; font-size: 16px; font-weight: 500; } -.page-result a:hover { text-decoration: underline; } -.page-path { font-size: 12px; color: #4ade80; margin-top: 4px; font-family: monospace; } -.page-snippet { font-size: 13px; color: #999; margin-top: 8px; line-height: 1.5; } -.page-snippet mark { background: #ff6b6b33; color: #ff9999; padding: 1px 3px; border-radius: 2px; } -.search-columns { display: grid; grid-template-columns: 1fr; gap: 30px; align-items: start; } -.search-columns.has-pages { grid-template-columns: 1fr 2fr; } -.search-columns .page-column { display: none; } -.search-columns.has-pages .page-column { display: block; } -@media (max-width: 1000px) { .search-columns.has-pages { grid-template-columns: 1fr; } } -@media (max-width: 768px) { .results { grid-template-columns: repeat(2, 1fr); } } -""" -SEARCH_CONTENT = """ -
- -
{{loading_stats}}
-
-
-

{{pages_label}}

-
-
- -
-

{{media}}

-
-
-
-
- - -
-""" - -def get_search_html(): - """Build search page using layout.""" - return layout("neopig SERP", SEARCH_CONTENT, SEARCH_CSS) - -CRAWL_HTML = """ - - - - - neopig {{crawl}} - - - - -
- - - -

{{crawl}}

-

{{hydrate_subtitle}}

- -
-
- - -
- -
- - -
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-

{{recent_jobs}}

-
{{loading}}
-
- - - -
-
-

{{console}}

- -
-
-
-
- - -""" - - -LIVE_HTML = """ - - - - - neopig {{live}} - {{live_subtitle}} - - - - -
- - - -

{{live}}

-

{{live_subtitle}}

- -
-
- 0 - {{total_images}} -
-
- 0 - {{new_session}} -
-
- 0 - {{per_minute}} -
-
- -
- - - {{watching}} -
- -
- - -
- - -""" @app.get("/live", response_class=HTMLResponse) -async def live_page(domain: str = Query(None), lang: str = Cookie(None), accept_language: str = Header(None)): +async def live_page(request: Request, domain: str = Query(None), lang: str = Cookie(None), accept_language: str = Header(None)): """Live feed page - watch images appear as they're crawled.""" language = get_lang(lang, accept_language) - # Inject domain filter into HTML - html = LIVE_HTML - if domain: - # Add domain to the poll URL - html = html.replace( - "/api/search?q=&limit=50", - f"/api/search?q={domain}&limit=50" - ) - html = html.replace( - "

{{live}}

", - "

{{live}}: " + domain + "

" - ) - return inject_i18n(html, language) + t = TRANSLATIONS.get(language, TRANSLATIONS["en"]) + + if templates: + return templates.TemplateResponse("live.html.j2", { + "request": request, + "t": t, + "t_json": json.dumps(t), + "domain_json": json.dumps(domain or ""), + "domain": domain, + "lang": language, + "langs": LANG_NAMES, + "import_mode": IMPORT_MODE, + }) + raise HTTPException(status_code=500, detail="Templates required") @app.get("/view/{md5_hash}", response_class=HTMLResponse) -async def view_media_page(md5_hash: str, noai: bool = Query(False), lang: str = Cookie(None), accept_language: str = Header(None)): +async def view_media_page(request: Request, md5_hash: str, noai: bool = Query(False), lang: str = Cookie(None), accept_language: str = Header(None)): """Detail view page for a single media item.""" language = get_lang(lang, accept_language) import html as html_module @@ -2846,7 +1282,7 @@ async def view_media_page(md5_hash: str, noai: bool = Query(False), lang: str = else: hero_html = f'{html_module.escape(media.get(' - # Metadata rows + # Metadata rows - (key, value) where key is looked up via t.get(key, key) import hashlib media_uri = sorted_sources[0]["media_uri"] if sorted_sources else None neopig_media_uri = f"/view/{md5_hash}" @@ -2854,16 +1290,16 @@ async def view_media_page(md5_hash: str, noai: bool = Query(False), lang: str = neopig_page_uri = f"/page/{page_uri_hash}" if page_uri_hash else None keywords_html = ''.join([f'{k}' for k in keywords]) or '-' meta_rows = [ - ("{{source_uri}}", f'{media_uri}' if media_uri else '-'), - ("{{neopig_uri}}", f'{neopig_media_uri}'), - ("{{source_page}}", f'{page_uri}' if page_uri else '-'), - ("{{neopig_page}}", f'{neopig_page_uri}' if neopig_page_uri else '-'), + ("source_uri", f'{media_uri}' if media_uri else '-'), + ("neopig_uri", f'{neopig_media_uri}'), + ("source_page", f'{page_uri}' if page_uri else '-'), + ("neopig_page", f'{neopig_page_uri}' if neopig_page_uri else '-'), ("MD5", f'{md5_hash}'), - ("{{type_label}}", media['media_type']), - ("{{mime_label}}", media.get('mime_type') or 'unknown'), - ("{{size_label}}", f"{media.get('file_size') or 0:,} bytes"), - ("{{alt_label}}", media.get('alt_text') or '-'), - ("{{keywords_label}}", keywords_html), + ("type_label", media['media_type']), + ("mime_label", media.get('mime_type') or 'unknown'), + ("size_label", f"{media.get('file_size') or 0:,} bytes"), + ("alt_label", media.get('alt_text') or '-'), + ("keywords_label", keywords_html), ] # Download button - prioritize original filename from URL @@ -2962,6 +1398,7 @@ async def view_media_page(md5_hash: str, noai: bool = Query(False), lang: str = screenshot_hashes = [h for h in screenshot_hashes if h != md5_hash] # Build "Used on X pages" section (reverse image search) + t = TRANSLATIONS.get(language, TRANSLATIONS["en"]) sources_html = "" if sources and len(sources) > 0: import hashlib as hl @@ -2981,10 +1418,10 @@ async def view_media_page(md5_hash: str, noai: bool = Query(False), lang: str = ''' total_count = len(unique_pages) count_display = f"{OVER_9000:,}" if total_count >= OVER_9000 else str(total_count) - sources_html = ''' + sources_html = f'''

- {{used_on}} ''' + count_display + ''' {{pages}} + {t["used_on"]} ''' + count_display + f''' {t["pages"]}

@@ -2993,7 +1430,7 @@ async def view_media_page(md5_hash: str, noai: bool = Query(False), lang: str =
- + ''' + rows_html + '''
{{page}}{{discovered}}
{t["page"]}{t["discovered"]}
@@ -3035,26 +1472,30 @@ async def view_media_page(md5_hash: str, noai: bool = Query(False), lang: str = })(); ''' - return render_detail_page( - title=display_title, - hero_html=hero_html, - meta_rows=meta_rows, - media_items=media_items, - content_html=content_html, - screenshot_hashes=screenshot_hashes, - source_domain=source_domain, - page_uri=page_uri or "", - download_btn_html=download_btn, - noai=noai, - lang=language, - sources_html=sources_html, - ) - + return templates.TemplateResponse("view.html.j2", { + "request": request, + "t": TRANSLATIONS.get(language, TRANSLATIONS["en"]), + "t_json": json.dumps(TRANSLATIONS.get(language, TRANSLATIONS["en"])), + "title": display_title, + "hero_html": hero_html, + "meta_rows": meta_rows, + "media_items": media_items, + "content_html": content_html, + "screenshot_hashes": screenshot_hashes, + "source_domain": source_domain, + "page_uri": page_uri or "", + "download_btn_html": download_btn, + "noai": noai, + "sources_html": sources_html, + "lang": language, + "langs": LANG_NAMES, + "import_mode": IMPORT_MODE, + }) @app.get("/page/{uri_hash}", response_class=HTMLResponse) -async def view_page_by_hash( +async def view_page_by_hash(request: Request, uri_hash: str, noai: bool = Query(False, description="Disable AI assistant"), lang: str = Cookie(None), @@ -3065,11 +1506,11 @@ async def view_page_by_hash( if not page: raise HTTPException(status_code=404, detail="Page not found") # Redirect to the URI-based view (reuses same logic) - return await view_page(uri=page['uri'], noai=noai, lang=lang, accept_language=accept_language) + return await view_page(request=request, uri=page['uri'], noai=noai, lang=lang, accept_language=accept_language) @app.get("/page/view", response_class=HTMLResponse) -async def view_page( +async def view_page(request: Request, uri: str = Query(..., description="Page URI to view"), noai: bool = Query(False, description="Disable AI assistant"), lang: str = Cookie(None), @@ -3164,34 +1605,39 @@ async def view_page( screenshot_hashes = await db.get_page_screenshots(uri) media_items = await db.get_page_media(uri) - # Metadata rows for page view + # Metadata rows - (key, value) where key is looked up via t.get(key, key) import hashlib uri_hash = hashlib.md5(uri.encode()).hexdigest() neopig_page_uri = f"/page/{uri_hash}" keywords = json.loads(page.get('keywords') or '[]') if page.get('keywords') else [] keywords_html = ''.join([f'{k}' for k in keywords]) or '-' meta_rows = [ - ("{{source_uri}}", f'{uri}'), - ("{{neopig_uri}}", f'{neopig_page_uri}'), - ("{{description_label}}", page.get('description') or '-'), - ("{{keywords_label}}", keywords_html), - ("{{media}}", f"{len(media_items)}"), + ("source_uri", f'{uri}'), + ("neopig_uri", f'{neopig_page_uri}'), + ("description_label", page.get('description') or '-'), + ("keywords_label", keywords_html), + ("media", f"{len(media_items)}"), ] - return render_detail_page( - title=page_title, - hero_html="", # No hero media for page view - meta_rows=meta_rows, - media_items=media_items, - content_html=content_html, - screenshot_hashes=screenshot_hashes, - source_domain=source_domain, - page_uri=uri, - noai=noai, - lang=language, - ) - - + return templates.TemplateResponse("view.html.j2", { + "request": request, + "t": TRANSLATIONS.get(language, TRANSLATIONS["en"]), + "t_json": json.dumps(TRANSLATIONS.get(language, TRANSLATIONS["en"])), + "title": page_title, + "hero_html": "", + "meta_rows": meta_rows, + "media_items": media_items, + "content_html": content_html, + "screenshot_hashes": screenshot_hashes, + "source_domain": source_domain, + "page_uri": uri, + "download_btn_html": "", + "noai": noai, + "sources_html": "", + "lang": language, + "langs": LANG_NAMES, + "import_mode": IMPORT_MODE, + }) @app.get("/phantom/export") async def phantom_export(domain: str = Query(None, description="Filter by domain")): """ @@ -3328,1013 +1774,42 @@ async def phantom_export(domain: str = Query(None, description="Filter by domain @app.get("/phantom", response_class=HTMLResponse) -async def phantom_page(lang: str = Cookie(None), accept_language: str = Header(None)): +async def phantom_page(request: Request, lang: str = Cookie(None), accept_language: str = Header(None)): """Phantom site export UI.""" language = get_lang(lang, accept_language) + t = TRANSLATIONS.get(language, TRANSLATIONS["en"]) domains = await db.get_domains_with_pages() - domain_options = ''.join([ - f'' - for d in domains if d[0] - ]) + if templates: + return templates.TemplateResponse("phantom.html.j2", { + "request": request, + "t": t, + "lang": language, + "langs": LANG_NAMES, + "import_mode": IMPORT_MODE, + "domains": domains, + }) + + raise HTTPException(status_code=500, detail="Templates required") - html = f""" - - - - - Phantom Site Export - neopig - - - - -
- -

Phantom Site Export

-

{{phantom_subtitle}}

- -
-
- - -
- -
- -
-

What is a Phantom Site?

-
    -
  • Original HTML preserved exactly as crawled
  • -
  • All media URLs rewritten to local paths
  • -
  • Ready to host statically (nginx, Caddy, S3, etc.)
  • -
  • Works offline - all assets included
  • -
  • Perfect for archival & preservation
  • -
-
-
- - -""" - return inject_i18n(html, language) - - -ABOUT_CSS = """ -.about-page { width: 100%; min-height: 100vh; background: #0a0a0f; } - -/* Hero Section */ -.hero-section { - width: 100%; - min-height: 100vh; - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - background: linear-gradient(180deg, #0a0a0f 0%, #1a0a1a 50%, #0f0a1a 100%); - position: relative; - overflow: hidden; - padding: 60px 20px; -} -.hero-section::before { - content: ''; - position: absolute; - top: 0; left: 0; right: 0; bottom: 0; - background: radial-gradient(ellipse at 50% 0%, rgba(255,50,100,0.15) 0%, transparent 60%), - radial-gradient(ellipse at 20% 80%, rgba(100,50,255,0.1) 0%, transparent 40%), - radial-gradient(ellipse at 80% 60%, rgba(255,100,50,0.08) 0%, transparent 40%); - pointer-events: none; -} -.hero-pig { - width: 280px; - height: auto; - filter: drop-shadow(0 0 60px rgba(255,100,100,0.5)) drop-shadow(0 0 120px rgba(255,50,100,0.3)); - animation: pulse-glow 4s ease-in-out infinite; - margin-bottom: 40px; -} -@keyframes pulse-glow { - 0%, 100% { filter: drop-shadow(0 0 60px rgba(255,100,100,0.5)) drop-shadow(0 0 120px rgba(255,50,100,0.3)); } - 50% { filter: drop-shadow(0 0 80px rgba(255,100,100,0.7)) drop-shadow(0 0 160px rgba(255,50,100,0.5)); } -} -.hero-title { - font-size: 6em; - font-weight: 900; - letter-spacing: -0.02em; - background: linear-gradient(135deg, #ff6b6b 0%, #ff8e53 50%, #ff6b9d 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - text-shadow: 0 0 80px rgba(255,100,100,0.5); - margin: 0; - text-align: center; -} -.hero-subtitle { - font-size: 1.4em; - color: #888; - margin-top: 20px; - font-weight: 300; - letter-spacing: 0.3em; - text-transform: uppercase; -} -.hero-tagline { - font-size: 1.1em; - color: #666; - margin-top: 30px; - max-width: 600px; - text-align: center; - line-height: 1.8; -} - -/* Chapter Sections */ -.chapter { - width: 100%; - padding: 120px 40px; - position: relative; -} -.chapter-content { - max-width: 900px; - margin: 0 auto; -} -.chapter-number { - font-size: 8em; - font-weight: 900; - color: rgba(255,100,100,0.08); - position: absolute; - top: 40px; - left: 40px; - line-height: 1; - font-family: 'Georgia', serif; -} -.chapter-title { - font-size: 3em; - font-weight: 800; - color: #ff6b6b; - margin-bottom: 40px; - position: relative; -} -.chapter-title::after { - content: ''; - display: block; - width: 100px; - height: 4px; - background: linear-gradient(90deg, #ff6b6b, transparent); - margin-top: 20px; -} - -/* Narrative Text */ -.narrative { - font-size: 1.3em; - line-height: 2; - color: #c0c0c0; - margin-bottom: 30px; -} -.narrative em { color: #ff8e53; font-style: italic; } -.narrative strong { color: #fff; font-weight: 600; } -.narrative a { color: #ff6b6b; text-decoration: none; border-bottom: 1px solid rgba(255,107,107,0.3); transition: all 0.3s; } -.narrative a:hover { color: #ff8e53; border-bottom-color: #ff8e53; } - -/* Dramatic Quote */ -.dramatic-quote { - padding: 60px; - margin: 60px 0; - background: linear-gradient(135deg, rgba(255,50,100,0.1) 0%, rgba(100,50,200,0.1) 100%); - border-left: 6px solid #ff6b6b; - position: relative; -} -.dramatic-quote::before { - content: '"'; - font-size: 12em; - color: rgba(255,100,100,0.15); - position: absolute; - top: -40px; - left: 20px; - font-family: 'Georgia', serif; - line-height: 1; -} -.dramatic-quote p { - font-size: 1.8em; - font-style: italic; - color: #e0e0e0; - margin: 0; - position: relative; - z-index: 1; -} -.dramatic-quote cite { - display: block; - margin-top: 30px; - font-size: 1em; - color: #888; - font-style: normal; -} - -/* Dead Link Styling */ -.dead-link { - text-decoration: line-through; - color: #666; - cursor: not-allowed; - position: relative; -} -.dead-link::after { - content: ' [DEAD]'; - color: #ff4444; - font-size: 0.7em; - font-weight: bold; - text-decoration: none; -} - -/* Dark Chapter */ -.chapter-dark { - background: linear-gradient(180deg, #0a0a0f 0%, #150810 50%, #0a0a0f 100%); -} -.chapter-dark .chapter-number { color: rgba(255,50,50,0.1); } - -/* Rebirth Chapter - TempleOS cyan */ -.chapter-rebirth { - background: linear-gradient(180deg, #0a0a0f 0%, #0a1515 50%, #0a0a0f 100%); -} -.chapter-rebirth .chapter-title { color: #00FFFF; /* TempleOS cyan */ } -.chapter-rebirth .chapter-title::after { background: linear-gradient(90deg, #00FFFF, transparent); } -.chapter-rebirth .chapter-number { color: rgba(0,255,255,0.08); } - -/* Beast Section */ -.chapter-beast { - background: linear-gradient(180deg, #0a0a0f 0%, #1a0a05 50%, #0a0a0f 100%); -} -.chapter-beast .chapter-title { color: #ff8e53; } -.chapter-beast .chapter-title::after { background: linear-gradient(90deg, #ff8e53, transparent); } - -/* Feature Cards - Full Width Grid */ -.feature-section { - width: 100%; - padding: 100px 40px; - background: linear-gradient(180deg, #0a0a0f 0%, #0f0f18 50%, #0a0a0f 100%); - position: relative; -} -.feature-section .chapter-title::after { - margin-left: auto; - margin-right: auto; -} -.feature-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 30px; - max-width: 1400px; - margin: 0 auto; -} -.feature-card { - background: linear-gradient(145deg, rgba(30,30,40,0.8) 0%, rgba(20,20,30,0.9) 100%); - padding: 40px; - border-radius: 16px; - border: 1px solid rgba(255,100,100,0.1); - transition: all 0.4s ease; - position: relative; - overflow: hidden; -} -.feature-card::before { - content: ''; - position: absolute; - top: 0; left: 0; - width: 100%; height: 4px; - background: linear-gradient(90deg, #ff6b6b, #ff8e53, #4ecdc4); - opacity: 0; - transition: opacity 0.4s; -} -.feature-card:hover { - transform: translateY(-5px); - border-color: rgba(255,100,100,0.3); - box-shadow: 0 20px 60px rgba(0,0,0,0.4), 0 0 40px rgba(255,100,100,0.1); -} -.feature-card:hover::before { opacity: 1; } -.feature-card h3 { - font-size: 1.4em; - color: #ff6b6b; - margin-bottom: 15px; - font-weight: 700; -} -.feature-card p { - color: #999; - line-height: 1.7; - font-size: 1.05em; -} -.feature-card a { color: #4ecdc4; } - -/* Code Blocks */ -.code-section { - background: #0d0d12; - padding: 40px; - border-radius: 12px; - margin: 40px 0; - overflow-x: auto; - border: 1px solid #222; -} -.code-section pre { - margin: 0; - font-family: 'Fira Code', 'Monaco', monospace; - font-size: 2em; - line-height: 1.8; - color: #e0e0e0; -} -.code-section code { background: none; } - -/* Inline Code */ -.narrative code { - background: rgba(255,100,100,0.1); - padding: 3px 10px; - border-radius: 4px; - font-family: 'Fira Code', monospace; - font-size: 0.9em; - color: #ff8e53; -} - -/* Mirror Section - Special */ -.chapter-mirror { - background: linear-gradient(180deg, #0a0a0f 0%, #0a0a1f 30%, #100a1a 70%, #0a0a0f 100%); - position: relative; -} -.chapter-mirror::before { - content: ''; - position: absolute; - top: 0; left: 0; right: 0; bottom: 0; - background: repeating-linear-gradient( - 90deg, - transparent 0px, - transparent 100px, - rgba(100,100,255,0.02) 100px, - rgba(100,100,255,0.02) 101px - ); - pointer-events: none; -} - -/* Transition into cyan (Why We Archive) */ -.chapter-to-cyan { - background: linear-gradient(180deg, #0a0a0f 0%, #0a0a0f calc(100% - 121px), #55FFFF 100%); -} -.chapter-to-cyan .chapter-number { color: rgba(255,50,50,0.1); } - -/* The Forge - TempleOS background */ -.chapter-forge { - background: #55FFFF; - position: relative; -} -.chapter-forge::before { content: none; } - -/* Transition from cyan (Rules?) */ -.chapter-from-cyan { - background: linear-gradient(180deg, #55FFFF 0%, #0a0a0f 121px, #0a0a0f 100%); - padding-top: 200px; -} -.chapter-from-cyan .chapter-number { color: rgba(255,50,50,0.1); top: 140px; } -.forge-logo { - display: block; - max-width: 100%; - height: auto; - margin: 0 auto; -} -.chapter-forge .chapter-content { - position: relative; - z-index: 1; -} -.chapter-forge .chapter-title { color: #000; } -.chapter-forge .chapter-title::after { background: linear-gradient(90deg, #000, transparent); } -.chapter-forge .chapter-number { color: rgba(0,0,0,0.15); } -.chapter-forge .narrative { color: #000; } -.chapter-forge .narrative a { color: #006666; } -.chapter-forge .narrative em { color: #004444; } -.chapter-forge .narrative strong { color: #000; } -.chapter-forge .dramatic-quote { background: rgba(0,0,0,0.15); border-left-color: #000; } -.chapter-forge .dramatic-quote p { color: #000; font-style: italic; } -.chapter-forge .dramatic-quote cite { color: #333; } - -/* Rules? - 72 Benedictine principles */ -.rule-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 12px; - margin: 40px 0; -} -.rule-item { - font-size: 1.05em; - line-height: 1.6; - color: #b0b0b0; - padding: 12px 16px; - background: rgba(255,255,255,0.02); - border-radius: 6px; - border-left: 3px solid rgba(255,107,107,0.3); -} -.rule-item strong { - color: #ff6b6b; - font-weight: 700; -} -@media (max-width: 768px) { - .rule-grid { grid-template-columns: 1fr; } -} - -.chapter-mirror .chapter-title { color: #8b5cf6; } -.chapter-mirror .chapter-title::after { background: linear-gradient(90deg, #8b5cf6, transparent); } -.chapter-mirror .chapter-number { color: rgba(139,92,246,0.08); } - -/* Footer */ -.about-footer { - width: 100%; - padding: 80px 40px; - background: #050508; - text-align: center; -} -.about-footer p { - color: #666; - font-size: 1.1em; - margin-bottom: 20px; -} -.about-footer a { - color: #ff6b6b; - font-size: 1.2em; - text-decoration: none; - border-bottom: 2px solid rgba(255,107,107,0.3); - padding-bottom: 4px; - transition: all 0.3s; -} -.about-footer a:hover { - color: #ff8e53; - border-bottom-color: #ff8e53; -} - -/* Responsive */ -@media (max-width: 768px) { - .hero-title { font-size: 3.5em; } - .hero-pig { width: 200px; } - .chapter { padding: 80px 20px; } - .chapter-number { font-size: 5em; top: 20px; left: 20px; } - .chapter-title { font-size: 2em; } - .narrative { font-size: 1.1em; } - .dramatic-quote { padding: 30px; } - .dramatic-quote p { font-size: 1.3em; } - .feature-section { padding: 60px 20px; } -} -""" - -ABOUT_CONTENT = """ -
- - -
- pig.py -

neopig

-

Neo Python Image Grabber

-

- A phoenix-like chimera grown from the bones of dead repositories & forums. A pig that devours web domains whole. Where others archive pages, neopig consumes all digital files.

Welcome to An Age of Aquarius, no deceitful secrets. -

-
- - -
-
I
-
-

Genesis

- -
-

pig.py is a very simple python command line tool to download all the images from a given uri.

- — Russell Ballestrini, August 22, 2011 -
- -

- In the beginning, there was pig.py. A simple creature. Innocent. Hungry. -

- -

- Russell Ballestrini crafted it in the summer of 2011 — a humble Python script with a singular appetite: images. Point it at a webpage, and it would slurp down every pixel it could find. No configuration. No complexity. Just a hungry little pig gobbling up the visual fabric of the web. -

- -
-
python pig.py https://www.foxhop.net
-
- -

- That was it. Entire interface. Pig asked only for a target, and it fed. -

- -

- Russell released it into the public domain — a gift to anyone who needed to harvest images from the wild web. Source lived at bitbucket.org/russellballestrini/pig, nestled safely in a Mercurial repository. Pig slept soundly in its pen, unaware of the extinction event approaching. -

-
-
- - -
-
II
-
-

A Great Bitbucket Extinction

- -

- Then came the dark times. -

- -

- In 2020, Atlassian — the corporate leviathan that had swallowed Bitbucket whole — decreed that Mercurial repositories would be purged. All of them. Every hg repo on Bitbucket Cloud, atomized. Scattered like digital ash across the void. Atlassian never disclosed how many. -

- Bitbucket extinction - -

- Original pig.py, that innocent image-gobbling creature, was among the casualties. Its home at bitbucket.org/russellballestrini/pig became a tombstone. Click it. We dare you. There's nothing there but the echo of corporate indifference. -

- -
-

They literally killed pig.py & sent him to bitbucket.

- — Cruel irony of naming your graveyard after a slang term for death -
- -

- "Sent to the bitbucket" — programmer slang from the age of punch cards. When you discarded bad data, you threw it in the bit bucket. Atlassian named their service after a trash can, then proved the prophecy true by throwing away everyone's code. -

- -

- Pig was dead. Its bones scattered across cached search results & archived blog posts. A ghost in the machine, referenced but unreachable. -

- -

- But here's where it gets weird. -

-
-
- - -
-
III
-
-

An Alchemy

- -

- Russell once wrote that "programming is like alchemy — instead of exchanging matter, we programmers exchange time." -

- Alchemy - -

- Programs are golems. Familiar spirits. Magical servants performing repetitive tasks so we don't have to. "It is more accurate to group programs with technology than magic, but less fun." -

- -

- Years passed. Web continued its relentless churn — sites going dark, forums shutting down, communities scattering like startled birds. Somewhere, a very angry ex-Ruby developer (Python too) watched the digital decay & remembered the pig. -

- -
-

What if we could transmute that dead code into gold?

- — Thought that started everything -
- -

- And speaking of transmutation: Marathon Fusion discovered that tokamak breeding blankets — wrapped in Mercury-Lithium alloy, like pigs in a blanket — can transmute Mercury-198 into Gold-197 through chrysopoeia. Fast neutrons trigger (n, 2n) reactions; unstable mercury decays into stable gold within 64 hours. -

- -

- Two metric tons of gold per gigawatt. Alchemists' dream realized, wrapped in radioactive patience (17.7 years of cooling before you can touch your transmuted treasure). -

- -

- A golden goose born from the ashes of deprecated version control. Old pig was slaughtered, while a new creature stirred in the digital depths... -

- -

- Machine learning uses an equivalent amount of wu wei energy that elites used to kill pig.py to unfold neopig.py. -

-
-
- - -
-
IV
-
-

A Pig Awakens

- -

- Like a phoenix rising from dead Bitbucket repos, neopig emerged. -

- Neopig grows hungrier - -

- Not a resurrection. Not a mere fork. Something else. A chimera. A griffin. A creature stitched together from the DNA of the original pig & the fevered dreams of someone who had watched too many sites die. -

- -
-

A pig with an appetite like Gluttony from Fullmetal Alchemist — a homunculus that devours everything in its path, absorbing knowledge, power & form.

-
- -

- Where pig.py sipped politely from single pages, neopig devours entire domains. It doesn't just grab images — it consumes HTML, screenshots pages, converts content to markdown, indexes every scrap of text, and stores it all in content-addressed vaults that will outlast the original servers. -

- -

- Code lives now at git.unturf.com/engineering/unturf/pig.py. And it keeps evolving. Every crawl adds new capabilities. Every archived site teaches it new tricks. -

- -

- We are adding the ability to backup entire codebases. Neopig grows hungrier. -

-
-
- - -
-
V
-
-

A Mirror Dimension

- -

- We are about to invert the git tree into itself. -

- A sleeping pig - -

- Like Dr. Strange versus Spider-Man in the mirror dimension — that impossible space where geometry folds back on itself, where buildings become M.C. Escher nightmares & reality reflects endlessly into its own depths. -

- -

- neopig doesn't just archive websites. It can archive itself. It can archive the repositories that contain the code that does the archiving. It can crawl git forges & preserve the source code of tools that preserve source code. -

- -
-

Our spider-pig gets out of the danger of the mirror void — where days are years — and exits absorbing everything like a griffin, like a chimera, like a pig that feeds on bones of dead platforms.

-
- -

- When a code hosting platform dies (and they all die eventually — Google Code, Gitorious, BitBucket's hg repos), neopig ensures the knowledge survives. We are the ark. We are the vault. We are the pig that remembers. -

- -

- Archives become self-extracting executables. .run files that contain their own server, their own search engine, their own reality. Drop one on a machine with Python, and it wakes up. A sleeping pig, carrying an entire website in its belly, ready to serve it to anyone who asks. -

-
-
- - -
-
VI
-
-

The Arsenal

- -
-
-

Async Domain Crawling

-

Full-domain recursive crawling with configurable depth. Respects robots.txt & crawl delays. Polite but relentless.

-
-
-

Content-Addressed Vault

-

MD5-based deduplication in a 9-deep hex directory structure. Same image from 100 pages? Stored once. Space is memory.

-
-
-

Full-Text Search

-

Every image indexed with its surrounding context — page title, alt text, captions, nearby headings. Find images by meaning, not filename.

-
-
-

Page Screenshots

-

LinkPeek reborn! Full-page captures via uri2png. wkhtmltoimage, cutycapt, or Playwright backends.

-
-
-

Hydra Mode

-

Auto-discover RSS/Atom/Sitemap feeds. Persists feed URLs, checks for new content on every crawl. Self-healing archive maintenance.

-
-
-

Media Provenance

-

Track first_seen, last_seen, and when each page first linked to media. Watch content spread across the web over time.

-
-
-

Markdown Conversion

-

Intelligent HTML-to-Markdown with forum post detection, noise removal & content extraction. Human-readable archives.

-
-
-

Self-Extracting Archives

-

Create .run executables that contain entire websites. Drop on any machine, execute, browse. Pig travels light.

-
-
-

Code File Support

-

Archive source code, stylesheets, fonts, and scripts alongside media. Complete preservation for developer communities.

-
-
-

VCS Repository Cloning

-

Auto-detect GitHub, GitLab, Bitbucket & more. Clone repos, symlink files to vault. Deduplicated code across forks.

-
-
-

Live Feed

-

Watch media appear in real-time as crawls progress. SSE-powered live updates. Pig hunts while you watch.

-
-
-

SERP Web Interface

-

FastAPI-powered search engine. Browse by type, search by context, view media details. Your personal media search engine.

-
-
-
-
- - -
-
VII
-
-

A Skeleton Key 🗝️🔑

- -

- neopig uses what we call the "skeleton key" approach to media indexing. For every image, we capture all the text that might help you find it later: -

- Grow food not lawn - -

- Page context: Title, description, headings near the image.
- Image attributes: Alt text, title, surrounding captions.
- Link context: Text of links pointing to the image.
- Detail pages: For gallery sites, we follow through to detail pages & harvest their metadata too. -

- -

- Result? You can search for "sunset over mountains" and find that image even if it was named IMG_4372.jpg with no alt text — because the page title mentioned it, or someone linked to it with descriptive text, or a caption three divs away contained the words. -

- -

- Every door opens. Every search succeeds. Skeleton key fits every lock because it carries every possible key inside it. -

-
-
- - -
-
VIII
-
-

Why We Archive

- -

- Web is ephemeral. Sites go dark. Forums shut down. Communities scatter. Platforms get acquired & gutted. Executives decide that Mercurial isn't profitable & delete every repository with a single corporate memo. -

- -

- We have watched beloved communities vanish. Game modding forums. Technical documentation. Art galleries. Fan wikis. Personal blogs with decades of writing. Gone. Not archived by the Wayback Machine. Not cached by Google. Just gone. -

- -

- neopig is built for preservation — capturing not just the media, but the context that gives it meaning. When you archive a site with neopig, you get: -

- -

- • Original HTML with rewritten media links pointing to your local vault
- • Full-text searchable markdown versions of every page
- • Screenshots showing exactly how pages looked
- • A SQLite database you can query, backup, and migrate forever
- • Self-extracting archives that work offline forever -


-

-
-
- - -
-
IX
-
-

A Forge

- -

- This tool was not built for profit. It was forged. -

- -

- Forged in the fires of permacomputer.com — the belief that computing should be permanent. That knowledge should not evaporate when a company pivots. That communities should not lose their histories when a platform gets acquired. That the lore doesn't have to die when you get acqui-hired or outright terminated 🟣. A digital commons that belongs to everyone, forever. -

- - - -
-

Truth. Harmony. Freedom. Love.

- — The quadrivium of a permacomputer -
- -

- Truth — We preserve what actually existed, not sanitized versions. Original HTML. Exact timestamps. Unedited conversations. History as it happened, not as someone wishes it had. -

- -

- Harmony — Every piece connects. Media links to pages. Pages link to context. Context links to meaning. Archive is not a graveyard of disconnected files but a living web of relationships, searchable, traversable, whole. -

- -

- Freedom — Public domain. No licenses to parse. No corporate terms of service. Take it. Fork it. Run it. Share it. Code is yours. Archives you create are yours. No one can revoke your access to your own preserved knowledge. -

- -

- Love — We archive because we care. About the communities that built these spaces. About the people who poured years into forum posts & wiki pages & blog entries. About the future researchers who will want to understand how we lived & thought & created. This is an act of love for human knowledge. -

- -

- Pig is imbued with righteous fire. It does not crawl politely because it fears the powerful — it respects robots.txt because it honors the wishes of creators. It does not hoard in darkness — it preserves in light, making archives searchable, shareable, alive. A pig imbued with righteous fire. -

- -

- When the platforms fall (and they will fall), when the corporate servers go dark (and they will go dark), when the executives decide your community isn't profitable enough to maintain (and they will decide this) — a permacomputer endures. Archives persist. Knowledge survives. -

- -

- This is what we were forged to do. -

-
-
- - -
-
X
-
-

Rules?

- -

- neopig is built on SQLite — the most deployed database in existence. SQLite adopted the Rule of St. Benedict as their Code of Ethics. 72 principles from a 6th-century monastery, governing software in the 21st. -

- -

- We inherit this lineage. All 72 rules, unabridged: -

- -
-
1. Love the Lord God with your whole heart, soul, and strength.
-
2. Love your neighbor as yourself.
-
3. Do not murder.
-
4. Do not commit adultery.
-
5. Do not steal.
-
6. Do not covet.
-
7. Do not bear false witness.
-
8. Honor all people.
-
9. Do not do to another what you would not have done to yourself.
-
10. Deny oneself in order to follow Christ.
-
11. Chastise the body.
-
12. Do not become attached to pleasures.
-
13. Love fasting.
-
14. Relieve the poor.
-
15. Clothe the naked.
-
16. Visit the sick.
-
17. Bury the dead.
-
18. Be a help in times of trouble.
-
19. Console the sorrowing.
-
20. Be a stranger to the world's ways.
-
21. Prefer nothing more than the love of Christ.
-
22. Do not give way to anger.
-
23. Do not nurse a grudge.
-
24. Do not entertain deceit in your heart.
-
25. Do not give a false peace.
-
26. Do not forsake charity.
-
27. Do not swear, for fear of perjuring yourself.
-
28. Utter only truth from heart and mouth.
-
29. Do not return evil for evil.
-
30. Do no wrong to anyone, and bear patiently wrongs done to yourself.
-
31. Love your enemies.
-
32. Do not curse those who curse you, but rather bless them.
-
33. Bear persecution for justice's sake.
-
34. Be not proud.
-
35. Be not addicted to wine.
-
36. Be not a great eater.
-
37. Be not drowsy.
-
38. Be not lazy.
-
39. Be not a grumbler.
-
40. Be not a detractor.
-
41. Put your hope in God.
-
42. Attribute to God, and not to self, whatever good you see in yourself.
-
43. Recognize always that evil is your own doing, and to impute it to yourself.
-
44. Fear the Day of Judgment.
-
45. Be in dread of hell.
-
46. Desire eternal life with all the passion of the spirit.
-
47. Keep death daily before your eyes.
-
48. Keep constant guard over the actions of your life.
-
49. Know for certain that God sees you everywhere.
-
50. When wrongful thoughts come into your heart, dash them against Christ immediately.
-
51. Disclose wrongful thoughts to your spiritual mentor.
-
52. Guard your tongue against evil and depraved speech.
-
53. Do not love much talking.
-
54. Speak no useless words or words that move to laughter.
-
55. Do not love much or boisterous laughter.
-
56. Listen willingly to holy reading.
-
57. Devote yourself frequently to prayer.
-
58. Daily in your prayers, with tears and sighs, confess your past sins to God, and amend them for the future.
-
59. Fulfill not the desires of the flesh; hate your own will.
-
60. Obey in all things the commands of those whom God has placed in authority over you even though they should act otherwise, mindful of the Lord's precept, "Do what they say, but not what they do."
-
61. Do not wish to be called holy before one is holy; but first to be holy, that you may be truly so called.
-
62. Fulfill God's commandments daily in your deeds.
-
63. Love chastity.
-
64. Hate no one.
-
65. Be not jealous, nor harbor envy.
-
66. Do not love quarreling.
-
67. Shun arrogance.
-
68. Respect your seniors.
-
69. Love your juniors.
-
70. Pray for your enemies in the love of Christ.
-
71. Make peace with your adversary before the sun sets.
-
72. Never despair of God's mercy.
-
- -

- We archive not to hoard, but to give. We preserve not to control, but to liberate. Database that holds your memories runs on a foundation of ancient wisdom — and so do we. -

-
-
- - -
-
XI
-
-

Invoke the Pig

- -
-
# open http://127.0.0.1:31337 in browser
-python neopig.py --serve
-
- -

- neopig = Neo (new) + Python Image Grabber -

- -

- A tip of the hat to original pig.py. A nod to Matrix's Neo — seeing through surface of web to underlying content within. And perhaps a warning: this pig has grown teeth. -

-
-
- - -
-

neopig is open source, gifted into our public domain.

- Gift -

Neopig lives:

- git.unturf.com/engineering/unturf/pig.py -

- In memory of all the repositories Atlassian killed.
- Neopig remembers. Neopig archives. Neopig persists. -

- Oven fresh internet -

- Sorry for the convenience. ✝️ -

-
- -
-""" @app.get("/about", response_class=HTMLResponse) -async def about_page(lang: str = Cookie(None), accept_language: str = Header(None)): +async def about_page(request: Request, lang: str = Cookie(None), accept_language: str = Header(None)): """About neopig - the story of pig.py's evolution.""" language = get_lang(lang, accept_language) - html = layout("{{about}}", ABOUT_CONTENT, extra_css=ABOUT_CSS) - return inject_i18n(html, language) + t = TRANSLATIONS.get(language, TRANSLATIONS["en"]) + + if templates: + return templates.TemplateResponse("about.html.j2", { + "request": request, + "t": t, + "lang": language, + "langs": LANG_NAMES, + "import_mode": IMPORT_MODE, + }) + + raise HTTPException(status_code=500, detail="Templates required") @app.get("/health") @@ -5021,249 +2496,27 @@ async def import_status(): } -IMPORT_CSS = """ - .container { max-width: 800px; margin: 0 auto; } - h1 { color: #ff6b6b; margin-bottom: 5px; } - .subtitle { color: #666; margin-bottom: 30px; } - - .upload-zone { - border: 3px dashed #333; - border-radius: 12px; - padding: 60px 40px; - text-align: center; - background: #111; - cursor: pointer; - transition: all 0.2s; - } - .upload-zone:hover, .upload-zone.drag-over { - border-color: #ff6b6b; - background: #1a1a1a; - } - .upload-zone input[type="file"] { display: none; } - .upload-icon { font-size: 48px; margin-bottom: 15px; } - .upload-text { color: #888; font-size: 16px; } - .upload-hint { color: #555; font-size: 13px; margin-top: 10px; } - - .status { - margin-top: 30px; - padding: 20px; - background: #1a1a1a; - border-radius: 8px; - border: 1px solid #333; - } - .status-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #222; } - .status-row:last-child { border-bottom: none; } - .status-label { color: #888; } - .status-value { color: #e0e0e0; font-family: monospace; } - - .progress { display: none; margin-top: 20px; } - .progress-bar { - height: 8px; background: #333; border-radius: 4px; overflow: hidden; - } - .progress-fill { - height: 100%; background: #ff6b6b; width: 0%; transition: width 0.3s; - } - .progress-text { text-align: center; margin-top: 10px; color: #888; font-size: 14px; } - - .success { color: #6f6; } - .error { color: #f66; } -""" - -IMPORT_CONTENT = """ -
-

Import Mode 🧪

-

Upload a neopig archive (.tar.gz or .run) to explore it

- -
-
📦
-
Drop archive here or click to browse
-
Supports .tar.gz, .tgz, and .run files
- -
- -
-
-
Uploading...
-
- -
-

Current Archive

-
Loading...
-
-
- - -""" @app.get("/import", response_class=HTMLResponse) -async def import_page(lang: str = Cookie(None), accept_language: str = Header(None)): +async def import_page(request: Request, lang: str = Cookie(None), accept_language: str = Header(None)): """Import page for uploading archives.""" if not IMPORT_MODE: raise HTTPException(status_code=403, detail="Import mode not enabled (NEOPIG_IMPORT=1)") language = get_lang(lang, accept_language) - html = layout("Import", IMPORT_CONTENT, extra_css=IMPORT_CSS) - return inject_i18n(html, language) + t = TRANSLATIONS.get(language, TRANSLATIONS["en"]) + + if templates: + return templates.TemplateResponse("import.html.j2", { + "request": request, + "t": t, + "t_json": json.dumps(t), + "lang": language, + "langs": LANG_NAMES, + "import_mode": IMPORT_MODE, + }) + + raise HTTPException(status_code=500, detail="Templates required") # ============================================================================ diff --git a/templates/about.html.j2 b/templates/about.html.j2 new file mode 100644 index 0000000..cb4ee42 --- /dev/null +++ b/templates/about.html.j2 @@ -0,0 +1,889 @@ +{% extends "base.html.j2" %} + +{% block title %}{{ t.about }} - neopig{% endblock %} + +{% block extra_css %} +.about-page { width: 100%; min-height: 100vh; background: #0a0a0f; } + +/* Hero Section */ +.hero-section { + width: 100%; + min-height: 100vh; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background: linear-gradient(180deg, #0a0a0f 0%, #1a0a1a 50%, #0f0a1a 100%); + position: relative; + overflow: hidden; + padding: 60px 20px; +} +.hero-section::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: radial-gradient(ellipse at 50% 0%, rgba(255,50,100,0.15) 0%, transparent 60%), + radial-gradient(ellipse at 20% 80%, rgba(100,50,255,0.1) 0%, transparent 40%), + radial-gradient(ellipse at 80% 60%, rgba(255,100,50,0.08) 0%, transparent 40%); + pointer-events: none; +} +.hero-pig { + width: 280px; + height: auto; + filter: drop-shadow(0 0 60px rgba(255,100,100,0.5)) drop-shadow(0 0 120px rgba(255,50,100,0.3)); + animation: pulse-glow 4s ease-in-out infinite; + margin-bottom: 40px; +} +@keyframes pulse-glow { + 0%, 100% { filter: drop-shadow(0 0 60px rgba(255,100,100,0.5)) drop-shadow(0 0 120px rgba(255,50,100,0.3)); } + 50% { filter: drop-shadow(0 0 80px rgba(255,100,100,0.7)) drop-shadow(0 0 160px rgba(255,50,100,0.5)); } +} +.hero-title { + font-size: 6em; + font-weight: 900; + letter-spacing: -0.02em; + background: linear-gradient(135deg, #ff6b6b 0%, #ff8e53 50%, #ff6b9d 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0 0 80px rgba(255,100,100,0.5); + margin: 0; + text-align: center; +} +.hero-subtitle { + font-size: 1.4em; + color: #888; + margin-top: 20px; + font-weight: 300; + letter-spacing: 0.3em; + text-transform: uppercase; +} +.hero-tagline { + font-size: 1.1em; + color: #666; + margin-top: 30px; + max-width: 600px; + text-align: center; + line-height: 1.8; +} + +/* Chapter Sections */ +.chapter { + width: 100%; + padding: 120px 40px; + position: relative; +} +.chapter-content { + max-width: 900px; + margin: 0 auto; +} +.chapter-number { + font-size: 8em; + font-weight: 900; + color: rgba(255,100,100,0.08); + position: absolute; + top: 40px; + left: 40px; + line-height: 1; + font-family: 'Georgia', serif; +} +.chapter-title { + font-size: 3em; + font-weight: 800; + color: #ff6b6b; + margin-bottom: 40px; + position: relative; +} +.chapter-title::after { + content: ''; + display: block; + width: 100px; + height: 4px; + background: linear-gradient(90deg, #ff6b6b, transparent); + margin-top: 20px; +} + +/* Narrative Text */ +.narrative { + font-size: 1.3em; + line-height: 2; + color: #c0c0c0; + margin-bottom: 30px; +} +.narrative em { color: #ff8e53; font-style: italic; } +.narrative strong { color: #fff; font-weight: 600; } +.narrative a { color: #ff6b6b; text-decoration: none; border-bottom: 1px solid rgba(255,107,107,0.3); transition: all 0.3s; } +.narrative a:hover { color: #ff8e53; border-bottom-color: #ff8e53; } + +/* Dramatic Quote */ +.dramatic-quote { + padding: 60px; + margin: 60px 0; + background: linear-gradient(135deg, rgba(255,50,100,0.1) 0%, rgba(100,50,200,0.1) 100%); + border-left: 6px solid #ff6b6b; + position: relative; +} +.dramatic-quote::before { + content: '"'; + font-size: 12em; + color: rgba(255,100,100,0.15); + position: absolute; + top: -40px; + left: 20px; + font-family: 'Georgia', serif; + line-height: 1; +} +.dramatic-quote p { + font-size: 1.8em; + font-style: italic; + color: #e0e0e0; + margin: 0; + position: relative; + z-index: 1; +} +.dramatic-quote cite { + display: block; + margin-top: 30px; + font-size: 1em; + color: #888; + font-style: normal; +} + +/* Dead Link Styling */ +.dead-link { + text-decoration: line-through; + color: #666; + cursor: not-allowed; + position: relative; +} +.dead-link::after { + content: ' [DEAD]'; + color: #ff4444; + font-size: 0.7em; + font-weight: bold; + text-decoration: none; +} + +/* Dark Chapter */ +.chapter-dark { + background: linear-gradient(180deg, #0a0a0f 0%, #150810 50%, #0a0a0f 100%); +} +.chapter-dark .chapter-number { color: rgba(255,50,50,0.1); } + +/* Rebirth Chapter - TempleOS cyan */ +.chapter-rebirth { + background: linear-gradient(180deg, #0a0a0f 0%, #0a1515 50%, #0a0a0f 100%); +} +.chapter-rebirth .chapter-title { color: #00FFFF; } +.chapter-rebirth .chapter-title::after { background: linear-gradient(90deg, #00FFFF, transparent); } +.chapter-rebirth .chapter-number { color: rgba(0,255,255,0.08); } + +/* Beast Section */ +.chapter-beast { + background: linear-gradient(180deg, #0a0a0f 0%, #1a0a05 50%, #0a0a0f 100%); +} +.chapter-beast .chapter-title { color: #ff8e53; } +.chapter-beast .chapter-title::after { background: linear-gradient(90deg, #ff8e53, transparent); } + +/* Feature Cards */ +.feature-section { + width: 100%; + padding: 100px 40px; + background: linear-gradient(180deg, #0a0a0f 0%, #0f0f18 50%, #0a0a0f 100%); + position: relative; +} +.feature-section .chapter-title::after { + margin-left: auto; + margin-right: auto; +} +.feature-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 30px; + max-width: 1400px; + margin: 0 auto; +} +.feature-card { + background: linear-gradient(145deg, rgba(30,30,40,0.8) 0%, rgba(20,20,30,0.9) 100%); + padding: 40px; + border-radius: 16px; + border: 1px solid rgba(255,100,100,0.1); + transition: all 0.4s ease; + position: relative; + overflow: hidden; +} +.feature-card::before { + content: ''; + position: absolute; + top: 0; left: 0; + width: 100%; height: 4px; + background: linear-gradient(90deg, #ff6b6b, #ff8e53, #4ecdc4); + opacity: 0; + transition: opacity 0.4s; +} +.feature-card:hover { + transform: translateY(-5px); + border-color: rgba(255,100,100,0.3); + box-shadow: 0 20px 60px rgba(0,0,0,0.4), 0 0 40px rgba(255,100,100,0.1); +} +.feature-card:hover::before { opacity: 1; } +.feature-card h3 { + font-size: 1.4em; + color: #ff6b6b; + margin-bottom: 15px; + font-weight: 700; +} +.feature-card p { + color: #999; + line-height: 1.7; + font-size: 1.05em; +} +.feature-card a { color: #4ecdc4; } + +/* Code Blocks */ +.code-section { + background: #0d0d12; + padding: 40px; + border-radius: 12px; + margin: 40px 0; + overflow-x: auto; + border: 1px solid #222; +} +.code-section pre { + margin: 0; + font-family: 'Fira Code', 'Monaco', monospace; + font-size: 2em; + line-height: 1.8; + color: #e0e0e0; +} +.code-section code { background: none; } + +/* Inline Code */ +.narrative code { + background: rgba(255,100,100,0.1); + padding: 3px 10px; + border-radius: 4px; + font-family: 'Fira Code', monospace; + font-size: 0.9em; + color: #ff8e53; +} + +/* Mirror Section */ +.chapter-mirror { + background: linear-gradient(180deg, #0a0a0f 0%, #0a0a1f 30%, #100a1a 70%, #0a0a0f 100%); + position: relative; +} +.chapter-mirror::before { + content: ''; + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + background: repeating-linear-gradient( + 90deg, + transparent 0px, + transparent 100px, + rgba(100,100,255,0.02) 100px, + rgba(100,100,255,0.02) 101px + ); + pointer-events: none; +} + +/* Transition into cyan */ +.chapter-to-cyan { + background: linear-gradient(180deg, #0a0a0f 0%, #0a0a0f calc(100% - 121px), #55FFFF 100%); +} +.chapter-to-cyan .chapter-number { color: rgba(255,50,50,0.1); } + +/* The Forge - TempleOS background */ +.chapter-forge { + background: #55FFFF; + position: relative; +} +.chapter-forge::before { content: none; } + +/* Transition from cyan */ +.chapter-from-cyan { + background: linear-gradient(180deg, #55FFFF 0%, #0a0a0f 121px, #0a0a0f 100%); + padding-top: 200px; +} +.chapter-from-cyan .chapter-number { color: rgba(255,50,50,0.1); top: 140px; } +.forge-logo { + display: block; + max-width: 100%; + height: auto; + margin: 0 auto; +} +.chapter-forge .chapter-content { + position: relative; + z-index: 1; +} +.chapter-forge .chapter-title { color: #000; } +.chapter-forge .chapter-title::after { background: linear-gradient(90deg, #000, transparent); } +.chapter-forge .chapter-number { color: rgba(0,0,0,0.15); } +.chapter-forge .narrative { color: #000; } +.chapter-forge .narrative a { color: #006666; } +.chapter-forge .narrative em { color: #004444; } +.chapter-forge .narrative strong { color: #000; } +.chapter-forge .dramatic-quote { background: rgba(0,0,0,0.15); border-left-color: #000; } +.chapter-forge .dramatic-quote p { color: #000; font-style: italic; } +.chapter-forge .dramatic-quote cite { color: #333; } + +/* Rules grid */ +.rule-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 12px; + margin: 40px 0; +} +.rule-item { + font-size: 1.05em; + line-height: 1.6; + color: #b0b0b0; + padding: 12px 16px; + background: rgba(255,255,255,0.02); + border-radius: 6px; + border-left: 3px solid rgba(255,107,107,0.3); +} +.rule-item strong { + color: #ff6b6b; + font-weight: 700; +} +@media (max-width: 768px) { + .rule-grid { grid-template-columns: 1fr; } +} + +.chapter-mirror .chapter-title { color: #8b5cf6; } +.chapter-mirror .chapter-title::after { background: linear-gradient(90deg, #8b5cf6, transparent); } +.chapter-mirror .chapter-number { color: rgba(139,92,246,0.08); } + +/* Footer */ +.about-footer { + width: 100%; + padding: 80px 40px; + background: #050508; + text-align: center; +} +.about-footer p { + color: #666; + font-size: 1.1em; + margin-bottom: 20px; +} +.about-footer a { + color: #ff6b6b; + font-size: 1.2em; + text-decoration: none; + border-bottom: 2px solid rgba(255,107,107,0.3); + padding-bottom: 4px; + transition: all 0.3s; +} +.about-footer a:hover { + color: #ff8e53; + border-bottom-color: #ff8e53; +} + +/* Responsive */ +@media (max-width: 768px) { + .hero-title { font-size: 3.5em; } + .hero-pig { width: 200px; } + .chapter { padding: 80px 20px; } + .chapter-number { font-size: 5em; top: 20px; left: 20px; } + .chapter-title { font-size: 2em; } + .narrative { font-size: 1.1em; } + .dramatic-quote { padding: 30px; } + .dramatic-quote p { font-size: 1.3em; } + .feature-section { padding: 60px 20px; } +} +{% endblock %} + +{% block content %} +
+ + +
+ pig.py +

neopig

+

Neo Python Image Grabber

+

+ A phoenix-like chimera grown from the bones of dead repositories & forums. A pig that devours web domains whole. Where others archive pages, neopig consumes all digital files.

Welcome to An Age of Aquarius, no deceitful secrets. +

+
+ + +
+
I
+
+

Genesis

+ +
+

pig.py is a very simple python command line tool to download all the images from a given uri.

+ — Russell Ballestrini, August 22, 2011 +
+ +

+ In the beginning, there was pig.py. A simple creature. Innocent. Hungry. +

+ +

+ Russell Ballestrini crafted it in the summer of 2011 — a humble Python script with a singular appetite: images. Point it at a webpage, and it would slurp down every pixel it could find. No configuration. No complexity. Just a hungry little pig gobbling up the visual fabric of the web. +

+ +
+
python pig.py https://www.foxhop.net
+
+ +

+ That was it. Entire interface. Pig asked only for a target, and it fed. +

+ +

+ Russell released it into the public domain — a gift to anyone who needed to harvest images from the wild web. Source lived at bitbucket.org/russellballestrini/pig, nestled safely in a Mercurial repository. Pig slept soundly in its pen, unaware of the extinction event approaching. +

+
+
+ + +
+
II
+
+

A Great Bitbucket Extinction

+ +

+ Then came the dark times. +

+ +

+ In 2020, Atlassian — the corporate leviathan that had swallowed Bitbucket whole — decreed that Mercurial repositories would be purged. All of them. Every hg repo on Bitbucket Cloud, atomized. Scattered like digital ash across the void. Atlassian never disclosed how many. +

+ Bitbucket extinction + +

+ Original pig.py, that innocent image-gobbling creature, was among the casualties. Its home at bitbucket.org/russellballestrini/pig became a tombstone. Click it. We dare you. There's nothing there but the echo of corporate indifference. +

+ +
+

They literally killed pig.py & sent him to bitbucket.

+ — Cruel irony of naming your graveyard after a slang term for death +
+ +

+ "Sent to the bitbucket" — programmer slang from the age of punch cards. When you discarded bad data, you threw it in the bit bucket. Atlassian named their service after a trash can, then proved the prophecy true by throwing away everyone's code. +

+ +

+ Pig was dead. Its bones scattered across cached search results & archived blog posts. A ghost in the machine, referenced but unreachable. +

+ +

+ But here's where it gets weird. +

+
+
+ + +
+
III
+
+

An Alchemy

+ +

+ Russell once wrote that "programming is like alchemy — instead of exchanging matter, we programmers exchange time." +

+ Alchemy + +

+ Programs are golems. Familiar spirits. Magical servants performing repetitive tasks so we don't have to. "It is more accurate to group programs with technology than magic, but less fun." +

+ +

+ Years passed. Web continued its relentless churn — sites going dark, forums shutting down, communities scattering like startled birds. Somewhere, a very angry ex-Ruby developer (Python too) watched the digital decay & remembered the pig. +

+ +
+

What if we could transmute that dead code into gold?

+ — Thought that started everything +
+ +

+ And speaking of transmutation: Marathon Fusion discovered that tokamak breeding blankets — wrapped in Mercury-Lithium alloy, like pigs in a blanket — can transmute Mercury-198 into Gold-197 through chrysopoeia. Fast neutrons trigger (n, 2n) reactions; unstable mercury decays into stable gold within 64 hours. +

+ +

+ Two metric tons of gold per gigawatt. Alchemists' dream realized, wrapped in radioactive patience (17.7 years of cooling before you can touch your transmuted treasure). +

+ +

+ A golden goose born from the ashes of deprecated version control. Old pig was slaughtered, while a new creature stirred in the digital depths... +

+ +

+ Machine learning uses an equivalent amount of wu wei energy that elites used to kill pig.py to unfold neopig.py. +

+
+
+ + +
+
IV
+
+

A Pig Awakens

+ +

+ Like a phoenix rising from dead Bitbucket repos, neopig emerged. +

+ Neopig grows hungrier + +

+ Not a resurrection. Not a mere fork. Something else. A chimera. A griffin. A creature stitched together from the DNA of the original pig & the fevered dreams of someone who had watched too many sites die. +

+ +
+

A pig with an appetite like Gluttony from Fullmetal Alchemist — a homunculus that devours everything in its path, absorbing knowledge, power & form.

+
+ +

+ Where pig.py sipped politely from single pages, neopig devours entire domains. It doesn't just grab images — it consumes HTML, screenshots pages, converts content to markdown, indexes every scrap of text, and stores it all in content-addressed vaults that will outlast the original servers. +

+ +

+ Code lives now at git.unturf.com/engineering/unturf/pig.py. And it keeps evolving. Every crawl adds new capabilities. Every archived site teaches it new tricks. +

+ +

+ We are adding the ability to backup entire codebases. Neopig grows hungrier. +

+
+
+ + +
+
V
+
+

A Mirror Dimension

+ +

+ We are about to invert the git tree into itself. +

+ A sleeping pig + +

+ Like Dr. Strange versus Spider-Man in the mirror dimension — that impossible space where geometry folds back on itself, where buildings become M.C. Escher nightmares & reality reflects endlessly into its own depths. +

+ +

+ neopig doesn't just archive websites. It can archive itself. It can archive the repositories that contain the code that does the archiving. It can crawl git forges & preserve the source code of tools that preserve source code. +

+ +
+

Our spider-pig gets out of the danger of the mirror void — where days are years — and exits absorbing everything like a griffin, like a chimera, like a pig that feeds on bones of dead platforms.

+
+ +

+ When a code hosting platform dies (and they all die eventually — Google Code, Gitorious, BitBucket's hg repos), neopig ensures the knowledge survives. We are the ark. We are the vault. We are the pig that remembers. +

+ +

+ Archives become self-extracting executables. .run files that contain their own server, their own search engine, their own reality. Drop one on a machine with Python, and it wakes up. A sleeping pig, carrying an entire website in its belly, ready to serve it to anyone who asks. +

+
+
+ + +
+
VI
+
+

The Arsenal

+ +
+
+

Async Domain Crawling

+

Full-domain recursive crawling with configurable depth. Respects robots.txt & crawl delays. Polite but relentless.

+
+
+

Content-Addressed Vault

+

MD5-based deduplication in a 9-deep hex directory structure. Same image from 100 pages? Stored once. Space is memory.

+
+
+

Full-Text Search

+

Every image indexed with its surrounding context — page title, alt text, captions, nearby headings. Find images by meaning, not filename.

+
+
+

Page Screenshots

+

LinkPeek reborn! Full-page captures via uri2png. wkhtmltoimage, cutycapt, or Playwright backends.

+
+
+

Hydra Mode

+

Auto-discover RSS/Atom/Sitemap feeds. Persists feed URLs, checks for new content on every crawl. Self-healing archive maintenance.

+
+
+

Media Provenance

+

Track first_seen, last_seen, and when each page first linked to media. Watch content spread across the web over time.

+
+
+

Markdown Conversion

+

Intelligent HTML-to-Markdown with forum post detection, noise removal & content extraction. Human-readable archives.

+
+
+

Self-Extracting Archives

+

Create .run executables that contain entire websites. Drop on any machine, execute, browse. Pig travels light.

+
+
+

Code File Support

+

Archive source code, stylesheets, fonts, and scripts alongside media. Complete preservation for developer communities.

+
+
+

VCS Repository Cloning

+

Auto-detect GitHub, GitLab, Bitbucket & more. Clone repos, symlink files to vault. Deduplicated code across forks.

+
+
+

Live Feed

+

Watch media appear in real-time as crawls progress. SSE-powered live updates. Pig hunts while you watch.

+
+
+

SERP Web Interface

+

FastAPI-powered search engine. Browse by type, search by context, view media details. Your personal media search engine.

+
+
+
+
+ + +
+
VII
+
+

A Skeleton Key 🗝️🔑

+ +

+ neopig uses what we call the "skeleton key" approach to media indexing. For every image, we capture all the text that might help you find it later: +

+ Grow food not lawn + +

+ Page context: Title, description, headings near the image.
+ Image attributes: Alt text, title, surrounding captions.
+ Link context: Text of links pointing to the image.
+ Detail pages: For gallery sites, we follow through to detail pages & harvest their metadata too. +

+ +

+ Result? You can search for "sunset over mountains" and find that image even if it was named IMG_4372.jpg with no alt text — because the page title mentioned it, or someone linked to it with descriptive text, or a caption three divs away contained the words. +

+ +

+ Every door opens. Every search succeeds. Skeleton key fits every lock because it carries every possible key inside it. +

+
+
+ + +
+
VIII
+
+

Why We Archive

+ +

+ Web is ephemeral. Sites go dark. Forums shut down. Communities scatter. Platforms get acquired & gutted. Executives decide that Mercurial isn't profitable & delete every repository with a single corporate memo. +

+ +

+ We have watched beloved communities vanish. Game modding forums. Technical documentation. Art galleries. Fan wikis. Personal blogs with decades of writing. Gone. Not archived by the Wayback Machine. Not cached by Google. Just gone. +

+ +

+ neopig is built for preservation — capturing not just the media, but the context that gives it meaning. When you archive a site with neopig, you get: +

+ +

+ • Original HTML with rewritten media links pointing to your local vault
+ • Full-text searchable markdown versions of every page
+ • Screenshots showing exactly how pages looked
+ • A SQLite database you can query, backup, and migrate forever
+ • Self-extracting archives that work offline forever +


+

+
+
+ + +
+
IX
+
+

A Forge

+ +

+ This tool was not built for profit. It was forged. +

+ +

+ Forged in the fires of permacomputer.com — the belief that computing should be permanent. That knowledge should not evaporate when a company pivots. That communities should not lose their histories when a platform gets acquired. That the lore doesn't have to die when you get acqui-hired or outright terminated 🟣. A digital commons that belongs to everyone, forever. +

+ + + +
+

Truth. Harmony. Freedom. Love.

+ — The quadrivium of a permacomputer +
+ +

+ Truth — We preserve what actually existed, not sanitized versions. Original HTML. Exact timestamps. Unedited conversations. History as it happened, not as someone wishes it had. +

+ +

+ Harmony — Every piece connects. Media links to pages. Pages link to context. Context links to meaning. Archive is not a graveyard of disconnected files but a living web of relationships, searchable, traversable, whole. +

+ +

+ Freedom — Public domain. No licenses to parse. No corporate terms of service. Take it. Fork it. Run it. Share it. Code is yours. Archives you create are yours. No one can revoke your access to your own preserved knowledge. +

+ +

+ Love — We archive because we care. About the communities that built these spaces. About the people who poured years into forum posts & wiki pages & blog entries. About the future researchers who will want to understand how we lived & thought & created. This is an act of love for human knowledge. +

+ +

+ Pig is imbued with righteous fire. It does not crawl politely because it fears the powerful — it respects robots.txt because it honors the wishes of creators. It does not hoard in darkness — it preserves in light, making archives searchable, shareable, alive. A pig imbued with righteous fire. +

+ +

+ When the platforms fall (and they will fall), when the corporate servers go dark (and they will go dark), when the executives decide your community isn't profitable enough to maintain (and they will decide this) — a permacomputer endures. Archives persist. Knowledge survives. +

+ +

+ This is what we were forged to do. +

+
+
+ + +
+
X
+
+

Rules?

+ +

+ neopig is built on SQLite — the most deployed database in existence. SQLite adopted the Rule of St. Benedict as their Code of Ethics. 72 principles from a 6th-century monastery, governing software in the 21st. +

+ +

+ We inherit this lineage. All 72 rules, unabridged: +

+ +
+
1. Love the Lord God with your whole heart, soul, and strength.
+
2. Love your neighbor as yourself.
+
3. Do not murder.
+
4. Do not commit adultery.
+
5. Do not steal.
+
6. Do not covet.
+
7. Do not bear false witness.
+
8. Honor all people.
+
9. Do not do to another what you would not have done to yourself.
+
10. Deny oneself in order to follow Christ.
+
11. Chastise the body.
+
12. Do not become attached to pleasures.
+
13. Love fasting.
+
14. Relieve the poor.
+
15. Clothe the naked.
+
16. Visit the sick.
+
17. Bury the dead.
+
18. Be a help in times of trouble.
+
19. Console the sorrowing.
+
20. Be a stranger to the world's ways.
+
21. Prefer nothing more than the love of Christ.
+
22. Do not give way to anger.
+
23. Do not nurse a grudge.
+
24. Do not entertain deceit in your heart.
+
25. Do not give a false peace.
+
26. Do not forsake charity.
+
27. Do not swear, for fear of perjuring yourself.
+
28. Utter only truth from heart and mouth.
+
29. Do not return evil for evil.
+
30. Do no wrong to anyone, and bear patiently wrongs done to yourself.
+
31. Love your enemies.
+
32. Do not curse those who curse you, but rather bless them.
+
33. Bear persecution for justice's sake.
+
34. Be not proud.
+
35. Be not addicted to wine.
+
36. Be not a great eater.
+
37. Be not drowsy.
+
38. Be not lazy.
+
39. Be not a grumbler.
+
40. Be not a detractor.
+
41. Put your hope in God.
+
42. Attribute to God, and not to self, whatever good you see in yourself.
+
43. Recognize always that evil is your own doing, and to impute it to yourself.
+
44. Fear the Day of Judgment.
+
45. Be in dread of hell.
+
46. Desire eternal life with all the passion of the spirit.
+
47. Keep death daily before your eyes.
+
48. Keep constant guard over the actions of your life.
+
49. Know for certain that God sees you everywhere.
+
50. When wrongful thoughts come into your heart, dash them against Christ immediately.
+
51. Disclose wrongful thoughts to your spiritual mentor.
+
52. Guard your tongue against evil and depraved speech.
+
53. Do not love much talking.
+
54. Speak no useless words or words that move to laughter.
+
55. Do not love much or boisterous laughter.
+
56. Listen willingly to holy reading.
+
57. Devote yourself frequently to prayer.
+
58. Daily in your prayers, with tears and sighs, confess your past sins to God, and amend them for the future.
+
59. Fulfill not the desires of the flesh; hate your own will.
+
60. Obey in all things the commands of those whom God has placed in authority over you even though they should act otherwise, mindful of the Lord's precept, "Do what they say, but not what they do."
+
61. Do not wish to be called holy before one is holy; but first to be holy, that you may be truly so called.
+
62. Fulfill God's commandments daily in your deeds.
+
63. Love chastity.
+
64. Hate no one.
+
65. Be not jealous, nor harbor envy.
+
66. Do not love quarreling.
+
67. Shun arrogance.
+
68. Respect your seniors.
+
69. Love your juniors.
+
70. Pray for your enemies in the love of Christ.
+
71. Make peace with your adversary before the sun sets.
+
72. Never despair of God's mercy.
+
+ +

+ We archive not to hoard, but to give. We preserve not to control, but to liberate. Database that holds your memories runs on a foundation of ancient wisdom — and so do we. +

+
+
+ + +
+
XI
+
+

Invoke the Pig

+ +
+
# open http://127.0.0.1:31337 in browser
+python neopig.py --serve
+
+ +

+ neopig = Neo (new) + Python Image Grabber +

+ +

+ A tip of the hat to original pig.py. A nod to Matrix's Neo — seeing through surface of web to underlying content within. And perhaps a warning: this pig has grown teeth. +

+
+
+ + +
+

neopig is open source, gifted into our public domain.

+ Gift +

Neopig lives:

+ git.unturf.com/engineering/unturf/pig.py +

+ In memory of all the repositories Atlassian killed.
+ Neopig remembers. Neopig archives. Neopig persists. +

+ Oven fresh internet +

+ Sorry for the convenience. ✝️ +

+
+ +
+{% endblock %} diff --git a/templates/base.html.j2 b/templates/base.html.j2 new file mode 100644 index 0000000..a2d2ebb --- /dev/null +++ b/templates/base.html.j2 @@ -0,0 +1,76 @@ + + + + + {% block title %}neopig{% endblock %} + + {% block extra_head %}{% endblock %} + + + + + {% block content %}{% endblock %} + {% block scripts %}{% endblock %} + + diff --git a/templates/crawl.html.j2 b/templates/crawl.html.j2 new file mode 100644 index 0000000..129d94f --- /dev/null +++ b/templates/crawl.html.j2 @@ -0,0 +1,429 @@ +{% extends "base.html.j2" %} + +{% block title %}{{ t.crawl }} - neopig{% endblock %} + +{% block extra_css %} +h1 { color: #ff6b6b; margin-bottom: 5px; } +.subtitle { color: #666; margin-bottom: 20px; } +.form-group { margin-bottom: 15px; } +label { display: block; margin-bottom: 5px; color: #aaa; font-size: 14px; } +input[type="text"], input[type="number"], select, textarea { + width: 100%; padding: 12px 16px; font-size: 16px; + border: 2px solid #333; border-radius: 8px; background: #1a1a1a; color: #fff; +} +input:focus, select:focus, textarea:focus { outline: none; border-color: #ff6b6b; } +.row { display: flex; gap: 15px; } +.row > div { flex: 1; } +button { padding: 14px 28px; font-size: 16px; background: #ff6b6b; color: #fff; border: none; border-radius: 8px; cursor: pointer; margin-top: 10px; } +button:hover { background: #ff5252; } +button:disabled { background: #444; cursor: not-allowed; } +button.secondary { background: #333; } +button.secondary:hover { background: #444; } +.checkbox-group { display: flex; align-items: center; gap: 8px; } +.checkbox-group input { width: auto; } +.jobs-section { margin-top: 30px; padding-top: 20px; border-top: 1px solid #333; } +h2 { color: #ff6b6b; font-size: 18px; margin-bottom: 15px; } +.job { background: #1a1a1a; border-radius: 8px; padding: 15px; margin-bottom: 10px; } +.job-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +.job-id { font-family: monospace; color: #666; } +.job-status { padding: 4px 10px; border-radius: 4px; font-size: 12px; font-weight: bold; } +.job-status.running { background: #2d5a27; color: #7bed72; } +.job-status.completed { background: #1a3a4a; color: #6bc5e8; } +.job-status.failed { background: #5a2727; color: #ed7272; } +.job-target { font-size: 14px; word-break: break-all; margin-bottom: 5px; } +.job-meta { font-size: 12px; color: #666; } +.job-stats { display: flex; gap: 15px; margin-top: 10px; font-size: 13px; flex-wrap: wrap; } +.job-stats span { background: #252525; padding: 4px 10px; border-radius: 4px; } +.progress-bar { height: 4px; background: #333; border-radius: 2px; margin-top: 10px; overflow: hidden; } +.progress-bar-fill { height: 100%; background: #ff6b6b; transition: width 0.3s; } +.progress-bar-fill.running { + width: 100%; + background: linear-gradient(90deg, #ff6b6b 0%, #4ecdc4 50%, #ff6b6b 100%); + background-size: 200% 100%; + animation: progress-wave 1.5s ease-in-out infinite; +} +.job-actions { margin-top: 8px; display: flex; gap: 8px; flex-wrap: wrap; } +.job-actions button { padding: 4px 12px; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 12px; margin-top: 0; } +.pause-btn { background: #e67e22; } +.pause-btn:hover { background: #f39c12; } +.start-btn { background: #27ae60; } +.start-btn:hover { background: #2ecc71; } +.recrawl-btn { background: #3498db; } +.recrawl-btn:hover { background: #2980b9; } +.delete-btn { background: #7f8c8d; } +.delete-btn:hover { background: #c0392b; } +.job-links { margin-left: 10px; font-size: 12px; } +.job-links a { color: #4ecdc4; margin-right: 8px; } +.job-links a:hover { color: #7bed72; } +@keyframes progress-wave { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} +.job.running { border-color: #4ecdc4; } +.job-stats.live { color: #4ecdc4; } +.no-jobs { color: #666; text-align: center; padding: 30px; } +.console-btn { background: #2c3e50; } +.console-btn:hover { background: #34495e; } +.console-modal { + display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.8); z-index: 1000; padding: 20px; +} +.console-modal.active { display: flex; flex-direction: column; } +.console-header { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 15px; background: #1a1a1a; border-radius: 8px 8px 0 0; +} +.console-header h3 { margin: 0; color: #ff6b6b; } +.console-close { background: #c0392b; border: none; color: white; padding: 5px 15px; border-radius: 4px; cursor: pointer; } +.console-body { + flex: 1; background: #0d0d0d; padding: 15px; overflow: auto; + font-family: monospace; font-size: 12px; white-space: pre-wrap; + word-break: break-all; color: #4ade80; border-radius: 0 0 8px 8px; +} +.console-body .log-line { margin: 0; line-height: 1.4; } +.console-body .log-INFO { color: #4ade80; } +.console-body .log-WARNING { color: #f39c12; } +.console-body .log-ERROR { color: #e74c3c; } +{% endblock %} + +{% block content %} +
+ {% include 'partials/search_box.html.j2' %} + +

{{ t.crawl }}

+

{{ t.hydrate_subtitle }}

+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+

{{ t.recent_jobs }}

+
{{ t.loading }}
+
+ +
+
+

{{ t.console }}

+ +
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/import.html.j2 b/templates/import.html.j2 new file mode 100644 index 0000000..6968303 --- /dev/null +++ b/templates/import.html.j2 @@ -0,0 +1,218 @@ +{% extends "base.html.j2" %} + +{% block title %}Import - neopig{% endblock %} + +{% block extra_css %} +.container { max-width: 800px; margin: 0 auto; } +h1 { color: #ff6b6b; margin-bottom: 5px; } +.subtitle { color: #666; margin-bottom: 30px; } + +.upload-zone { + border: 3px dashed #333; + border-radius: 12px; + padding: 60px 40px; + text-align: center; + background: #111; + cursor: pointer; + transition: all 0.2s; +} +.upload-zone:hover, .upload-zone.drag-over { + border-color: #ff6b6b; + background: #1a1a1a; +} +.upload-zone input[type="file"] { display: none; } +.upload-icon { font-size: 48px; margin-bottom: 15px; } +.upload-text { color: #888; font-size: 16px; } +.upload-hint { color: #555; font-size: 13px; margin-top: 10px; } + +.status { + margin-top: 30px; + padding: 20px; + background: #1a1a1a; + border-radius: 8px; + border: 1px solid #333; +} +.status-row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #222; } +.status-row:last-child { border-bottom: none; } +.status-label { color: #888; } +.status-value { color: #e0e0e0; font-family: monospace; } + +.progress { display: none; margin-top: 20px; } +.progress-bar { + height: 8px; background: #333; border-radius: 4px; overflow: hidden; +} +.progress-fill { + height: 100%; background: #ff6b6b; width: 0%; transition: width 0.3s; +} +.progress-text { text-align: center; margin-top: 10px; color: #888; font-size: 14px; } + +.success { color: #6f6; } +.error { color: #f66; } +{% endblock %} + +{% block content %} +
+

Import Mode

+

Upload a neopig archive (.tar.gz or .run) to explore it

+ +
+
📦
+
Drop archive here or click to browse
+
Supports .tar.gz, .tgz, and .run files
+ +
+ +
+
+
Uploading...
+
+ +
+

Current Archive

+
Loading...
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/live.html.j2 b/templates/live.html.j2 new file mode 100644 index 0000000..a69cd46 --- /dev/null +++ b/templates/live.html.j2 @@ -0,0 +1,229 @@ +{% extends "base.html.j2" %} + +{% block title %}{{ t.live }} - neopig{% endblock %} + +{% block extra_css %} +h1 { color: #ff6b6b; margin-bottom: 5px; } +.subtitle { color: #666; margin-bottom: 10px; } +.stats { background: #1a1a1a; padding: 15px; border-radius: 8px; margin-bottom: 20px; display: flex; gap: 30px; flex-wrap: wrap; } +.stat { display: flex; flex-direction: column; } +.stat-value { font-size: 24px; font-weight: bold; color: #ff6b6b; } +.stat-label { font-size: 12px; color: #888; } +.controls { margin-bottom: 20px; display: flex; gap: 10px; align-items: center; } +.controls button { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-size: 14px; } +.controls button.primary { background: #ff6b6b; color: white; } +.controls button.secondary { background: #333; color: #ddd; } +.controls button:hover { opacity: 0.8; } +.status { color: #4ade80; font-size: 14px; } +.status.paused { color: #fbbf24; } +.live-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 15px; } +.live-card { background: #1a1a1a; border-radius: 8px; overflow: hidden; animation: fadeIn 0.5s ease-out; } +@keyframes fadeIn { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} +.live-card.new { box-shadow: 0 0 20px rgba(255, 107, 107, 0.5); } +.live-card img, .live-card video { width: 100%; height: 180px; object-fit: contain; background: #222; } +.live-card-info { padding: 10px; } +.live-card-title { font-size: 12px; color: #888; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.live-card-source { font-size: 10px; color: #555; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.score-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 9px; font-weight: bold; text-transform: uppercase; margin-left: 6px; vertical-align: middle; } +.score-screenshot { background: #666; color: #ccc; } +.score-og { background: #8b5cf6; color: white; } +.score-thumb { background: #3b82f6; color: white; } +.score-hd { background: #10b981; color: white; } +.live-card.upgraded { box-shadow: 0 0 20px rgba(16, 185, 129, 0.7); animation: upgradeGlow 1s ease-out; } +@keyframes upgradeGlow { + 0% { box-shadow: 0 0 30px rgba(16, 185, 129, 1); } + 100% { box-shadow: 0 0 20px rgba(16, 185, 129, 0.5); } +} +@media (max-width: 768px) { .live-grid { grid-template-columns: repeat(2, 1fr); } } +{% endblock %} + +{% block content %} +
+ {% include 'partials/search_box.html.j2' %} + +

{{ t.live }}{% if domain %}: {{ domain }}{% endif %}

+

{{ t.live_subtitle }}

+ +
+
+ 0 + {{ t.total_images }} +
+
+ 0 + {{ t.new_session }} +
+
+ 0 + {{ t.per_minute }} +
+
+ +
+ + + {{ t.watching }} +
+ +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/partials/search_box.html.j2 b/templates/partials/search_box.html.j2 new file mode 100644 index 0000000..d7dfa06 --- /dev/null +++ b/templates/partials/search_box.html.j2 @@ -0,0 +1,10 @@ + diff --git a/templates/phantom.html.j2 b/templates/phantom.html.j2 new file mode 100644 index 0000000..0eccabb --- /dev/null +++ b/templates/phantom.html.j2 @@ -0,0 +1,61 @@ +{% extends "base.html.j2" %} + +{% block title %}Phantom Site Export - neopig{% endblock %} + +{% block extra_css %} +h1 { color: #ff6b6b; margin-bottom: 10px; } +.subtitle { color: #888; margin-bottom: 30px; } +.form-group { margin-bottom: 20px; } +label { display: block; margin-bottom: 8px; color: #aaa; } +select { + width: 100%; + padding: 12px 16px; + font-size: 16px; + border: 2px solid #333; + border-radius: 8px; + background: #1a1a1a; + color: #fff; +} +.info { + background: #1a1a1a; + padding: 20px; + border-radius: 8px; + margin-top: 30px; +} +.info h3 { color: #ff6b6b; margin-top: 0; } +.info ul { color: #aaa; line-height: 1.8; } +{% endblock %} + +{% block content %} +
+ {% include 'partials/search_box.html.j2' %} +

Phantom Site Export

+

{{ t.phantom_subtitle }}

+ +
+
+ + +
+ +
+ +
+

What is a Phantom Site?

+
    +
  • Original HTML preserved exactly as crawled
  • +
  • All media URLs rewritten to local paths
  • +
  • Ready to host statically (nginx, Caddy, S3, etc.)
  • +
  • Works offline - all assets included
  • +
  • Perfect for archival & preservation
  • +
+
+
+{% endblock %} diff --git a/templates/search.html.j2 b/templates/search.html.j2 new file mode 100644 index 0000000..d2adbf4 --- /dev/null +++ b/templates/search.html.j2 @@ -0,0 +1,228 @@ +{% extends "base.html.j2" %} + +{% block title %}neopig SERP{% endblock %} + +{% block extra_css %} +h1 { color: #ff6b6b; margin-bottom: 5px; } +.subtitle { color: #666; margin-bottom: 20px; } +.stats { padding: 10px 15px; background: #1a1a1a; border-radius: 8px; margin-bottom: 20px; font-size: 14px; color: #888; } +.section-title { color: #ff6b6b; font-size: 18px; margin: 25px 0 15px 0; border-bottom: 1px solid #333; padding-bottom: 8px; } +.results { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 15px; } +.result { background: #1a1a1a; border-radius: 8px; overflow: hidden; transition: transform 0.2s; } +.result:hover { transform: scale(1.02); } +.result img, .result video { width: 100%; height: 200px; object-fit: contain; background: #1a1a1a; } +.result-info { padding: 10px; } +.result-filename { font-size: 12px; color: #aaa; margin-bottom: 4px; word-break: break-all; } +.result-hash { font-family: monospace; font-size: 11px; color: #ff6b6b; text-decoration: none; word-break: break-all; } +.result-hash:hover { text-decoration: underline; } +.result-meta { font-size: 12px; color: #888; margin-top: 5px; } +.result-keywords { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; } +.tag { background: #333; padding: 2px 8px; border-radius: 4px; font-size: 11px; color: #aaa; } +.no-results { text-align: center; padding: 60px; color: #666; } +.result-count { padding: 10px 15px; color: #888; font-size: 14px; } +.result-count.over-9000 { color: #ff6b6b; font-weight: bold; font-size: 18px; text-shadow: 0 0 10px rgba(255,107,107,0.5); animation: powerUp 0.5s ease-out; } +@keyframes powerUp { 0% { transform: scale(1); } 50% { transform: scale(1.2); } 100% { transform: scale(1); } } +.pagination { display: flex; gap: 15px; justify-content: center; padding: 20px; margin-top: 20px; border-top: 1px solid #333; } +.pagination button { padding: 10px 20px; background: #4ecdc4; color: #1a1a2e; border: none; border-radius: 5px; cursor: pointer; font-weight: bold; } +.pagination button:hover { background: #7bed72; } +.media-link { display: block; cursor: pointer; } +.media-link:hover img, .media-link:hover video { opacity: 0.8; } +.page-results { display: flex; flex-direction: column; gap: 10px; } +.page-result { background: #1a1a1a; border-radius: 8px; padding: 15px; transition: background 0.2s; } +.page-result:hover { background: #252525; } +.page-result a { color: #ff6b6b; text-decoration: none; font-size: 16px; font-weight: 500; } +.page-result a:hover { text-decoration: underline; } +.page-path { font-size: 12px; color: #4ade80; margin-top: 4px; font-family: monospace; } +.page-snippet { font-size: 13px; color: #999; margin-top: 8px; line-height: 1.5; } +.page-snippet mark { background: #ff6b6b33; color: #ff9999; padding: 1px 3px; border-radius: 2px; } +.search-columns { display: grid; grid-template-columns: 1fr; gap: 30px; align-items: start; } +.search-columns.has-pages { grid-template-columns: 1fr 2fr; } +.search-columns .page-column { display: none; } +.search-columns.has-pages .page-column { display: block; } +@media (max-width: 1000px) { .search-columns.has-pages { grid-template-columns: 1fr; } } +@media (max-width: 768px) { .results { grid-template-columns: repeat(2, 1fr); } } +{% endblock %} + +{% block content %} +
+ + +
{{ t.loading_stats }}
+ +
+
+

{{ t.pages_label }}

+
+
+ +
+

{{ t.media }}

+
+
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/view.html.j2 b/templates/view.html.j2 new file mode 100644 index 0000000..8ffc41f --- /dev/null +++ b/templates/view.html.j2 @@ -0,0 +1,269 @@ +{% extends "base.html.j2" %} + +{% block title %}{{ title }} - neopig{% endblock %} + +{% block extra_head %} + + +{% endblock %} + +{% block extra_css %} +h1 { color: #ff6b6b; font-size: 20px; margin: 0 0 10px 0; } +h3 { color: #ff6b6b; font-size: 16px; margin: 30px 0 15px 0; border-bottom: 1px solid #333; padding-bottom: 8px; } +.meta { background: #1a1a1a; padding: 15px; border-radius: 8px; margin: 15px 0; } +.meta-row { display: flex; margin: 8px 0; } +.meta-label { width: 100px; color: #888; font-size: 13px; } +.meta-value { flex: 1; word-break: break-all; font-size: 13px; } +.meta-value a { color: #4ade80; } +.hero { text-align: center; margin-bottom: 20px; } +.hero img, .hero video { max-width: 100%; max-height: 60vh; border-radius: 8px; } +.content-rendered { + background: #1a1a1a; padding: 20px; border-radius: 8px; + line-height: 1.7; font-size: 14px; color: #ccc; text-align: left; +} +.content-rendered img { max-width: 100%; height: auto; margin: 10px 0; } +.content-rendered img.avatar, +.content-rendered img[alt="avatar"] { + display: inline-block; vertical-align: middle; + width: 40px; height: 40px; border-radius: 50%; + margin: 0 10px 0 0; object-fit: cover; +} +.content-rendered img.emoji, +.content-rendered img[alt^=":"][alt$=":"], +.content-rendered img[src*="emoji"] { + display: inline; width: 20px; height: 20px; + max-width: 20px; margin: 0 2px; vertical-align: text-bottom; +} +.content-rendered a { color: #ff6b6b; } +.content-rendered pre, .content-rendered code { + background: #252525; padding: 2px 6px; + border-radius: 4px; font-family: monospace; font-size: 13px; +} +.content-rendered pre { + padding: 15px; display: block; + white-space: pre-wrap; overflow-x: auto; +} +.content-rendered blockquote { + background: #151520; border-left: 3px solid #4a9eff; + padding: 12px 16px; margin: 16px 0; + border-radius: 0 6px 6px 0; color: #aaa; font-style: italic; +} +.content-rendered hr { border: none; border-top: 1px solid #333; margin: 24px 0; } +.content-rendered p { margin: 0 0 16px 0; line-height: 1.7; } +.content-rendered h1, .content-rendered h2, .content-rendered h3, .content-rendered h4, .content-rendered h5, .content-rendered h6 { + display: block; color: #ff6b6b; margin-top: 28px; margin-bottom: 12px; +} +.content-rendered h1 { font-size: 1.8em; } +.content-rendered h2 { font-size: 1.5em; } +.content-rendered h3 { font-size: 1.25em; } +.media-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); + gap: 10px; margin-top: 15px; +} +.media-card { + background: #1a1a1a; border-radius: 8px; + overflow: hidden; display: block; transition: transform 0.2s; + text-decoration: none; +} +.media-card:hover { transform: scale(1.02); } +.media-card img, .media-card video, .media-card svg { + width: 100%; height: 120px; + object-fit: contain; background: #0a0a0a; +} +.media-card-name { + padding: 4px 8px; font-size: 11px; color: #888; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + background: #111; text-align: left; text-decoration: none; +} +.tag { background: #333; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-right: 5px; } +.screenshot-stack { border-radius: 8px; } +.screenshot-stack img { border-radius: 0; } +.screenshot-stack img:first-child { border-radius: 8px 8px 0 0; } +.screenshot-stack img:last-child { border-radius: 0 0 8px 8px; } +.screenshot-stack img:only-child { border-radius: 8px; } +#screenshot-container.collapsed { display: none; } +.hero-grid { display: grid; grid-template-columns: 1fr; gap: 20px; margin-bottom: 20px; } +.hero-grid.has-hero { grid-template-columns: 1fr 1fr; } +.content-grid { display: grid; grid-template-columns: 1fr; gap: 20px; margin-top: 20px; } +.content-grid.has-screenshots { grid-template-columns: 1fr 1fr; } +.content-col, .screenshot-col { min-width: 0; overflow: hidden; } +.content-grid.ss-collapsed { grid-template-columns: 1fr 20px; } +.content-grid.ss-collapsed .screenshot-col h3 { writing-mode: vertical-rl; text-orientation: mixed; margin: 0; font-size: 12px; } +.sources-section { margin-top: 40px; padding-top: 20px; border-top: 1px solid #333; } +.sources-section h3 { cursor: pointer; user-select: none; } +.sources-section h3:hover { color: #ff6b6b; } +.sources-list { display: none; margin-top: 15px; } +.sources-list.expanded { display: block; } +.sources-list table { width: 100%; border-collapse: collapse; font-size: 13px; } +.sources-list th, .sources-list td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #333; } +.sources-list th { background: #1a1a1a; color: #888; font-weight: normal; } +.sources-list td a { color: #6ea8fe; } +.sources-list td a:hover { color: #ff6b6b; } +/* Forum post styling */ +.post-block { display: flex; gap: 15px; margin: 20px 0; padding: 15px; background: #151515; border-radius: 8px; border-left: 3px solid #ff6b6b; } +.post-avatar-col { flex-shrink: 0; } +.post-avatar { width: 48px; height: 48px; border-radius: 50%; object-fit: cover; } +.post-avatar-placeholder { width: 48px; height: 48px; border-radius: 50%; background: #333; display: flex; align-items: center; justify-content: center; font-weight: bold; color: #888; font-size: 16px; } +.post-content { flex: 1; min-width: 0; } +.post-header { margin-bottom: 10px; } +.post-header strong { color: #ff6b6b; font-size: 14px; } +.post-content p { margin: 8px 0; } +.post-content img:not(.post-avatar):not(.emoji) { max-width: 100%; border-radius: 4px; margin: 10px 0; } +@media (max-width: 768px) { .hero-grid, .hero-grid.has-hero, .content-grid, .content-grid.has-screenshots { grid-template-columns: 1fr; } } +{% endblock %} + +{% block content %} +
+ {% include 'partials/search_box.html.j2' %} + +
+ {% if hero_html %} +
+
+ {{ hero_html|safe }} +
+
+ {% endif %} +
+

{{ title }}

+
+ {% for key, value in meta_rows %} +
+ {{ t.get(key, key) }}: + {{ value|safe }} +
+ {% endfor %} +
+ {% if download_btn_html %} + {{ download_btn_html|safe }} + {% endif %} +
+
+ + {% if media_items %} + + {% endif %} + +
+
+ {% if content_html %} +

{{ t.content or 'Content' }}

+
{{ content_html|safe }}
+ {% endif %} +
+ {% if screenshot_hashes %} +
+

{{ t.screenshots or 'Screenshots' }} ({{ screenshot_hashes|length }}) [hide]

+
+ {% for hash in screenshot_hashes %} + + {% endfor %} +
+
+ {% endif %} +
+ + {{ sources_html|safe }} +
+{% endblock %} + +{% block scripts %} + +{% if not noai %} + + +{% endif %} +{% endblock %}