def _save_content(self, work_id: str, content: str, format: str): """Save work content in specified format""" work_path = self.work_dir / work_id if format == "html": output_file = work_path / "work.html" elif format == "txt": output_file = work_path / "work.txt" elif format == "epub": output_file = work_path / "work.epub" else: raise ValueError(f"Unsupported format: {format}") with open(output_file, 'w', encoding='utf-8') as f: f.write(content) <!-- templates/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>AO3 Mirror Tool</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 20px; } .container { max-width: 1200px; margin: 0 auto; } .card { background: white; border-radius: 15px; padding: 30px; margin-bottom: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.2); } h1 { color: #333; margin-bottom: 10px; } .subtitle { color: #666; margin-bottom: 30px; } .input-group { margin-bottom: 20px; } label { display: block; margin-bottom: 8px; color: #555; font-weight: 500; } input, select { width: 100%; padding: 12px; border: 2px solid #e0e0e0; border-radius: 8px; font-size: 16px; transition: border-color 0.3s; } input:focus { outline: none; border-color: #667eea; } button { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; border: none; padding: 12px 24px; border-radius: 8px; font-size: 16px; cursor: pointer; transition: transform 0.2s; } button:hover { transform: translateY(-2px); } .queue-item { background: #f8f9fa; border-left: 4px solid #667eea; padding: 15px; margin-bottom: 10px; border-radius: 8px; } .status { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; } .status.pending { background: #fff3cd; color: #856404; } .status.completed { background: #d4edda; color: #155724; } .status.failed { background: #f8d7da; color: #721c24; } .progress-bar { width: 100%; height: 6px; background: #e0e0e0; border-radius: 3px; overflow: hidden; margin-top: 10px; } .progress-fill { height: 100%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); transition: width 0.3s; } .library-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; margin-top: 20px; } .work-card { background: #f8f9fa; border-radius: 10px; padding: 15px; cursor: pointer; transition: transform 0.2s, box-shadow 0.2s; } .work-card:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0,0,0,0.1); } .work-title { font-weight: 600; color: #333; margin-bottom: 8px; } .work-author { color: #666; font-size: 14px; margin-bottom: 8px; } .work-stats { display: flex; gap: 15px; font-size: 12px; color: #888; } .modal { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); justify-content: center; align-items: center; z-index: 1000; } .modal-content { background: white; border-radius: 15px; padding: 30px; max-width: 800px; max-height: 80vh; overflow-y: auto; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .spinner { border: 3px solid #f3f3f3; border-top: 3px solid #667eea; border-radius: 50%; width: 40px; height: 40px; animation: spin 1s linear infinite; margin: 20px auto; } </style> </head> <body> <div class="container"> <div class="card"> <h1>📚 AO3 Mirror Tool</h1> <p class="subtitle">Archive works for offline reading with full metadata preservation</p>
return jsonify(works) @app.route('/api/read/<work_id>', methods=['GET']) def read_work(work_id): work_path = mirror.work_dir / work_id
async def _fetch_work(self, url: str) -> Dict: """Fetch work from AO3 with proper headers and rate limiting""" # Use aiohttp with proper user agent # Parse HTML using BeautifulSoup # Extract metadata and content pass
class AO3Mirror: def (self, cache_dir: str = "ao3_cache"): self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(exist_ok=True) self.work_dir = self.cache_dir / "works" self.work_dir.mkdir(exist_ok=True) ao3 mirror
async def respectful_fetch(self, url): """Fetch with proper rate limiting and headers""" await self._rate_limit() headers = { 'User-Agent': self.USER_AGENT, 'Accept': 'text/html,application/xhtml+xml', } # Implementation...
if format == 'epub': file_path = work_path / 'work.epub' mime_type = 'application/epub+zip' elif format == 'txt': file_path = work_path / 'work.txt' mime_type = 'text/plain' else: file_path = work_path / 'work.html' mime_type = 'text/html'
<div class="input-group"> <label>AO3 URL</label> <input type="text" id="urlInput" placeholder="https://archiveofourown.org/works/12345678"> </div> <div class="input-group"> <label>Format</label> <select id="formatSelect"> <option value="html">HTML (Original)</option> <option value="txt">Plain Text</option> <option value="epub">EPUB</option> </select> </div> <button onclick="mirrorWork()">Mirror Work</button> <button onclick="mirrorSeries()" style="margin-left: 10px;">Mirror Series</button> </div> <div class="card"> <h2>📥 Download Queue</h2> <div id="queue"></div> </div> <div class="card"> <h2>📖 Mirrored Library</h2> <div id="library" class="library-grid"></div> </div> </div> encoding='utf-8') as f: f.write(content) <
def _save_metadata(self, work_id: str, metadata: WorkMetadata): """Save work metadata as JSON""" work_path = self.work_dir / work_id work_path.mkdir(exist_ok=True) metadata_file = work_path / "metadata.json" with open(metadata_file, 'w', encoding='utf-8') as f: json.dump(asdict(metadata), f, indent=2, ensure_ascii=False)
<script> let queue = []; async function mirrorWork() { const url = document.getElementById('urlInput').value; const format = document.getElementById('formatSelect').value; if (!url) { alert('Please enter an AO3 URL'); return; } addToQueue(url, 'work', format); await processQueue(); } async function mirrorSeries() { const url = document.getElementById('urlInput').value; const format = document.getElementById('formatSelect').value; if (!url) { alert('Please enter an AO3 series URL'); return; } addToQueue(url, 'series', format); await processQueue(); } function addToQueue(url, type, format) { queue.push({ id: Date.now(), url: url, type: type, format: format, status: 'pending' }); updateQueueDisplay(); } async function processQueue() { while (queue.length > 0) { const item = queue[0]; if (item.status === 'processing') break; item.status = 'processing'; updateQueueDisplay(); try { const response = await fetch('/api/mirror', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item) }); const result = await response.json(); if (result.status === 'success') { item.status = 'completed'; loadLibrary(); } else { item.status = 'failed'; item.error = result.error; } } catch (error) { item.status = 'failed'; item.error = error.message; } updateQueueDisplay(); queue.shift(); await new Promise(resolve => setTimeout(resolve, 1000)); } } function updateQueueDisplay() { const queueDiv = document.getElementById('queue'); if (queue.length === 0) { queueDiv.innerHTML = '<p style="color: #888;">No active downloads</p>'; return; } queueDiv.innerHTML = queue.map(item => ` <div class="queue-item"> <strong>${item.url}</strong> <span class="status ${item.status}">${item.status}</span> ${item.error ? `<div style="color: red; font-size: 12px; margin-top: 5px;">${item.error}</div>` : ''} </div> `).join(''); } async function loadLibrary() { const response = await fetch('/api/library'); const works = await response.json(); const libraryDiv = document.getElementById('library'); if (works.length === 0) { libraryDiv.innerHTML = '<p style="color: #888;">No mirrored works yet</p>'; return; } libraryDiv.innerHTML = works.map(work => ` <div class="work-card" onclick="readWork('${work.work_id}')"> <div class="work-title">${escapeHtml(work.title)}</div> <div class="work-author">by ${escapeHtml(work.author)}</div> <div class="work-stats"> <span>📄 ${work.word_count.toLocaleString()} words</span> <span>📖 ${work.chapters} chapters</span> <span>❤️ ${work.kudos}</span> </div> </div> `).join(''); } async function readWork(workId) { const response = await fetch(`/api/read/${workId}`); const data = await response.json(); const modal = document.getElementById('readerModal'); const content = document.getElementById('readerContent'); content.innerHTML = ` <h2>${escapeHtml(data.metadata.title)}</h2> <p><strong>by ${escapeHtml(data.metadata.author)}</strong></p> <div style="margin: 20px 0;">${data.content}</div> `; modal.style.display = 'flex'; } function closeModal() { document.getElementById('readerModal').style.display = 'none'; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Load library on page load loadLibrary(); </script> </body> </html> # api.py from flask import Flask, request, jsonify, send_file from flask_cors import CORS import asyncio import json from pathlib import Path app = Flask( name ) CORS(app)
return jsonify(result) @app.route('/api/library', methods=['GET']) def get_library(): works = [] work_dir = mirror.work_dir !-- templates/index.html -->
async def mirror_work(self, work_url: str, format: str = "html") -> Dict: """Mirror a single work from AO3""" work_id = self._extract_work_id(work_url) # Check if already mirrored if self._is_mirrored(work_id): return {"status": "exists", "work_id": work_id} # Fetch work data work_data = await self._fetch_work(work_url) # Save metadata self._save_metadata(work_id, work_data['metadata']) # Save content self._save_content(work_id, work_data['content'], format) return {"status": "success", "work_id": work_id}
mirror = AO3Mirror()
def _is_mirrored(self, work_id: str) -> bool: """Check if work is already mirrored""" return (self.work_dir / work_id / "metadata.json").exists()
def _extract_work_id(self, url: str) -> str: """Extract work ID from AO3 URL""" import re match = re.search(r'/works/(\d+)', url) if match: return match.group(1) raise ValueError("Invalid AO3 work URL")