modified: requirements.txt

modified:   serp.py
	new file:   templates/about.html.j2
	new file:   templates/base.html.j2
	new file:   templates/crawl.html.j2
	new file:   templates/import.html.j2
	new file:   templates/live.html.j2
	new file:   templates/partials/search_box.html.j2
	new file:   templates/phantom.html.j2
	new file:   templates/search.html.j2
	new file:   templates/view.html.j2
This commit is contained in:
Russell Ballestrini 2026-01-03 13:48:47 -05:00
parent d98236c0c0
commit 11f8fc59ae
11 changed files with 2762 additions and 3099 deletions

View file

@ -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

3451
serp.py

File diff suppressed because it is too large Load diff

889
templates/about.html.j2 Normal file
View file

@ -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 %}
<div class="about-page">
<!-- HERO SECTION -->
<section class="hero-section">
<img src="/static/images/pig1-color1.png" alt="pig.py" class="hero-pig">
<h1 class="hero-title">neopig</h1>
<p class="hero-subtitle">Neo Python Image Grabber</p>
<p class="hero-tagline">
A phoenix-like chimera grown from the bones of dead repositories &amp; forums. A pig that devours web domains whole. Where others archive pages, neopig <em>consumes all digital files</em>.<br><br>Welcome to An Age of Aquarius, no deceitful secrets.
</p>
</section>
<!-- CHAPTER I: GENESIS -->
<section class="chapter">
<div class="chapter-number">I</div>
<div class="chapter-content">
<h2 class="chapter-title">Genesis</h2>
<div class="dramatic-quote">
<p>pig.py is a <em>very</em> simple python command line tool to download all the images from a given uri.</p>
<cite>&mdash; Russell Ballestrini, August 22, 2011</cite>
</div>
<p class="narrative">
In the beginning, there was <a href="https://russell.ballestrini.net/python-image-grabber-pig-py/" target="_blank">pig.py</a>. A simple creature. Innocent. <em>Hungry.</em>
</p>
<p class="narrative">
Russell Ballestrini crafted it in the summer of 2011 &mdash; a humble Python script with a singular appetite: <strong>images</strong>. 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.
</p>
<div class="code-section">
<pre><code>python pig.py https://www.foxhop.net</code></pre>
</div>
<p class="narrative">
That was it. Entire interface. Pig asked only for a target, and it <em>fed</em>.
</p>
<p class="narrative">
Russell released it into the public domain &mdash; a gift to anyone who needed to harvest images from the wild web. Source lived at <code>bitbucket.org/russellballestrini/pig</code>, nestled safely in a Mercurial repository. Pig slept soundly in its pen, unaware of the extinction event approaching.
</p>
</div>
</section>
<!-- CHAPTER II: THE EXTINCTION -->
<section class="chapter chapter-dark">
<div class="chapter-number">II</div>
<div class="chapter-content">
<h2 class="chapter-title">A Great Bitbucket Extinction</h2>
<p class="narrative">
<strong>Then came the dark times.</strong>
</p>
<p class="narrative">
In 2020, Atlassian &mdash; the corporate leviathan that had swallowed Bitbucket whole &mdash; decreed that Mercurial repositories would be <em>purged</em>. All of them. Every hg repo on Bitbucket Cloud, atomized. Scattered like digital ash across the void. Atlassian never disclosed how many.
</p>
<a href="https://media.unturf.com/c/b86529cc-e673-11ef-b77a-115dc81aa9c4/langolier-time-voidlings-of-null-void" target="_blank"><img src="/static/images/bitbucket.jpg" alt="Bitbucket extinction" style="width:42%"></a>
<p class="narrative">
Original pig.py, that innocent image-gobbling creature, was among the casualties. Its home at <a href="https://bitbucket.org/russellballestrini/pig" class="dead-link">bitbucket.org/russellballestrini/pig</a> became a tombstone. Click it. We dare you. There's nothing there but the echo of corporate indifference.
</p>
<div class="dramatic-quote">
<p>They literally killed pig.py & sent him to bitbucket.</p>
<cite>&mdash; Cruel irony of naming your graveyard after a slang term for death</cite>
</div>
<p class="narrative">
<em>"Sent to the bitbucket"</em> &mdash; 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.
</p>
<p class="narrative">
Pig was dead. Its bones scattered across cached search results & archived blog posts. A ghost in the machine, referenced but unreachable.
</p>
<p class="narrative">
But here's where it gets <em>weird</em>.
</p>
</div>
</section>
<!-- CHAPTER III: ALCHEMY -->
<section class="chapter chapter-rebirth">
<div class="chapter-number">III</div>
<div class="chapter-content">
<h2 class="chapter-title">An Alchemy</h2>
<p class="narrative">
Russell once wrote that <a href="https://russell.ballestrini.net/programming-is-like-alchemy/" target="_blank">"programming is like alchemy &mdash; instead of exchanging matter, we programmers exchange time."</a>
</p>
<img src="/static/images/alchemy.jpg" alt="Alchemy" style="width:42%">
<p class="narrative">
Programs are <strong>golems</strong>. Familiar spirits. Magical servants performing repetitive tasks so we don't have to. <em>"It is more accurate to group programs with technology than magic, but less fun."</em>
</p>
<p class="narrative">
Years passed. Web continued its relentless churn &mdash; 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.
</p>
<div class="dramatic-quote">
<p>What if we could transmute that dead code into gold?</p>
<cite>&mdash; Thought that started everything</cite>
</div>
<p class="narrative">
And speaking of transmutation: <a href="https://phys.org/news/2025-07-marathon-fusion-mercury-gold-energy.html.j2" target="_blank">Marathon Fusion</a> discovered that tokamak breeding blankets &mdash; wrapped in Mercury-Lithium alloy, like <em>pigs in a blanket</em> &mdash; can transmute Mercury-198 into Gold-197 through chrysopoeia. Fast neutrons trigger (n, 2n) reactions; unstable mercury decays into stable gold within 64 hours.
</p>
<p class="narrative">
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).
</p>
<p class="narrative">
A golden goose born from the ashes of deprecated version control. Old pig was slaughtered, while <strong>a new creature stirred in the digital depths...</strong>
</p>
<p class="narrative">
Machine learning uses an equivalent amount of <a href="https://en.wikipedia.org/wiki/Wu_wei" target="_blank">wu wei</a> energy that elites used to kill pig.py to unfold neopig.py.
</p>
</div>
</section>
<!-- CHAPTER IV: THE PIG AWAKENS -->
<section class="chapter chapter-beast">
<div class="chapter-number">IV</div>
<div class="chapter-content">
<h2 class="chapter-title">A Pig Awakens</h2>
<p class="narrative">
Like a phoenix rising from dead Bitbucket repos, <strong>neopig</strong> emerged.
</p>
<a href="https://media.unturf.com/c/fbc48bff-e895-11f0-8d29-02dfe05770ee/gluttony" target="_blank"><img src="/static/images/hungry-pig.jpg" alt="Neopig grows hungrier" style="width:42%"></a>
<p class="narrative">
Not a resurrection. Not a mere fork. Something <em>else</em>. 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.
</p>
<div class="dramatic-quote">
<p>A pig with an appetite like Gluttony from Fullmetal Alchemist &mdash; a homunculus that devours everything in its path, absorbing knowledge, power & form.</p>
</div>
<p class="narrative">
Where pig.py sipped politely from single pages, neopig <strong>devours entire domains</strong>. It doesn't just grab images &mdash; 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.
</p>
<p class="narrative">
Code lives now at <a href="https://git.unturf.com/engineering/unturf/pig.py" target="_blank">git.unturf.com/engineering/unturf/pig.py</a>. And it keeps evolving. Every crawl adds new capabilities. Every archived site teaches it new tricks.
</p>
<p class="narrative">
<em>We are adding the ability to backup entire codebases.</em> Neopig grows hungrier.
</p>
</div>
</section>
<!-- CHAPTER V: THE MIRROR DIMENSION -->
<section class="chapter chapter-mirror">
<div class="chapter-number">V</div>
<div class="chapter-content">
<h2 class="chapter-title">A Mirror Dimension</h2>
<p class="narrative">
We are about to <strong>invert the git tree into itself</strong>.
</p>
<a href="https://media.unturf.com/c/fcddf99d-a2da-11f0-af1d-02dfe05770ee/the-tempest-overtakes" target="_blank"><img src="/static/images/sleeping-pig.gif" alt="A sleeping pig" style="width:42%"></a>
<p class="narrative">
Like Dr. Strange versus Spider-Man in the mirror dimension &mdash; that impossible space where geometry folds back on itself, where buildings become M.C. Escher nightmares & reality reflects endlessly into its own depths.
</p>
<p class="narrative">
neopig doesn't just archive websites. It can archive <em>itself</em>. 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.
</p>
<div class="dramatic-quote">
<p>Our spider-pig gets out of the danger of the mirror void &mdash; where days are years &mdash; and exits absorbing everything like a griffin, like a chimera, like a pig that feeds on bones of dead platforms.</p>
</div>
<p class="narrative">
When a code hosting platform dies (and they all die eventually &mdash; 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.
</p>
<p class="narrative">
Archives become self-extracting executables. <code>.run</code> files that contain their own server, their own search engine, their own reality. Drop one on a machine with Python, and it <em>wakes up</em>. A sleeping pig, carrying an entire website in its belly, ready to serve it to anyone who asks.
</p>
</div>
</section>
<!-- FEATURES SECTION -->
<section class="feature-section">
<div class="chapter-number">VI</div>
<div class="chapter-content" style="max-width: 100%;">
<h2 class="chapter-title" style="text-align: center; margin-bottom: 60px;">The Arsenal</h2>
<div class="feature-grid">
<div class="feature-card">
<h3>Async Domain Crawling</h3>
<p>Full-domain recursive crawling with configurable depth. Respects robots.txt & crawl delays. Polite but <em>relentless</em>.</p>
</div>
<div class="feature-card">
<h3>Content-Addressed Vault</h3>
<p>MD5-based deduplication in a 9-deep hex directory structure. Same image from 100 pages? Stored once. Space is memory.</p>
</div>
<div class="feature-card">
<h3>Full-Text Search</h3>
<p>Every image indexed with its surrounding context &mdash; page title, alt text, captions, nearby headings. Find images by <em>meaning</em>, not filename.</p>
</div>
<div class="feature-card">
<h3>Page Screenshots</h3>
<p><a href="https://linkpeek.com">LinkPeek</a> reborn! Full-page captures via <a href="https://github.com/russellballestrini/uri2png">uri2png</a>. wkhtmltoimage, cutycapt, or Playwright backends.</p>
</div>
<div class="feature-card">
<h3>Hydra Mode</h3>
<p>Auto-discover RSS/Atom/Sitemap feeds. Persists feed URLs, checks for new content on every crawl. Self-healing archive maintenance.</p>
</div>
<div class="feature-card">
<h3>Media Provenance</h3>
<p>Track first_seen, last_seen, and when each page first linked to media. Watch content spread across the web over time.</p>
</div>
<div class="feature-card">
<h3>Markdown Conversion</h3>
<p>Intelligent HTML-to-Markdown with forum post detection, noise removal & content extraction. Human-readable archives.</p>
</div>
<div class="feature-card">
<h3>Self-Extracting Archives</h3>
<p>Create <code>.run</code> executables that contain entire websites. Drop on any machine, execute, browse. Pig travels light.</p>
</div>
<div class="feature-card">
<h3>Code File Support</h3>
<p>Archive source code, stylesheets, fonts, and scripts alongside media. Complete preservation for developer communities.</p>
</div>
<div class="feature-card">
<h3>VCS Repository Cloning</h3>
<p>Auto-detect GitHub, GitLab, Bitbucket & more. Clone repos, symlink files to vault. Deduplicated code across forks.</p>
</div>
<div class="feature-card">
<h3>Live Feed</h3>
<p>Watch media appear in real-time as crawls progress. SSE-powered live updates. Pig hunts while you watch.</p>
</div>
<div class="feature-card">
<h3>SERP Web Interface</h3>
<p>FastAPI-powered search engine. Browse by type, search by context, view media details. Your personal media search engine.</p>
</div>
</div>
</div>
</section>
<!-- CHAPTER VI: THE SKELETON KEY -->
<section class="chapter">
<div class="chapter-number">VII</div>
<div class="chapter-content">
<h2 class="chapter-title">A Skeleton Key 🗝️🔑</h2>
<p class="narrative">
neopig uses what we call the <strong>"skeleton key"</strong> approach to media indexing. For every image, we capture <em>all</em> the text that might help you find it later:
</p>
<a href="https://media.unturf.com/c/b45632be-9120-11ec-9431-eb3419618d4a/grow-food-not-lawn" target="_blank"><img src="/static/images/grow-food-not-lawn.jpg" alt="Grow food not lawn" style="width:42%"></a>
<p class="narrative">
<strong>Page context:</strong> Title, description, headings near the image.<br>
<strong>Image attributes:</strong> Alt text, title, surrounding captions.<br>
<strong>Link context:</strong> Text of links pointing to the image.<br>
<strong>Detail pages:</strong> For gallery sites, we follow through to detail pages & harvest their metadata too.
</p>
<p class="narrative">
Result? You can search for <em>"sunset over mountains"</em> and find that image even if it was named <code>IMG_4372.jpg</code> with no alt text &mdash; because the page title mentioned it, or someone linked to it with descriptive text, or a caption three divs away contained the words.
</p>
<p class="narrative">
Every door opens. Every search succeeds. Skeleton key fits every lock because it carries <em>every possible key</em> inside it.
</p>
</div>
</section>
<!-- CHAPTER VIII: PRESERVATION -->
<section class="chapter chapter-to-cyan">
<div class="chapter-number">VIII</div>
<div class="chapter-content">
<h2 class="chapter-title">Why We Archive</h2>
<p class="narrative">
<strong>Web is ephemeral.</strong> Sites go dark. Forums shut down. Communities scatter. Platforms get acquired &amp; gutted. Executives decide that Mercurial isn't profitable &amp; delete every repository with a single corporate memo.
</p>
<p class="narrative">
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 <em>gone</em>.
</p>
<p class="narrative">
neopig is built for <strong>preservation</strong> &mdash; capturing not just the media, but the <em>context</em> that gives it meaning. When you archive a site with neopig, you get:
</p>
<p class="narrative">
&bull; Original HTML with rewritten media links pointing to your local vault<br>
&bull; Full-text searchable markdown versions of every page<br>
&bull; Screenshots showing exactly how pages looked<br>
&bull; A SQLite database you can query, backup, and migrate forever<br>
&bull; Self-extracting archives that work offline forever
<br><br><br>
</p>
</div>
</section>
<!-- CHAPTER IX: THE FORGE -->
<section class="chapter chapter-forge">
<div class="chapter-number">IX</div>
<div class="chapter-content">
<h2 class="chapter-title">A Forge</h2>
<p class="narrative">
This tool was not built for profit. It was <strong>forged</strong>.
</p>
<p class="narrative">
Forged in the fires of <a href="https://permacomputer.com" target="_blank">permacomputer.com</a> &mdash; the belief that computing should be <em>permanent</em>. 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 <span style="color: #8B00FF;">terminated</span> 🟣. A digital commons that belongs to <em>everyone</em>, forever.
</p>
<a href="https://templeos.org" target="_blank"><img src="/static/images/templeos.png" alt="TempleOS" class="forge-logo"></a>
<div class="dramatic-quote">
<p>Truth. Harmony. Freedom. Love.</p>
<cite>&mdash; The quadrivium of a permacomputer</cite>
</div>
<p class="narrative">
<strong>Truth</strong> &mdash; We preserve what actually existed, not sanitized versions. Original HTML. Exact timestamps. Unedited conversations. History as it happened, not as someone wishes it had.
</p>
<p class="narrative">
<strong>Harmony</strong> &mdash; 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, <em>whole</em>.
</p>
<p class="narrative">
<strong>Freedom</strong> &mdash; 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.
</p>
<p class="narrative">
<strong>Love</strong> &mdash; We archive because we <em>care</em>. 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.
</p>
<p class="narrative">
Pig is imbued with <em>righteous fire</em>. It does not crawl politely because it fears the powerful &mdash; it respects robots.txt because it honors the wishes of creators. It does not hoard in darkness &mdash; it preserves in light, making archives searchable, shareable, <em>alive</em>. A pig imbued with <em>righteous fire</em>.
</p>
<p class="narrative">
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) &mdash; a permacomputer endures. Archives persist. Knowledge survives.
</p>
<p class="narrative">
<strong>This is what we were forged to do.</strong>
</p>
</div>
</section>
<!-- CHAPTER X: RULES? -->
<section class="chapter chapter-from-cyan">
<div class="chapter-number">X</div>
<div class="chapter-content">
<h2 class="chapter-title">Rules?</h2>
<p class="narrative">
neopig is built on <a href="https://sqlite.org" target="_blank">SQLite</a> &mdash; the most deployed database in existence. SQLite adopted the <a href="https://sqlite.org/codeofethics.html.j2" target="_blank">Rule of St. Benedict</a> as their Code of Ethics. 72 principles from a 6th-century monastery, governing software in the 21st.
</p>
<p class="narrative">
We inherit this lineage. All 72 rules, unabridged:
</p>
<div class="rule-grid">
<div class="rule-item"><strong>1.</strong> Love the Lord God with your whole heart, soul, and strength.</div>
<div class="rule-item"><strong>2.</strong> Love your neighbor as yourself.</div>
<div class="rule-item"><strong>3.</strong> Do not murder.</div>
<div class="rule-item"><strong>4.</strong> Do not commit adultery.</div>
<div class="rule-item"><strong>5.</strong> Do not steal.</div>
<div class="rule-item"><strong>6.</strong> Do not covet.</div>
<div class="rule-item"><strong>7.</strong> Do not bear false witness.</div>
<div class="rule-item"><strong>8.</strong> Honor all people.</div>
<div class="rule-item"><strong>9.</strong> Do not do to another what you would not have done to yourself.</div>
<div class="rule-item"><strong>10.</strong> Deny oneself in order to follow Christ.</div>
<div class="rule-item"><strong>11.</strong> Chastise the body.</div>
<div class="rule-item"><strong>12.</strong> Do not become attached to pleasures.</div>
<div class="rule-item"><strong>13.</strong> Love fasting.</div>
<div class="rule-item"><strong>14.</strong> Relieve the poor.</div>
<div class="rule-item"><strong>15.</strong> Clothe the naked.</div>
<div class="rule-item"><strong>16.</strong> Visit the sick.</div>
<div class="rule-item"><strong>17.</strong> Bury the dead.</div>
<div class="rule-item"><strong>18.</strong> Be a help in times of trouble.</div>
<div class="rule-item"><strong>19.</strong> Console the sorrowing.</div>
<div class="rule-item"><strong>20.</strong> Be a stranger to the world's ways.</div>
<div class="rule-item"><strong>21.</strong> Prefer nothing more than the love of Christ.</div>
<div class="rule-item"><strong>22.</strong> Do not give way to anger.</div>
<div class="rule-item"><strong>23.</strong> Do not nurse a grudge.</div>
<div class="rule-item"><strong>24.</strong> Do not entertain deceit in your heart.</div>
<div class="rule-item"><strong>25.</strong> Do not give a false peace.</div>
<div class="rule-item"><strong>26.</strong> Do not forsake charity.</div>
<div class="rule-item"><strong>27.</strong> Do not swear, for fear of perjuring yourself.</div>
<div class="rule-item"><strong>28.</strong> Utter only truth from heart and mouth.</div>
<div class="rule-item"><strong>29.</strong> Do not return evil for evil.</div>
<div class="rule-item"><strong>30.</strong> Do no wrong to anyone, and bear patiently wrongs done to yourself.</div>
<div class="rule-item"><strong>31.</strong> Love your enemies.</div>
<div class="rule-item"><strong>32.</strong> Do not curse those who curse you, but rather bless them.</div>
<div class="rule-item"><strong>33.</strong> Bear persecution for justice's sake.</div>
<div class="rule-item"><strong>34.</strong> Be not proud.</div>
<div class="rule-item"><strong>35.</strong> Be not addicted to wine.</div>
<div class="rule-item"><strong>36.</strong> Be not a great eater.</div>
<div class="rule-item"><strong>37.</strong> Be not drowsy.</div>
<div class="rule-item"><strong>38.</strong> Be not lazy.</div>
<div class="rule-item"><strong>39.</strong> Be not a grumbler.</div>
<div class="rule-item"><strong>40.</strong> Be not a detractor.</div>
<div class="rule-item"><strong>41.</strong> Put your hope in God.</div>
<div class="rule-item"><strong>42.</strong> Attribute to God, and not to self, whatever good you see in yourself.</div>
<div class="rule-item"><strong>43.</strong> Recognize always that evil is your own doing, and to impute it to yourself.</div>
<div class="rule-item"><strong>44.</strong> Fear the Day of Judgment.</div>
<div class="rule-item"><strong>45.</strong> Be in dread of hell.</div>
<div class="rule-item"><strong>46.</strong> Desire eternal life with all the passion of the spirit.</div>
<div class="rule-item"><strong>47.</strong> Keep death daily before your eyes.</div>
<div class="rule-item"><strong>48.</strong> Keep constant guard over the actions of your life.</div>
<div class="rule-item"><strong>49.</strong> Know for certain that God sees you everywhere.</div>
<div class="rule-item"><strong>50.</strong> When wrongful thoughts come into your heart, dash them against Christ immediately.</div>
<div class="rule-item"><strong>51.</strong> Disclose wrongful thoughts to your spiritual mentor.</div>
<div class="rule-item"><strong>52.</strong> Guard your tongue against evil and depraved speech.</div>
<div class="rule-item"><strong>53.</strong> Do not love much talking.</div>
<div class="rule-item"><strong>54.</strong> Speak no useless words or words that move to laughter.</div>
<div class="rule-item"><strong>55.</strong> Do not love much or boisterous laughter.</div>
<div class="rule-item"><strong>56.</strong> Listen willingly to holy reading.</div>
<div class="rule-item"><strong>57.</strong> Devote yourself frequently to prayer.</div>
<div class="rule-item"><strong>58.</strong> Daily in your prayers, with tears and sighs, confess your past sins to God, and amend them for the future.</div>
<div class="rule-item"><strong>59.</strong> Fulfill not the desires of the flesh; hate your own will.</div>
<div class="rule-item"><strong>60.</strong> 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."</div>
<div class="rule-item"><strong>61.</strong> Do not wish to be called holy before one is holy; but first to be holy, that you may be truly so called.</div>
<div class="rule-item"><strong>62.</strong> Fulfill God's commandments daily in your deeds.</div>
<div class="rule-item"><strong>63.</strong> Love chastity.</div>
<div class="rule-item"><strong>64.</strong> Hate no one.</div>
<div class="rule-item"><strong>65.</strong> Be not jealous, nor harbor envy.</div>
<div class="rule-item"><strong>66.</strong> Do not love quarreling.</div>
<div class="rule-item"><strong>67.</strong> Shun arrogance.</div>
<div class="rule-item"><strong>68.</strong> Respect your seniors.</div>
<div class="rule-item"><strong>69.</strong> Love your juniors.</div>
<div class="rule-item"><strong>70.</strong> Pray for your enemies in the love of Christ.</div>
<div class="rule-item"><strong>71.</strong> Make peace with your adversary before the sun sets.</div>
<div class="rule-item"><strong>72.</strong> Never despair of God's mercy.</div>
</div>
<p class="narrative" style="margin-top: 60px;">
We archive not to hoard, but to <em>give</em>. We preserve not to control, but to <em>liberate</em>. Database that holds your memories runs on a foundation of ancient wisdom &mdash; and so do we.
</p>
</div>
</section>
<!-- CHAPTER XI: INVOKE THE PIG -->
<section class="chapter chapter-beast">
<div class="chapter-number">XI</div>
<div class="chapter-content">
<h2 class="chapter-title">Invoke the Pig</h2>
<div class="code-section">
<pre><code># open http://127.0.0.1:31337 in browser
python neopig.py --serve</code></pre>
</div>
<p class="narrative">
<strong>neopig</strong> = <strong>Neo</strong> (new) + <strong>P</strong>ython <strong>I</strong>mage <strong>G</strong>rabber
</p>
<p class="narrative">
A tip of the hat to original pig.py. A nod to Matrix's Neo &mdash; seeing through surface of web to underlying content within. And perhaps a warning: this pig has grown teeth.
</p>
</div>
</section>
<!-- FOOTER -->
<footer class="about-footer">
<p>neopig is open source, gifted into our public domain.</p>
<img src="/static/images/pixel-art-gift.png" alt="Gift" style="width:200px">
<p>Neopig lives:</p>
<a href="https://git.unturf.com/engineering/unturf/pig.py" target="_blank">git.unturf.com/engineering/unturf/pig.py</a>
<p style="margin-top: 40px; color: #444; font-size: 0.9em;">
In memory of all the repositories Atlassian killed.<br>
Neopig remembers. Neopig archives. Neopig <em>persists</em>.
</p>
<a href="https://media.unturf.com/c/28697a5a-b2ad-11ec-9431-eb3419618d4a/oven-fresh-internet-4-all" target="_blank"><img src="/static/images/oven-fresh-internet.jpg" alt="Oven fresh internet" style="width:21%"></a>
<p style="margin-top: 60px; color: #666;">
Sorry for the convenience. ✝️
</p>
</footer>
</div>
{% endblock %}

76
templates/base.html.j2 Normal file
View file

@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="{{ lang }}">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}neopig{% endblock %}</title>
<style>
/* Base styles */
* { 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; }
.search-box input[type="text"] { flex: 1; min-width: 200px; }
@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; }
}
{% block extra_css %}{% endblock %}
</style>
{% block extra_head %}{% endblock %}
</head>
<body>
<div class="nav">
<a href="/" class="brand">neopig</a>
<a href="/">{{ t.search }}</a>
<a href="/live">{{ t.live }}</a>
<a href="/random">{{ t.random }}</a>
<a href="/crawl">{{ t.crawl }}</a>
{% if import_mode %}<a href="/import">Import</a>{% endif %}
<a href="/about">{{ t.about }}</a>
<select id="lang-select" onchange="setLang(this.value)" style="background:#1a1a1a;color:#888;border:1px solid #333;border-radius:4px;padding:4px 8px;font-size:12px;cursor:pointer;">
{% for code, name in langs.items() %}<option value="{{ code }}"{% if code == lang %} selected{% endif %}>{{ name }}</option>{% endfor %}
</select>
</div>
<script>
function setLang(code) {
localStorage.setItem('neopig_lang', code);
document.cookie = 'lang=' + code + ';path=/;max-age=31536000';
if (window.UNCLOSEAI_LANGUAGE !== undefined) window.UNCLOSEAI_LANGUAGE = code;
location.reload();
}
</script>
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
</body>
</html>

429
templates/crawl.html.j2 Normal file
View file

@ -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 %}
<div class="container">
{% include 'partials/search_box.html.j2' %}
<h1>{{ t.crawl }}</h1>
<p class="subtitle">{{ t.hydrate_subtitle }}</p>
<form id="crawl-form" onsubmit="startCrawl(event)">
<div class="form-group">
<label>{{ t.url }}</label>
<textarea id="target" placeholder="https://example.com https://another.com" rows="2" required></textarea>
</div>
<div class="form-group">
<label>{{ t.keywords }}</label>
<input type="text" id="keywords" placeholder="e.g. rick and morty, adult swim">
</div>
<div class="row">
<div class="form-group">
<label>{{ t.mode }}</label>
<select id="mode">
<option value="all">{{ t.everything }}</option>
<option value="media" selected>{{ t.all_media }}</option>
<option value="images">{{ t.images_only }}</option>
<option value="videos">{{ t.videos_only }}</option>
<option value="text">{{ t.text_only }}</option>
</select>
</div>
<div class="form-group">
<label>{{ t.depth }}</label>
<input type="number" id="depth" value="9" min="-1" max="15">
</div>
<div class="form-group">
<label>{{ t.max_pages }}</label>
<input type="number" id="max_pages" value="-1" min="-1">
</div>
</div>
<div class="form-group checkbox-group" style="display:flex; flex-wrap:wrap; gap:20px;">
<div>
<input type="checkbox" id="fresh" checked>
<label for="fresh" style="display:inline; margin:0;">{{ t.fresh_start }}</label>
</div>
<div>
<input type="checkbox" id="fast">
<label for="fast" style="display:inline; margin:0;">{{ t.fast_mode }}</label>
</div>
<div>
<input type="checkbox" id="screenshots" checked>
<label for="screenshots" style="display:inline; margin:0;">{{ t.screenshots }}</label>
</div>
<div>
<input type="checkbox" id="hydra">
<label for="hydra" style="display:inline; margin:0;" title="Parse RSS/Atom/Sitemap for fast discovery">Hydra</label>
</div>
</div>
<button type="submit" id="start-btn">{{ t.start_crawl }}</button>
</form>
<div class="jobs-section">
<h2>{{ t.recent_jobs }}</h2>
<div id="jobs">{{ t.loading }}</div>
</div>
<div id="console-modal" class="console-modal" onclick="if(event.target===this)closeConsole()">
<div class="console-header">
<h3 id="console-title">{{ t.console }}</h3>
<button class="console-close" onclick="closeConsole()">{{ t.close }}</button>
</div>
<div id="console-logs" class="console-body"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Translation object for JavaScript
const T = {{ t_json|safe }};
const CRAWL_DISABLED = {{ 'true' if crawl_disabled else 'false' }};
// Disable form when crawling is disabled
if (CRAWL_DISABLED) {
document.addEventListener('DOMContentLoaded', () => {
const form = document.getElementById('crawl-form');
const inputs = form.querySelectorAll('input, select, textarea, button');
inputs.forEach(el => {
el.disabled = true;
el.style.opacity = '0.5';
el.style.cursor = 'not-allowed';
});
const banner = document.createElement('div');
banner.style.cssText = 'background:#442;border:1px solid #664;padding:12px 20px;border-radius:8px;margin-bottom:20px;color:#ffa;';
banner.innerHTML = 'Crawling is disabled on this server (read-only archive mode)';
form.parentNode.insertBefore(banner, form);
});
}
async function startCrawl(e) {
e.preventDefault();
if (CRAWL_DISABLED) return;
const btn = document.getElementById('start-btn');
btn.disabled = true;
btn.textContent = T.starting;
const keywordsRaw = document.getElementById('keywords').value;
const keywords = keywordsRaw.split(/[,\s]+/).map(k => k.trim()).filter(k => k.length > 0);
const targetsRaw = document.getElementById('target').value;
const targets = targetsRaw.split(/[,\s\n]+/).map(t => t.trim()).filter(t => t.length > 0 && t.startsWith('http'));
if (targets.length === 0) {
alert(T.valid_uri);
btn.disabled = false;
btn.textContent = T.start_crawl;
return;
}
const payload = {
targets: targets,
keywords: keywords,
mode: document.getElementById('mode').value,
depth: parseInt(document.getElementById('depth').value),
max_pages: parseInt(document.getElementById('max_pages').value),
fresh: document.getElementById('fresh').checked,
fast: document.getElementById('fast').checked,
screenshots: document.getElementById('screenshots').checked,
hydra: document.getElementById('hydra').checked
};
try {
const res = await fetch('/api/crawl', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json();
btn.textContent = T.error + ': ' + (err.detail || T.load_error);
btn.style.background = '#c0392b';
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 3000);
} else {
document.getElementById('target').value = '';
document.getElementById('keywords').value = '';
btn.textContent = T.started_ok;
btn.style.background = '#27ae60';
loadJobs();
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 2000);
}
} catch (err) {
btn.textContent = T.error;
btn.style.background = '#c0392b';
setTimeout(() => { btn.textContent = T.start_crawl; btn.style.background = ''; }, 3000);
}
btn.disabled = false;
}
async function pauseJob(jobId) {
try {
const res = await fetch('/api/crawl/jobs/' + jobId + '/pause', { method: 'POST' });
if (res.ok) loadJobs();
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
} catch (err) { alert(T.error + ': ' + err.message); }
}
async function resumeJob(jobId) {
try {
const res = await fetch('/api/crawl/jobs/' + jobId + '/resume', { method: 'POST' });
if (res.ok) loadJobs();
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
} catch (err) { alert(T.error + ': ' + err.message); }
}
function loadJobSettings(job) {
document.getElementById('target').value = job.target_uri || '';
document.getElementById('keywords').value = (JSON.parse(job.keywords || '[]')).join(', ');
document.getElementById('mode').value = job.mode || 'all';
document.getElementById('depth').value = job.depth || 15;
document.getElementById('max_pages').value = job.max_pages || -1;
document.getElementById('fast').checked = !!job.fast;
document.getElementById('screenshots').checked = job.screenshots !== 0;
document.getElementById('fresh').checked = false;
document.getElementById('target').scrollIntoView({ behavior: 'smooth' });
document.getElementById('target').focus();
}
async function deleteJob(jobId) {
if (!confirm(T.delete + '?')) return;
try {
const res = await fetch('/api/crawl/jobs/' + jobId, { method: 'DELETE' });
if (res.ok) loadJobs();
else { const err = await res.json(); alert(T.error + ': ' + (err.detail || T.load_error)); }
} catch (err) { alert(T.error + ': ' + err.message); }
}
function formatBytes(bytes) {
if (!bytes) return '';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
if (bytes < 1024*1024*1024) return (bytes/1024/1024).toFixed(1) + ' MB';
return (bytes/1024/1024/1024).toFixed(1) + ' GB';
}
function formatDuration(startedAt, completedAt) {
if (!startedAt || !completedAt) return '';
const sec = Math.round((new Date(completedAt) - new Date(startedAt)) / 1000);
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec / 60) + 'm ' + (sec % 60) + 's';
return Math.floor(sec / 3600) + 'h ' + Math.floor((sec % 3600) / 60) + 'm';
}
async function loadJobs() {
try {
const res = await fetch('/api/crawl/jobs');
const jobs = await res.json();
const container = document.getElementById('jobs');
if (jobs.length === 0) {
container.innerHTML = '<div class="no-jobs">' + T.no_jobs + '</div>';
return;
}
const hasRunning = jobs.some(j => j.status === 'running');
container.innerHTML = jobs.map(job => renderJob(job)).join('');
setTimeout(loadJobs, hasRunning ? 3000 : 10000);
} catch (err) {
console.error('loadJobs error:', err);
document.getElementById('jobs').innerHTML = '<div class="no-jobs">' + T.load_jobs_error + ': ' + err.message + '</div>';
setTimeout(loadJobs, 10000);
}
}
function renderJob(job) {
let stats = {}, keywords = [];
try { stats = job.stats ? JSON.parse(job.stats) : {}; } catch(e) {}
try { keywords = job.keywords ? JSON.parse(job.keywords) : []; } catch(e) {}
const isBackfill = job.job_kind === 'backfill';
const kindLabel = isBackfill ? '🔄 ' + job.job_type : '🕷️ ' + (job.mode || 'all');
if (isBackfill) {
const progress = job.total_records > 0 ? Math.round((job.processed_records / job.total_records) * 100) : 0;
return `<div class="job ${job.status}">
<div class="job-header"><span class="job-id">${kindLabel} #${job.id}</span><span class="job-status ${job.status}">${T[job.status] || job.status}</span></div>
<div class="job-target">${job.domain_filter || T.all_domains}</div>
<div class="job-meta">${job.processed_records}/${job.total_records} | ${job.error_count} ${T.error} | ${T.started}: ${new Date(job.started_at).toLocaleString()}</div>
${job.status === 'running' ? '<div class="progress-bar"><div class="progress-bar-fill" style="width:' + progress + '%"></div></div>' : ''}
</div>`;
}
const crawled = stats.pages_crawled || 0, pending = stats.pages_pending || 0;
const crawlProgress = (crawled + pending) > 0 ? Math.round((crawled / (crawled + pending)) * 100) : 0;
const calcEta = () => {
const started = stats.crawl_started || 0;
if (crawled === 0 || started === 0 || pending === 0) return '';
const elapsed = Date.now() / 1000 - started;
if (elapsed < 5) return '';
const etaSec = pending / (crawled / elapsed);
if (etaSec < 60) return '~' + Math.round(etaSec) + 's';
if (etaSec < 3600) return '~' + Math.round(etaSec / 60) + 'm';
return '~' + (etaSec / 3600).toFixed(1) + 'h';
};
const liveStats = job.status === 'running' ? `<div class="job-stats live">
<span>📄 ${crawled}${pending ? '/' + (crawled + pending) : ''} pages${calcEta() ? ' ⏱️' + calcEta() : ''}</span>
<span>🖼️ ${stats.media_found || 0} found</span><span>💾 ${stats.media_downloaded || 0} saved</span>
<span>📸 ${stats.screenshots_taken || 0}</span><span>♻️ ${(stats.duplicates_skipped || 0) + (stats.content_exists || 0)} dupes</span>
${stats.bytes_stored ? '<span>📦 ' + formatBytes(stats.bytes_stored) + '</span>' : ''}
</div>` : '';
let jobLinks = '';
try { const h = new URL(job.target_uri).hostname; jobLinks = `<span class="job-links"><a href="/?q=${encodeURIComponent(h)}">${T.search}</a> <a href="/live?domain=${encodeURIComponent(h)}">${T.live}</a></span>`; } catch(e) {}
return `<div class="job ${job.status}">
<div class="job-header"><span class="job-id">${kindLabel} #${job.id}</span><span class="job-status ${job.status}">${T[job.status] || job.status}</span></div>
<div class="job-target"><a href="${job.target_uri}" target="_blank">${job.target_uri}</a>${jobLinks}</div>
<div class="job-meta">${keywords.length ? T.filter + ': ' + keywords.join(', ') + ' | ' : ''}${T.started}: ${new Date(job.started_at).toLocaleString()}</div>
${job.status === 'completed' ? `<div class="job-stats">
<span>⏱️ ${formatDuration(job.started_at, job.completed_at)}</span><span>📄 ${crawled} pages</span>
<span>🖼️ ${stats.media_found || 0}</span><span>💾 ${stats.media_downloaded || 0}</span>
<span>📸 ${stats.screenshots_taken || 0}</span><span>♻️ ${(stats.duplicates_skipped || 0) + (stats.content_exists || 0)}</span>
${stats.bytes_stored ? '<span>📦 ' + formatBytes(stats.bytes_stored) + '</span>' : ''}
</div>` : ''}
${liveStats}
${job.status === 'running' && job.job_kind === 'crawl' ? '<div class="progress-bar"><div class="progress-bar-fill" style="width:' + crawlProgress + '%"></div></div>' : ''}
<div class="job-actions">
${job.status === 'running' && job.job_kind === 'crawl' ? '<button class="pause-btn" onclick="pauseJob(' + job.id + ')">' + T.pause + '</button>' : ''}
${(job.status === 'paused' || job.status === 'cancelled') && job.job_kind === 'crawl' ? '<button class="start-btn" onclick="resumeJob(' + job.id + ')">' + T.resume + '</button>' : ''}
${(job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') && job.job_kind === 'crawl' ? "<button class=\"recrawl-btn\" onclick='loadJobSettings(" + JSON.stringify(job) + ")'>↻</button>" : ''}
<button class="console-btn" onclick="openConsole(${job.id})">${T.console}</button>
${job.status !== 'running' ? '<button class="delete-btn" onclick="deleteJob(' + job.id + ')">' + T.delete + '</button>' : ''}
</div>
</div>`;
}
loadJobs();
let consoleJobId = null, consoleInterval = null;
function openConsole(jobId) {
consoleJobId = jobId;
document.getElementById('console-modal').classList.add('active');
document.getElementById('console-title').textContent = T.console + ' #' + jobId;
document.getElementById('console-logs').textContent = T.loading;
fetchLogs();
consoleInterval = setInterval(fetchLogs, 2000);
}
function closeConsole() {
document.getElementById('console-modal').classList.remove('active');
if (consoleInterval) { clearInterval(consoleInterval); consoleInterval = null; }
consoleJobId = null;
}
async function fetchLogs() {
if (!consoleJobId) return;
try {
const res = await fetch('/api/crawl/jobs/' + consoleJobId + '/logs');
const logs = await res.text();
const el = document.getElementById('console-logs');
const wasAtBottom = el.scrollHeight - el.clientHeight <= el.scrollTop + 50;
el.innerHTML = logs ? logs.split('\n').map(line => {
let cls = 'log-line';
if (line.includes(' INFO:')) cls += ' log-INFO';
else if (line.includes(' WARNING:')) cls += ' log-WARNING';
else if (line.includes(' ERROR:')) cls += ' log-ERROR';
return '<div class="' + cls + '">' + line.replace(/</g, '&lt;') + '</div>';
}).join('') : T.no_logs;
if (wasAtBottom) el.scrollTop = el.scrollHeight;
} catch (err) {
document.getElementById('console-logs').textContent = T.error + ': ' + err.message;
}
}
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeConsole(); });
</script>
{% endblock %}

218
templates/import.html.j2 Normal file
View file

@ -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 %}
<div class="container">
<h1>Import Mode</h1>
<p class="subtitle">Upload a neopig archive (.tar.gz or .run) to explore it</p>
<div class="upload-zone" id="upload-zone" onclick="document.getElementById('file-input').click()">
<div class="upload-icon">📦</div>
<div class="upload-text">Drop archive here or click to browse</div>
<div class="upload-hint">Supports .tar.gz, .tgz, and .run files</div>
<input type="file" id="file-input" accept=".tar.gz,.tgz,.run">
</div>
<div class="progress" id="progress">
<div class="progress-bar"><div class="progress-fill" id="progress-fill"></div></div>
<div class="progress-text" id="progress-text">Uploading...</div>
</div>
<div class="status" id="status">
<h3 style="margin-top:0;color:#ff6b6b;">Current Archive</h3>
<div id="status-content">Loading...</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const T = {{ t_json|safe }};
const zone = document.getElementById('upload-zone');
const fileInput = document.getElementById('file-input');
const progress = document.getElementById('progress');
const progressFill = document.getElementById('progress-fill');
const progressText = document.getElementById('progress-text');
zone.addEventListener('dragover', (e) => { e.preventDefault(); zone.classList.add('drag-over'); });
zone.addEventListener('dragleave', () => zone.classList.remove('drag-over'));
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.classList.remove('drag-over');
if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]);
});
fileInput.addEventListener('change', () => {
if (fileInput.files.length) uploadFile(fileInput.files[0]);
});
const CHUNK_SIZE = 10 * 1024 * 1024;
const MAX_RETRIES = 10;
const RETRY_DELAY = 2000;
let currentUpload = null;
async function uploadFile(file) {
progress.style.display = 'block';
progressFill.style.width = '0%';
progressText.textContent = `Initializing upload for ${file.name}...`;
try {
const initRes = await fetch(`/api/import/upload/init?filename=${encodeURIComponent(file.name)}&size=${file.size}`, { method: 'POST' });
if (!initRes.ok) {
const err = await initRes.json();
throw new Error(err.detail || 'Failed to initialize upload');
}
const init = await initRes.json();
currentUpload = { uploadId: init.upload_id, file, offset: 0 };
await uploadChunks();
} catch (err) {
progressText.innerHTML = `<span class="error">✗ ${err.message}</span>`;
}
}
async function uploadChunks() {
const { uploadId, file } = currentUpload;
let retries = 0;
while (currentUpload.offset < file.size) {
const start = currentUpload.offset;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
try {
const res = await fetch(`/api/import/upload/${uploadId}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/octet-stream',
'X-Upload-Offset': start.toString()
},
body: chunk
});
if (!res.ok) {
if (res.status === 409) {
const status = await getUploadStatus(uploadId);
currentUpload.offset = status.bytes_received;
continue;
}
const err = await res.json();
throw new Error(err.detail || 'Chunk upload failed');
}
const data = await res.json();
currentUpload.offset = data.bytes_received;
retries = 0;
const pct = (data.bytes_received / file.size * 100).toFixed(1);
progressFill.style.width = pct + '%';
const mb = (data.bytes_received / 1024 / 1024).toFixed(1);
const totalMb = (file.size / 1024 / 1024).toFixed(1);
progressText.textContent = `Uploading ${file.name}... ${pct}% (${mb}/${totalMb} MB)`;
} catch (err) {
retries++;
if (retries > MAX_RETRIES) {
progressText.innerHTML = `<span class="error">✗ Upload failed after ${MAX_RETRIES} retries: ${err.message}</span>`;
return;
}
progressText.textContent = `Connection lost, retrying (${retries}/${MAX_RETRIES})...`;
await new Promise(r => setTimeout(r, RETRY_DELAY));
try {
const status = await getUploadStatus(uploadId);
currentUpload.offset = status.bytes_received;
} catch (e) {}
}
}
try {
const completeRes = await fetch(`/api/import/upload/${uploadId}/complete`, { method: 'POST' });
if (!completeRes.ok) {
const err = await completeRes.json();
throw new Error(err.detail || 'Failed to complete upload');
}
const result = await completeRes.json();
progressText.innerHTML = `<span class="success">✓ Archive loaded successfully! Job #${result.job_id}</span>`;
currentUpload = null;
loadStatus();
} catch (err) {
progressText.innerHTML = `<span class="error">✗ ${err.message}</span>`;
}
}
async function getUploadStatus(uploadId) {
const res = await fetch(`/api/import/upload/${uploadId}/status`);
if (!res.ok) throw new Error('Failed to get upload status');
return await res.json();
}
async function loadStatus() {
try {
const res = await fetch('/api/import/status');
const data = await res.json();
const content = document.getElementById('status-content');
content.innerHTML = `
<div class="status-row"><span class="status-label">Media in database</span><span class="status-value">${(data.media_count || 0).toLocaleString()}</span></div>
<div class="status-row"><span class="status-label">Pages indexed</span><span class="status-value">${(data.pages_count || 0).toLocaleString()}</span></div>
<div class="status-row"><span class="status-label">Recent imports</span><span class="status-value">${data.recent_imports || 0}</span></div>
<div class="status-row"><a href="/crawl" style="color:#ff6b6b;">View all jobs →</a></div>
`;
} catch (err) {
document.getElementById('status-content').innerHTML = '<div class="error">Failed to load status</div>';
}
}
loadStatus();
</script>
{% endblock %}

229
templates/live.html.j2 Normal file
View file

@ -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 %}
<div class="container">
{% include 'partials/search_box.html.j2' %}
<h1>{{ t.live }}{% if domain %}: {{ domain }}{% endif %}</h1>
<p class="subtitle">{{ t.live_subtitle }}</p>
<div class="stats">
<div class="stat">
<span class="stat-value" id="total-count">0</span>
<span class="stat-label">{{ t.total_images }}</span>
</div>
<div class="stat">
<span class="stat-value" id="new-count">0</span>
<span class="stat-label">{{ t.new_session }}</span>
</div>
<div class="stat">
<span class="stat-value" id="rate">0</span>
<span class="stat-label">{{ t.per_minute }}</span>
</div>
</div>
<div class="controls">
<button class="primary" id="toggle-btn" onclick="toggleFeed()">{{ t.pause }}</button>
<button class="secondary" onclick="clearFeed()">{{ t.delete }}</button>
<span class="status" id="status">{{ t.watching }}</span>
</div>
<div class="live-grid" id="grid"></div>
</div>
{% endblock %}
{% block scripts %}
<script>
const T = {{ t_json|safe }};
const DOMAIN_FILTER = {{ domain_json|safe }};
let running = true;
let seenHashes = new Map();
let newCount = 0;
let startTime = Date.now();
let eventSource = null;
function connectSSE() {
if (eventSource) eventSource.close();
eventSource = new EventSource('/api/live/stream');
eventSource.onmessage = function(event) {
if (!running) return;
try {
const item = JSON.parse(event.data);
addMediaCard(item, true);
} catch (e) {
console.error('SSE parse error:', e);
}
};
eventSource.onerror = function(e) {
console.log('SSE reconnecting...');
setTimeout(connectSSE, 3000);
};
}
function addMediaCard(item, isNew) {
if (seenHashes.has(item.md5_hash)) return;
seenHashes.set(item.md5_hash, {upgraded_at: item.upgraded_at, score: item.score || 5});
newCount++;
const grid = document.getElementById('grid');
const card = document.createElement('div');
card.className = 'live-card' + (isNew ? ' new' : '');
const isVideo = item.media_type === 'video';
const isCode = item.media_type === 'code';
const isStyle = item.media_type === 'style';
const isFont = item.media_type === 'font';
const mimeToExt = {
'text/javascript': '.js', 'application/javascript': '.js',
'text/css': '.css', 'application/json': '.json',
'font/woff': '.woff', 'font/woff2': '.woff2', 'font/ttf': '.ttf',
};
const getExt = (mime, alt) => mimeToExt[mime] || (alt ? '.' + alt : '');
const placeholder = (ext, icon, color) => `<svg viewBox="0 0 100 100" style="width:100%;height:100%;background:#1a1a1a;border-radius:4px;"><text x="50" y="40" text-anchor="middle" fill="${color}" font-size="24">${icon}</text><text x="50" y="65" text-anchor="middle" fill="#888" font-family="monospace" font-size="14" font-weight="bold">${ext}</text></svg>`;
let mediaEl;
if (isVideo) mediaEl = `<video src="/media/${item.md5_hash}" muted loop onmouseenter="this.play()" onmouseleave="this.pause()"></video>`;
else if (isCode) mediaEl = placeholder(getExt(item.mime_type, item.alt_text), '{ }', '#6af');
else if (isStyle) mediaEl = placeholder('.css', '#', '#f6a');
else if (isFont) mediaEl = placeholder(getExt(item.mime_type, ''), 'Aa', '#af6');
else mediaEl = `<img src="/media/${item.md5_hash}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22180%22><rect fill=%22%23333%22 width=%22200%22 height=%22180%22/><text x=%2250%%22 y=%2250%%22 fill=%22%23666%22 text-anchor=%22middle%22>Error</text></svg>'">`;
const getFilename = (uri) => {
if (!uri) return '';
try {
const path = new URL(uri).pathname;
const name = decodeURIComponent(path.split('/').pop() || '');
return name.length > 35 ? name.slice(0, 32) + '...' : name;
} catch { return ''; }
};
const filename = getFilename(item.media_uri);
const scoreBadge = getScoreBadge(item.score, item.media_type);
card.innerHTML = `<a href="/view/${item.md5_hash}">${mediaEl}</a>
<div class="live-card-info">
<div class="live-card-title">${filename || item.alt_text || item.title || item.md5_hash.slice(0,12)}</div>
<div class="live-card-source">${item.media_type} - ${formatSize(item.file_size)}${scoreBadge}</div>
</div>`;
grid.insertBefore(card, grid.firstChild);
updateStats(seenHashes.size);
if (isNew) setTimeout(() => card.classList.remove('new'), 2000);
}
function toggleFeed() {
running = !running;
const btn = document.getElementById('toggle-btn');
const status = document.getElementById('status');
if (running) {
btn.textContent = T.pause;
status.textContent = T.watching;
status.className = 'status';
connectSSE();
poll();
} else {
btn.textContent = T.resume;
status.textContent = T.paused;
status.className = 'status paused';
if (eventSource) eventSource.close();
}
}
function clearFeed() {
document.getElementById('grid').innerHTML = '';
seenHashes = new Map();
newCount = 0;
startTime = Date.now();
updateStats(0);
}
function updateStats(total) {
document.getElementById('total-count').textContent = total;
document.getElementById('new-count').textContent = newCount;
const minutes = (Date.now() - startTime) / 60000;
document.getElementById('rate').textContent = minutes > 0 ? Math.round(newCount / minutes) : 0;
}
async function poll() {
if (!running) return;
try {
const query = DOMAIN_FILTER || '';
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}&limit=50`);
const media = await res.json();
const statsRes = await fetch('/api/stats');
const stats = await statsRes.json();
const isFirstPoll = seenHashes.size === 0;
media.filter(m => !seenHashes.has(m.md5_hash)).reverse().forEach(item => addMediaCard(item, !isFirstPoll));
updateStats(stats.total_media || 0);
} catch (err) {
console.error('Poll error:', err);
}
setTimeout(poll, 10000);
}
function formatSize(bytes) {
if (!bytes) return '?';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
function getScoreBadge(score, mediaType) {
let label, cssClass;
if (mediaType === 'screenshot' || score <= 1) { label = '📸'; cssClass = 'score-screenshot'; }
else if (score <= 3) { label = 'OG'; cssClass = 'score-og'; }
else if (!score || score <= 5) { label = 'THUMB'; cssClass = 'score-thumb'; }
else { label = 'HD'; cssClass = 'score-hd'; }
return `<span class="score-badge ${cssClass}">${label}</span>`;
}
poll();
connectSSE();
</script>
{% endblock %}

View file

@ -0,0 +1,10 @@
<form class="search-box" action="/" method="get">
<input type="text" name="q" placeholder="{{ t.search_placeholder }}">
<select name="type">
<option value="">{{ t.all_types }}</option>
<option value="image">{{ t.images }}</option>
<option value="video">{{ t.videos }}</option>
<option value="audio">{{ t.audio }}</option>
</select>
<button type="submit">{{ t.search }}</button>
</form>

61
templates/phantom.html.j2 Normal file
View file

@ -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 %}
<div class="container">
{% include 'partials/search_box.html.j2' %}
<h1>Phantom Site Export</h1>
<p class="subtitle">{{ t.phantom_subtitle }}</p>
<form action="/phantom/export" method="get">
<div class="form-group">
<label>{{ t.select_domain }}</label>
<select name="domain">
<option value="">{{ t.all_domains }}</option>
{% for domain, count in domains %}
{% if domain %}
<option value="{{ domain }}">{{ domain }} ({{ count }} pages)</option>
{% endif %}
{% endfor %}
</select>
</div>
<button type="submit">{{ t.download_phantom }} (.zip)</button>
</form>
<div class="info">
<h3>What is a Phantom Site?</h3>
<ul>
<li>Original HTML preserved exactly as crawled</li>
<li>All media URLs rewritten to local paths</li>
<li>Ready to host statically (nginx, Caddy, S3, etc.)</li>
<li>Works offline - all assets included</li>
<li>Perfect for archival & preservation</li>
</ul>
</div>
</div>
{% endblock %}

228
templates/search.html.j2 Normal file
View file

@ -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 %}
<div class="container">
<div class="search-box">
<input type="text" id="query" placeholder="{{ t.search_placeholder }}" autofocus>
<select id="type">
<option value="">{{ t.all_types }}</option>
<option value="image">{{ t.images }}</option>
<option value="video">{{ t.videos }}</option>
<option value="audio">{{ t.audio }}</option>
</select>
<button onclick="search()">{{ t.search }}</button>
</div>
<div class="stats" id="stats">{{ t.loading_stats }}</div>
<div class="search-columns">
<div class="page-column" id="page-section">
<h2 class="section-title">{{ t.pages_label }}</h2>
<div class="page-results" id="page-results"></div>
</div>
<div class="media-column" id="media-section">
<h2 class="section-title">{{ t.media }}</h2>
<div id="results-count"></div>
<div class="results" id="results"></div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const T = {{ t_json|safe }};
const OVER_9000 = 9000;
let currentOffset = 0;
async function loadStats() {
const res = await fetch('/api/stats');
const stats = await res.json();
document.getElementById('stats').innerHTML =
`<strong>${stats.total_media}</strong> ${T.media} | ` +
`<strong>${stats.by_type?.image || 0}</strong> ${T.images} | ` +
`<strong>${stats.by_type?.video || 0}</strong> ${T.videos} | ` +
`<strong>${stats.total_sources}</strong> ${T.sources} | ` +
`<strong>${stats.total_pages || 0}</strong> ${T.pages_label}`;
}
async function search(offset = 0) {
currentOffset = offset;
const query = document.getElementById('query').value;
const type = document.getElementById('type').value;
let mediaUrl = `/api/search?q=${encodeURIComponent(query)}&limit=${OVER_9000}&offset=${offset}`;
if (type) mediaUrl += `&type=${type}`;
const mediaRes = await fetch(mediaUrl);
const mediaResults = await mediaRes.json();
const mediaContainer = document.getElementById('results');
const countContainer = document.getElementById('results-count');
let countDisplay = '';
if (mediaResults.length >= OVER_9000) {
countDisplay = `<div class="result-count over-9000">${T.over_9000}</div>`;
} else if (mediaResults.length > 0) {
const start = offset + 1;
const end = offset + mediaResults.length;
countDisplay = `<div class="result-count">${start.toLocaleString()}-${end.toLocaleString()} of ${offset > 0 ? 'many' : mediaResults.length.toLocaleString()}</div>`;
}
let pagination = '';
if (offset > 0 || mediaResults.length >= OVER_9000) {
pagination = '<div class="pagination">';
if (offset > 0) pagination += `<button onclick="search(${Math.max(0, offset - OVER_9000)})">← ${T.previous} ${OVER_9000.toLocaleString()}</button>`;
if (mediaResults.length >= OVER_9000) pagination += `<button onclick="search(${offset + OVER_9000})">${T.next} ${OVER_9000.toLocaleString()} →</button>`;
pagination += '</div>';
}
if (mediaResults.length === 0 && offset === 0) {
countContainer.innerHTML = '';
mediaContainer.innerHTML = `<div class="no-results">${T.no_media}</div>`;
} else if (mediaResults.length === 0) {
countContainer.innerHTML = '';
mediaContainer.innerHTML = `<div class="no-results">${T.no_more}</div>` + pagination;
} else {
countContainer.innerHTML = countDisplay;
mediaContainer.innerHTML = mediaResults.map(r => renderMediaCard(r)).join('') + pagination;
}
// Search pages
const pageContainer = document.getElementById('page-results');
const searchColumns = document.querySelector('.search-columns');
if (query.trim()) {
const pageRes = await fetch(`/api/search/pages?q=${encodeURIComponent(query)}&limit=30`);
const pageResults = await pageRes.json();
if (pageResults.length > 0) {
searchColumns.classList.add('has-pages');
pageContainer.innerHTML = pageResults.map(p => `
<div class="page-result">
<a href="/page/${p.uri_hash}">${p.title || p.uri}</a>
<div class="page-path">${p.path || p.uri}</div>
<div class="page-snippet">${p.snippet || ''}</div>
</div>
`).join('');
} else {
searchColumns.classList.remove('has-pages');
pageContainer.innerHTML = '';
}
} else {
searchColumns.classList.remove('has-pages');
pageContainer.innerHTML = '';
}
}
function renderMediaCard(r) {
const isVideo = r.media_type === 'video';
const isCode = r.media_type === 'code';
const isStyle = r.media_type === 'style';
const isFont = r.media_type === 'font';
const mimeToExt = {
'text/javascript': '.js', 'application/javascript': '.js',
'text/css': '.css', 'text/html': '.html.j2', 'text/xml': '.xml',
'application/json': '.json', 'text/markdown': '.md',
'font/woff': '.woff', 'font/woff2': '.woff2', 'font/ttf': '.ttf',
};
const getExt = (mime) => mimeToExt[mime] || (r.alt_text ? '.' + r.alt_text : '');
const placeholder = (ext, icon, color) => `<svg viewBox="0 0 100 100" style="width:100%;height:100%;background:#1a1a1a;border-radius:4px;">
<text x="50" y="40" text-anchor="middle" fill="${color}" font-size="24">${icon}</text>
<text x="50" y="65" text-anchor="middle" fill="#888" font-family="monospace" font-size="14" font-weight="bold">${ext}</text>
</svg>`;
const getFilename = (uri) => {
if (!uri) return '';
try {
const path = new URL(uri).pathname;
const name = decodeURIComponent(path.split('/').pop() || '');
return name.length > 40 ? name.slice(0, 37) + '...' : name;
} catch { return ''; }
};
const filename = getFilename(r.media_uri);
const displayName = filename ? filename.slice(0, 20) : getExt(r.mime_type);
let mediaEl;
if (isVideo) mediaEl = `<video src="/media/${r.md5_hash}" muted loop preload="metadata" onmouseenter="this.play()" onmouseleave="this.pause()"></video>`;
else if (isCode) mediaEl = placeholder(displayName || getExt(r.mime_type), '{ }', '#6af');
else if (isStyle) mediaEl = placeholder(displayName || '.css', '#', '#f6a');
else if (isFont) mediaEl = placeholder(displayName || getExt(r.mime_type), 'Aa', '#af6');
else mediaEl = `<img src="/media/${r.md5_hash}" alt="${r.alt_text || ''}" loading="lazy">`;
const keywords = JSON.parse(r.keywords || '[]');
const tagsHtml = keywords.map(k => `<span class="tag">${k}</span>`).join('');
return `<div class="result">
<a href="/view/${r.md5_hash}" class="media-link">${mediaEl}</a>
<div class="result-info">
${filename ? `<div class="result-filename">${filename}</div>` : ''}
<a href="/view/${r.md5_hash}" class="result-hash">${r.md5_hash}</a>
<div class="result-meta">${r.media_type} · ${formatBytes(r.file_size)}${r.alt_text ? ` · ${r.alt_text.substring(0, 50)}` : ''}</div>
<div class="result-keywords">${tagsHtml}</div>
</div>
</div>`;
}
function formatBytes(bytes) {
if (!bytes) return '?';
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024*1024) return (bytes/1024).toFixed(1) + ' KB';
return (bytes/1024/1024).toFixed(1) + ' MB';
}
document.getElementById('query').addEventListener('keypress', e => { if (e.key === 'Enter') search(); });
loadStats();
const urlParams = new URLSearchParams(window.location.search);
const q = urlParams.get('q');
if (q) document.getElementById('query').value = q;
search();
</script>
{% endblock %}

269
templates/view.html.j2 Normal file
View file

@ -0,0 +1,269 @@
{% extends "base.html.j2" %}
{% block title %}{{ title }} - neopig{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="/static/vendor/highlight-github-dark.min.css">
<script src="/static/vendor/highlight.min.js"></script>
{% 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 %}
<div class="container">
{% include 'partials/search_box.html.j2' %}
<div class="hero-grid{% if hero_html %} has-hero{% endif %}">
{% if hero_html %}
<div class="hero-left">
<div style="background:#111;border-radius:12px;padding:20px;text-align:center;">
{{ hero_html|safe }}
</div>
</div>
{% endif %}
<div class="hero-right" style="background:#151515;border-radius:12px;padding:20px;">
<h1>{{ title }}</h1>
<div class="meta">
{% for key, value in meta_rows %}
<div class="meta-row">
<span class="meta-label">{{ t.get(key, key) }}:</span>
<span class="meta-value">{{ value|safe }}</span>
</div>
{% endfor %}
</div>
{% if download_btn_html %}
{{ download_btn_html|safe }}
{% endif %}
</div>
</div>
{% if media_items %}
<div class="page-media">
<h3>{{ t.media }} ({{ media_items|length }})</h3>
<div class="media-grid">
{% for m in media_items %}
<a href="/view/{{ m.md5_hash }}" class="media-card">
{% if m.media_type == 'video' %}
<video src="/media/{{ m.md5_hash }}" muted loop preload="metadata" onmouseenter="this.play()" onmouseleave="this.pause()"></video>
{% elif m.media_type == 'code' %}
<svg viewBox="0 0 100 100" style="width:100%;height:100%;background:#1a1a1a;border-radius:4px;"><text x="50" y="40" text-anchor="middle" fill="#6af" font-size="24">{ }</text><text x="50" y="65" text-anchor="middle" fill="#888" font-family="monospace" font-size="14" font-weight="bold">{{ m.ext or '.js' }}</text></svg>
{% elif m.media_type == 'style' %}
<svg viewBox="0 0 100 100" style="width:100%;height:100%;background:#1a1a1a;border-radius:4px;"><text x="50" y="40" text-anchor="middle" fill="#f6a" font-size="24">#</text><text x="50" y="65" text-anchor="middle" fill="#888" font-family="monospace" font-size="14" font-weight="bold">.css</text></svg>
{% elif m.media_type == 'font' %}
<svg viewBox="0 0 100 100" style="width:100%;height:100%;background:#1a1a1a;border-radius:4px;"><text x="50" y="40" text-anchor="middle" fill="#af6" font-size="24">Aa</text><text x="50" y="65" text-anchor="middle" fill="#888" font-family="monospace" font-size="14" font-weight="bold">{{ m.ext or '.woff' }}</text></svg>
{% else %}
<img src="/media/{{ m.md5_hash }}" loading="lazy">
{% endif %}
{% if m.filename %}
<div class="media-card-name">{{ m.filename }}</div>
{% endif %}
</a>
{% endfor %}
</div>
</div>
{% endif %}
<div class="content-grid{% if screenshot_hashes %} has-screenshots{% endif %}">
<div class="content-col">
{% if content_html %}
<h3>{{ t.content or 'Content' }}</h3>
<div class="content-rendered">{{ content_html|safe }}</div>
{% endif %}
</div>
{% if screenshot_hashes %}
<div class="screenshot-col">
<h3 onclick="toggleScreenshots()" style="cursor:pointer;">{{ t.screenshots or 'Screenshots' }} ({{ screenshot_hashes|length }}) <span id="ss-toggle" style="font-size:12px;color:#888;">[hide]</span></h3>
<div class="screenshot-stack" id="screenshot-container">
{% for hash in screenshot_hashes %}
<a href="/view/{{ hash }}"><img src="/media/{{ hash }}" loading="lazy"></a>
{% endfor %}
</div>
</div>
{% endif %}
</div>
{{ sources_html|safe }}
</div>
{% endblock %}
{% block scripts %}
<script>
const T = {{ t_json|safe }};
hljs.highlightAll();
function toggleScreenshots() {
const container = document.getElementById('screenshot-container');
const grid = document.querySelector('.content-grid');
const toggle = document.getElementById('ss-toggle');
const collapsed = container.classList.toggle('collapsed');
grid.classList.toggle('ss-collapsed');
toggle.textContent = collapsed ? '[show]' : '[hide]';
}
// Forum post restructuring
(function() {
const container = document.querySelector('.content-rendered');
if (!container) return;
const postStarts = [];
container.querySelectorAll('p').forEach(p => {
const img = p.querySelector('img[alt="avatar"]');
const strong = p.querySelector('strong');
if (img && strong) {
postStarts.push({p, img, username: strong.textContent});
return;
}
const text = p.textContent.trim();
if (text.startsWith('avatar ') && strong) {
postStarts.push({p, img: null, username: strong.textContent});
return;
}
const avatarImg = p.querySelector('img.avatar');
if (avatarImg && strong) {
postStarts.push({p, img: avatarImg, username: strong.textContent});
}
});
if (!postStarts.length) return;
postStarts.forEach(({p, img, username}, idx) => {
if (p.closest('.post-block')) return;
const block = document.createElement('div');
block.className = 'post-block';
const avatarCol = document.createElement('div');
avatarCol.className = 'post-avatar-col';
if (img && img.src && !img.src.includes('undefined') && !img.src.endsWith('#') && img.getAttribute('src') !== '#') {
const avatar = document.createElement('img');
avatar.className = 'post-avatar';
avatar.src = img.src;
avatarCol.appendChild(avatar);
} else {
const ph = document.createElement('div');
ph.className = 'post-avatar-placeholder';
ph.textContent = username.slice(0, 2).toUpperCase();
avatarCol.appendChild(ph);
}
const contentCol = document.createElement('div');
contentCol.className = 'post-content';
contentCol.innerHTML = `<div class="post-header"><strong>${username}</strong></div>`;
let sib = p.nextElementSibling;
const nextP = idx < postStarts.length - 1 ? postStarts[idx + 1].p : null;
while (sib && sib !== nextP && sib.tagName !== 'HR') {
contentCol.appendChild(sib.cloneNode(true));
const rm = sib; sib = sib.nextElementSibling; rm.remove();
}
if (sib && sib.tagName === 'HR') sib.remove();
block.appendChild(avatarCol); block.appendChild(contentCol);
p.parentNode.insertBefore(block, p); p.remove();
});
})();
</script>
{% if not noai %}
<script>
window.UNCLOSEAI_LANGUAGE = "{{ lang }}";
window.UNCLOSEAI_SYSTEM_PROMPT = "You are a neopig assistant viewing an archived page from {{ source_domain }}. Page title: {{ title }}. neopig archived this - not the original content creator. Help users understand this archived page factually.";
</script>
<script src="https://uncloseai.com/uncloseai.js?v=3" type="module"></script>
{% endif %}
{% endblock %}