Compare commits

..

10 Commits

Author SHA1 Message Date
9664e945d5 chore: remove local artifacts
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions
2026-08-21 17:15:18 +00:00
jarianc
df9e37f7f1 Merge pull request 'CI: remove --no-cache for docker layer caching' (#1) from ci-fix-nocache into main
Reviewed-on: https://git.home.ms/jarianc/redhead/pulls/1
2026-07-04 22:24:08 -05:00
Jarian
d2ad449eb9 CI: remove --no-cache for docker layer caching 2026-07-05 03:14:30 +00:00
Jarian
65d9771a13 CI: add generalized workflow 2026-07-05 02:46:38 +00:00
Jarian Cottingham
7ada2278c6 Implement backend pre-caching optimization for faster load times with 300-article prefetching 2026-02-02 09:43:03 -06:00
Jarian Cottingham
432c162642 Clean up website design and add search functionality 2026-02-02 09:30:34 -06:00
Jarian Cottingham
6786f982f2 update git ignore 2026-02-02 09:08:46 -06:00
Jarian Cottingham
70ce394302 cleanup 2026-02-02 09:07:38 -06:00
Jarian Cottingham
eed0dd9ced Simplified project structure and fixed Docker configuration to properly point to archive directory 2026-02-02 09:05:56 -06:00
Jarian Cottingham
57ac07730e Simplified Docker setup with minimal configuration 2026-02-02 08:46:30 -06:00
18 changed files with 667 additions and 403 deletions

BIN
.DS_Store vendored

Binary file not shown.

143
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,143 @@
name: CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
env:
GITEA_URL: https://git.home.ms
jobs:
lint:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run ruff (Python lint)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
pip3 install ruff
ruff check .
else
echo "No Python project detected, skipping ruff"
fi
- name: Run npm lint (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run lint --if-present || true
else
echo "No Node.js project detected, skipping npm lint"
fi
test:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run pytest (Python)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
python3 -m pip install --upgrade pip
pip3 install -e ".[dev]" 2>/dev/null || pip3 install -e . 2>/dev/null || true
pip3 install pytest
pytest tests/ -v --tb=short 2>/dev/null || true
else
echo "No Python project detected, skipping pytest"
fi
- name: Run npm test (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm run test --if-present || true
else
echo "No Node.js project detected, skipping npm test"
fi
- name: Run Go tests
if: always()
run: |
if [[ -f go.mod ]]; then
go test ./...
else
echo "No Go project detected, skipping go test"
fi
docker-build:
runs-on: ubuntu-latest
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Build Docker image
if: always()
run: |
if [[ -f Dockerfile ]]; then
docker build -t $GITHUB_REPOSITORY:test .
else
echo "No Dockerfile found, skipping docker build"
fi
security:
runs-on: ubuntu-latest
container:
image: gitea-job-image
steps:
- name: Clone repo
run: |
rm -rf $GITHUB_WORKSPACE/*
git clone --depth 1 $GITEA_URL/$GITHUB_REPOSITORY $GITHUB_WORKSPACE
git -C $GITHUB_WORKSPACE checkout $GITHUB_SHA 2>/dev/null || true
- name: Run bandit (Python SAST)
if: always()
run: |
if [[ -f pyproject.toml ]]; then
pip3 install bandit
bandit -r . --severity-level high --confidence-level high --exclude tests/,test_*
else
echo "No Python project detected, skipping bandit"
fi
- name: Run npm audit (JS/TS)
if: always()
run: |
if [[ -f package.json ]]; then
npm ci
npm audit --audit-level=high 2>/dev/null || echo "npm audit: vulnerabilities found (non-blocking)"
else
echo "No Node.js project detected, skipping npm audit"
fi
build-result:
needs: [lint, test, docker-build, security]
runs-on: ubuntu-latest
container:
image: gitea-job-image
if: always()
steps:
- name: Summary
run: echo "All CI checks completed"

6
.gitignore vendored
View File

@ -66,4 +66,8 @@ cache/
# System files # System files
.DS_Store .DS_Store
._* ._*
venv/
bin/
share/

View File

@ -1,64 +1,22 @@
# Multi-stage Dockerfile for Red Head Python Backend # Simple Dockerfile for Red Head Python Backend
# Build stage
FROM python:3.9-slim as builder
# Set working directory
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Create a non-root user and group for building
RUN groupadd --gid 1001 builder && \
useradd --uid 1001 --gid builder --shell /bin/bash --create-home builder
# Switch to builder user for dependency installation
USER builder
WORKDIR /home/builder
# Copy requirements and install Python dependencies in a virtual environment
COPY src/server/requirements.txt .
RUN python -m venv /home/builder/venv && \
/home/builder/venv/bin/pip install --no-cache-dir -r requirements.txt
# Production stage
FROM python:3.9-slim FROM python:3.9-slim
# Create a non-root user and group
RUN groupadd --gid 1001 appuser && \
useradd --uid 1001 --gid appuser --shell /bin/bash --create-home appuser
# Set working directory # Set working directory
WORKDIR /app WORKDIR /app
# Copy Python dependencies from builder stage (virtual environment) # Copy requirements and install Python dependencies
COPY --from=builder /home/builder/venv /app/venv COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code # Copy application code and all files
COPY --chown=appuser:appuser src/server/ . COPY . .
# Copy website files
COPY --chown=appuser:appuser src/website/ website/
# Create directory for cache if it doesn't exist # Create directory for cache if it doesn't exist
RUN mkdir -p website/cache && \ RUN mkdir -p cache
chown -R appuser:appuser website/cache
# Expose port # Expose port
EXPOSE 6006 EXPOSE 6006
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
ENV USER=appuser
ENV PATH=/app/venv/bin:$PATH
# Switch to non-root user
USER appuser
# Health check # Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD curl -f http://localhost:6006/api || exit 1 CMD curl -f http://localhost:6006/api || exit 1

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,6 +1,8 @@
// Global variables // Global variables
let isLoading = false; let isLoading = false;
let currentPostCount = 0; let currentPostCount = 0;
let allPosts = []; // Store all posts for search functionality
let filteredPosts = []; // Store currently displayed posts
const POSTS_CACHE_KEY = "cached_posts_100"; const POSTS_CACHE_KEY = "cached_posts_100";
const CACHE_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes const CACHE_EXPIRY_TIME = 5 * 60 * 1000; // 5 minutes
@ -116,13 +118,36 @@ function createPostElement(post) {
return postElement; return postElement;
} }
// Function to update search results counter
function updateSearchResultsCount() {
const searchResultsElement = document.getElementById("search-results");
const searchInput = document.getElementById("search-input");
if (searchResultsElement && searchInput) {
const searchTerm = searchInput.value.trim().toLowerCase();
const count = searchTerm ? filteredPosts.length : allPosts.length;
if (searchResultsElement) {
searchResultsElement.textContent = searchTerm
? `${filteredPosts.length} post${filteredPosts.length !== 1 ? 's' : ''} found`
: `${allPosts.length} post${allPosts.length !== 1 ? 's' : ''}`;
}
}
}
function renderPosts(posts) { function renderPosts(posts) {
const container = document.getElementById("posts-container"); const container = document.getElementById("posts-container");
// Clear container
container.innerHTML = "";
posts.forEach((post) => { posts.forEach((post) => {
const postElement = createPostElement(post); const postElement = createPostElement(post);
container.appendChild(postElement); container.appendChild(postElement);
}); });
// Update search results counter
updateSearchResultsCount();
} }
// Cache management functions // Cache management functions
@ -166,6 +191,52 @@ function clearCachedPosts() {
} }
} }
// Search functionality
function performSearch() {
const searchInput = document.getElementById("search-input");
const searchTerm = searchInput.value.trim().toLowerCase();
if (searchTerm === "") {
// Show all posts if search is empty
filteredPosts = [...allPosts];
renderPosts(filteredPosts);
return;
}
// Filter posts by title (case-insensitive partial match)
filteredPosts = allPosts.filter(post =>
post.title &&
post.title.toLowerCase().includes(searchTerm)
);
renderPosts(filteredPosts);
}
// Initialize search functionality
function initializeSearch() {
const searchInput = document.getElementById("search-input");
if (searchInput) {
// Add debouncing to search for better performance
let searchTimeout;
searchInput.addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(performSearch, 300); // 300ms delay
});
// Add search results counter
const searchResults = document.createElement("div");
searchResults.id = "search-results";
searchResults.className = "search-results";
searchResults.textContent = "Loading posts...";
// Insert search results counter after the header
const header = document.querySelector(".header");
if (header) {
header.parentNode.insertBefore(searchResults, header.nextSibling);
}
}
}
// Extract real title from base_url // Extract real title from base_url
function extractRealTitleFromUrl(baseUrl) { function extractRealTitleFromUrl(baseUrl) {
if (!baseUrl || typeof baseUrl !== "string") { if (!baseUrl || typeof baseUrl !== "string") {
@ -236,7 +307,9 @@ async function loadInitialPosts() {
if (cachedPosts && cachedPosts.length > 0) { if (cachedPosts && cachedPosts.length > 0) {
console.log("Loading posts from cache"); console.log("Loading posts from cache");
renderPosts(cachedPosts); allPosts = cachedPosts;
filteredPosts = [...allPosts];
renderPosts(filteredPosts);
currentPostCount = cachedPosts.length; currentPostCount = cachedPosts.length;
// Check if we have more posts or not // Check if we have more posts or not
@ -253,7 +326,9 @@ async function loadInitialPosts() {
try { try {
console.log("Fetching posts from API"); console.log("Fetching posts from API");
const posts = await fetchRedditPosts(); const posts = await fetchRedditPosts();
renderPosts(posts); allPosts = posts;
filteredPosts = [...allPosts];
renderPosts(filteredPosts);
currentPostCount = posts.length; currentPostCount = posts.length;
// Cache the posts // Cache the posts
@ -284,7 +359,11 @@ async function loadMorePosts() {
const morePosts = await fetchMorePosts(currentPostCount); const morePosts = await fetchMorePosts(currentPostCount);
if (morePosts.length > 0) { if (morePosts.length > 0) {
renderPosts(morePosts); // Add new posts to allPosts and filteredPosts
allPosts = [...allPosts, ...morePosts];
filteredPosts = [...filteredPosts, ...morePosts];
renderPosts(filteredPosts);
currentPostCount += morePosts.length; currentPostCount += morePosts.length;
} else { } else {
// No more posts to load // No more posts to load
@ -329,6 +408,7 @@ function setupInfiniteScroll() {
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
loadInitialPosts(); loadInitialPosts();
setupInfiniteScroll(); setupInfiniteScroll();
initializeSearch();
// Add event listener to dark mode toggle button // Add event listener to dark mode toggle button
const darkModeToggle = document.getElementById("dark-mode-toggle"); const darkModeToggle = document.getElementById("dark-mode-toggle");

View File

@ -1,10 +1,12 @@
from flask import Flask, jsonify, request, send_from_directory from flask import Flask, jsonify, request, send_from_directory, send_file
from flask_cors import CORS
import os import os
import sys import logging
app = Flask(__name__) app = Flask(__name__)
CORS(app) # Enable CORS for all routes
# Set up logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Import our archive parser # Import our archive parser
from parse_archive import ArchiveParser from parse_archive import ArchiveParser
@ -12,51 +14,18 @@ from parse_archive import ArchiveParser
# Initialize the archive parser # Initialize the archive parser
archive_parser = ArchiveParser() archive_parser = ArchiveParser()
# Validate archive directory at startup # Serve static files from the root directory
def validate_archive_directory():
"""Validate that the archive directory exists and has content"""
archive_dir = os.environ.get('ARCHIVE_DIR', '/app/archive')
print(f"Checking archive directory: {archive_dir}")
if not os.path.exists(archive_dir):
print(f"ERROR: Archive directory does not exist: {archive_dir}")
return False
try:
# Try to list directories in archive
dirs = os.listdir(archive_dir)
if not dirs:
print(f"WARNING: Archive directory is empty: {archive_dir}")
else:
print(f"INFO: Archive directory contains {len(dirs)} items")
return True
except Exception as e:
print(f"ERROR: Cannot access archive directory {archive_dir}: {e}")
return False
# Validate at startup
if not validate_archive_directory():
print("FATAL: Archive directory validation failed. Application cannot start.")
sys.exit(1)
# Serve static files from the website directory
# The website directory is at the same level as the src directory
@app.route("/<path:filename>") @app.route("/<path:filename>")
def serve_static(filename): def serve_static(filename):
# In Docker, the structure is: /app/src/server/ and /app/website/ try:
# So we need to go up from src/server to /app/ and then to /app/website return send_from_directory(".", filename)
website_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "website") except FileNotFoundError:
return send_from_directory(website_dir, filename) return jsonify({"error": "File not found"}), 404
# Serve the main index.html page # Serve the main index.html page
@app.route("/") @app.route("/")
def serve_index(): def serve_index():
# In Docker, the structure is: /app/src/server/ and /app/website/ return send_file("index.html")
website_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "website")
return send_from_directory(website_dir, "index.html")
@app.route("/posts", methods=["GET"]) @app.route("/posts", methods=["GET"])
def get_initial_posts(): def get_initial_posts():
@ -73,7 +42,6 @@ def get_initial_posts():
print(f"Error: {e}") print(f"Error: {e}")
return jsonify({"error": "Failed to fetch posts"}), 500 return jsonify({"error": "Failed to fetch posts"}), 500
@app.route("/posts/more", methods=["GET"]) @app.route("/posts/more", methods=["GET"])
def get_more_posts(): def get_more_posts():
"""Get next 10 posts based on the count parameter""" """Get next 10 posts based on the count parameter"""
@ -89,10 +57,9 @@ def get_more_posts():
filters_posts += [p] filters_posts += [p]
return jsonify(filters_posts) return jsonify(filters_posts)
except Exception as e: except Exception as e:
print(f"Error: {e}") logger.error(f"Error fetching more posts: {e}")
return jsonify({"error": "Failed to fetch more posts"}), 500 return jsonify({"error": "Failed to fetch more posts"}), 500
@app.route("/posts/total", methods=["GET"]) @app.route("/posts/total", methods=["GET"])
def get_total_posts(): def get_total_posts():
"""Get the total number of posts available""" """Get the total number of posts available"""
@ -101,10 +68,9 @@ def get_total_posts():
total = archive_parser.get_total_posts() total = archive_parser.get_total_posts()
return jsonify({"total": total}) return jsonify({"total": total})
except Exception as e: except Exception as e:
print(f"Error: {e}") logger.error(f"Error fetching total posts: {e}")
return jsonify({"error": "Failed to fetch total count"}), 500 return jsonify({"error": "Failed to fetch total count"}), 500
@app.route("/api") @app.route("/api")
def home(): def home():
"""Home endpoint to verify server is running""" """Home endpoint to verify server is running"""
@ -116,9 +82,9 @@ def home():
"GET /posts/more?count=N": "Get next 10 posts, starting from index N", "GET /posts/more?count=N": "Get next 10 posts, starting from index N",
"GET /posts/total": "Get total number of posts", "GET /posts/total": "Get total number of posts",
}, },
"status": "healthy"
} }
) )
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True, host="0.0.0.0", port=6006) app.run(debug=True, host="0.0.0.0", port=6006)

View File

@ -10,10 +10,7 @@ services:
- ARCHIVE_DIR=/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/ - ARCHIVE_DIR=/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/
- FLASK_ENV=production - FLASK_ENV=production
volumes: volumes:
- ./src/server:/app/src/server - .:/app
- ./src/website:/app/website
- ./website/cache:/app/website/cache
- /app/src/server/__pycache__
# Mount the actual archive directory # Mount the actual archive directory
- /mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/:/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/ - /mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/:/mnt/centralstoragemedia/Websites/ArchiveBox/data/archive/
working_dir: /app working_dir: /app

View File

@ -0,0 +1,5 @@
{
"load_extensions": {
"jupyter-js-widgets/extension": true
}
}

View File

@ -0,0 +1,164 @@
/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */
/* Greenlet object interface */
#ifndef Py_GREENLETOBJECT_H
#define Py_GREENLETOBJECT_H
#include <Python.h>
#ifdef __cplusplus
extern "C" {
#endif
/* This is deprecated and undocumented. It does not change. */
#define GREENLET_VERSION "1.0.0"
#ifndef GREENLET_MODULE
#define implementation_ptr_t void*
#endif
typedef struct _greenlet {
PyObject_HEAD
PyObject* weakreflist;
PyObject* dict;
implementation_ptr_t pimpl;
} PyGreenlet;
#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type))
/* C API functions */
/* Total number of symbols that are exported */
#define PyGreenlet_API_pointers 12
#define PyGreenlet_Type_NUM 0
#define PyExc_GreenletError_NUM 1
#define PyExc_GreenletExit_NUM 2
#define PyGreenlet_New_NUM 3
#define PyGreenlet_GetCurrent_NUM 4
#define PyGreenlet_Throw_NUM 5
#define PyGreenlet_Switch_NUM 6
#define PyGreenlet_SetParent_NUM 7
#define PyGreenlet_MAIN_NUM 8
#define PyGreenlet_STARTED_NUM 9
#define PyGreenlet_ACTIVE_NUM 10
#define PyGreenlet_GET_PARENT_NUM 11
#ifndef GREENLET_MODULE
/* This section is used by modules that uses the greenlet C API */
static void** _PyGreenlet_API = NULL;
# define PyGreenlet_Type \
(*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM])
# define PyExc_GreenletError \
((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM])
# define PyExc_GreenletExit \
((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM])
/*
* PyGreenlet_New(PyObject *args)
*
* greenlet.greenlet(run, parent=None)
*/
# define PyGreenlet_New \
(*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \
_PyGreenlet_API[PyGreenlet_New_NUM])
/*
* PyGreenlet_GetCurrent(void)
*
* greenlet.getcurrent()
*/
# define PyGreenlet_GetCurrent \
(*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM])
/*
* PyGreenlet_Throw(
* PyGreenlet *greenlet,
* PyObject *typ,
* PyObject *val,
* PyObject *tb)
*
* g.throw(...)
*/
# define PyGreenlet_Throw \
(*(PyObject * (*)(PyGreenlet * self, \
PyObject * typ, \
PyObject * val, \
PyObject * tb)) \
_PyGreenlet_API[PyGreenlet_Throw_NUM])
/*
* PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args)
*
* g.switch(*args, **kwargs)
*/
# define PyGreenlet_Switch \
(*(PyObject * \
(*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \
_PyGreenlet_API[PyGreenlet_Switch_NUM])
/*
* PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent)
*
* g.parent = new_parent
*/
# define PyGreenlet_SetParent \
(*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \
_PyGreenlet_API[PyGreenlet_SetParent_NUM])
/*
* PyGreenlet_GetParent(PyObject* greenlet)
*
* return greenlet.parent;
*
* This could return NULL even if there is no exception active.
* If it does not return NULL, you are responsible for decrementing the
* reference count.
*/
# define PyGreenlet_GetParent \
(*(PyGreenlet* (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_GET_PARENT_NUM])
/*
* deprecated, undocumented alias.
*/
# define PyGreenlet_GET_PARENT PyGreenlet_GetParent
# define PyGreenlet_MAIN \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_MAIN_NUM])
# define PyGreenlet_STARTED \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_STARTED_NUM])
# define PyGreenlet_ACTIVE \
(*(int (*)(PyGreenlet*)) \
_PyGreenlet_API[PyGreenlet_ACTIVE_NUM])
/* Macro that imports greenlet and initializes C API */
/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we
keep the older definition to be sure older code that might have a copy of
the header still works. */
# define PyGreenlet_Import() \
{ \
_PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \
}
#endif /* GREENLET_MODULE */
#ifdef __cplusplus
}
#endif
#endif /* !Py_GREENLETOBJECT_H */

View File

@ -10,6 +10,15 @@
<div class="container"> <div class="container">
<header class="header"> <header class="header">
<h1>Red Head</h1> <h1>Red Head</h1>
<div class="search-container">
<span class="search-icon">🔍</span>
<input
type="text"
id="search-input"
placeholder="Search posts by title..."
autocomplete="off"
/>
</div>
<button <button
id="dark-mode-toggle" id="dark-mode-toggle"
class="dark-mode-toggle" class="dark-mode-toggle"

View File

@ -5,6 +5,8 @@ import urllib.parse
import uuid import uuid
from io import BytesIO from io import BytesIO
from typing import Any, Dict, List from typing import Any, Dict, List
from concurrent.futures import ThreadPoolExecutor
import threading
import requests import requests
from PIL import Image from PIL import Image
@ -14,6 +16,10 @@ class ArchiveParser:
def __init__(self): def __init__(self):
"""Initialize the ArchiveParser with archive_dir from environment variable.""" """Initialize the ArchiveParser with archive_dir from environment variable."""
self.archive_dir = os.environ.get("ARCHIVE_DIR", "/default/archive/path") self.archive_dir = os.environ.get("ARCHIVE_DIR", "/default/archive/path")
self.cache = {}
self.cache_lock = threading.Lock()
self.pre_cache_size = 300 # Pre-cache 300 articles ahead
self.cache_executor = ThreadPoolExecutor(max_workers=2) # For background caching
def get_posts(self, count: int, start_index: int = 0) -> List[Dict[str, Any]]: def get_posts(self, count: int, start_index: int = 0) -> List[Dict[str, Any]]:
""" """
@ -31,6 +37,13 @@ class ArchiveParser:
List[Dict[str, Any]]: List of post dictionaries List[Dict[str, Any]]: List of post dictionaries
""" """
try: try:
# Check if we have cached posts for this range
cache_key = f"posts_{start_index}_{count}"
with self.cache_lock:
if cache_key in self.cache:
print(f"Cache hit for {cache_key}")
return self.cache[cache_key]
# Use ls with tail and head commands for efficient pagination # Use ls with tail and head commands for efficient pagination
# Get all directories, skip the first start_index, then get count number of entries # Get all directories, skip the first start_index, then get count number of entries
cmd = f"ls -1 {self.archive_dir} | tail -{start_index + count + 1} | head -{count}" cmd = f"ls -1 {self.archive_dir} | tail -{start_index + count + 1} | head -{count}"
@ -52,6 +65,15 @@ class ArchiveParser:
# Convert extracted posts to final format with proper IDs # Convert extracted posts to final format with proper IDs
formatted_posts = [self._create_post(post) for post in posts] formatted_posts = [self._create_post(post) for post in posts]
# Cache the results
with self.cache_lock:
self.cache[cache_key] = formatted_posts
# Pre-cache the next batch in background
if start_index + count < self.get_total_posts():
self._pre_cache_batch(start_index + count)
return formatted_posts return formatted_posts
except subprocess.CalledProcessError as e: except subprocess.CalledProcessError as e:
@ -119,6 +141,24 @@ class ArchiveParser:
print(f"Error getting total posts: {e}") print(f"Error getting total posts: {e}")
return 0 return 0
def _pre_cache_batch(self, start_index: int):
"""Pre-cache the next batch of posts in the background."""
def cache_task():
try:
# Pre-cache next 300 posts
next_posts = self.get_posts(self.pre_cache_size, start_index)
print(f"Pre-cached {len(next_posts)} posts starting from index {start_index}")
except Exception as e:
print(f"Error in pre-caching: {e}")
# Submit to background thread
self.cache_executor.submit(cache_task)
def clear_cache(self):
"""Clear the cache."""
with self.cache_lock:
self.cache.clear()
def _extract_real_title_from_url(self, base_url: str) -> str: def _extract_real_title_from_url(self, base_url: str) -> str:
"""Extract the real title from base_url when title is 'Reddit - Prove your humanity'.""" """Extract the real title from base_url when title is 'Reddit - Prove your humanity'."""
if not base_url or not isinstance(base_url, str): if not base_url or not isinstance(base_url, str):
@ -219,4 +259,4 @@ class ArchiveParser:
except Exception as e: except Exception as e:
print(f"Error converting index to post: {e}") print(f"Error converting index to post: {e}")
# Return a minimal post if there's an error # Return a minimal post if there's an error
raise e raise e

5
pyvenv.cfg Normal file
View File

@ -0,0 +1,5 @@
home = /opt/homebrew/opt/python@3.13/bin
include-system-site-packages = false
version = 3.13.7
executable = /opt/homebrew/Cellar/python@3.13/3.13.7/Frameworks/Python.framework/Versions/3.13/bin/python3.13
command = /opt/homebrew/opt/python@3.13/bin/python3.13 -m venv /Users/jariancottingham/Projects/redhead

View File

@ -2,4 +2,4 @@ Flask
Flask-CORS Flask-CORS
Pillow Pillow
beautifulsoup4 beautifulsoup4
requests requests

View File

@ -1,99 +0,0 @@
# red head API Server
A Python Flask server that serves data for the red head application.
## Endpoints
- `GET /posts` - Get first 10 posts for initial load
- `GET /posts/more?count=N` - Get next 10 posts, starting from index N
- `GET /posts/total` - Get total number of posts available
- `GET /` - Server status endpoint
## Setup Instructions
1. Install Python 3 if you don't have it already
2. Navigate to the server directory:
```bash
cd /Users/jariancottingham/Projects/redhead/src/server
```
3. Create a virtual environment (recommended):
```bash
python -m venv venv
source venv/bin/activate # On Windows use: venv\Scripts\activate
```
4. Install dependencies:
```bash
pip install -r requirements.txt
```
5. Run the server:
```bash
python app.py
```
6. The server will be available at http://localhost:5000
## Data Format
The server returns JSON data in the same format as used by the frontend:
```json
[
{
"id": "1",
"title": "This is a sample Reddit post title",
"url": "https://example.com/sample-post",
"image": "https://picsum.photos/400/300?random=1"
}
]
```
## Usage with Frontend
Update the frontend's `fetchRedditPosts()` and `fetchMorePosts()` functions to call your server endpoints instead of using mock data:
```javascript
// Replace URLs with your server endpoints
function fetchRedditPosts() {
return fetch('/posts')
.then(response => response.json())
}
function fetchMorePosts(currentCount) {
return fetch(`/posts/more?count=${currentCount}`)
.then(response => response.json())
}
```
## Example Data
The sample data is stored in `sample_posts.json`. You can replace this with your own data, or connect to a real Reddit API.
To add your own posts:
1. Replace the content of `sample_posts.json` with your data
2. Each post should follow this structure:
```json
{
"id": "post_id",
"title": "Post title",
"url": "https://example.com/post-url",
"image": "https://example.com/image-url"
}
```
## Running the Server
After installation, run the server with:
```bash
python app.py
```
The server will start on port 5000 and listen for connections from localhost.
## Testing Endpoints
You can test the endpoints directly using curl or a browser:
- `http://localhost:5000/posts` - Get initial posts
- `http://localhost:5000/posts/more?count=10` - Get more posts starting from index 10
- `http://localhost:5000/posts/total` - Get total post count

View File

@ -1,122 +0,0 @@
[
{
"id": "1",
"title": "This is a sample Reddit post title",
"url": "https://example.com/sample-post",
"image": "https://picsum.photos/400/300?random=1"
},
{
"id": "2",
"title": "Another interesting Reddit post about technology",
"url": "https://example.com/tech-post",
"image": "https://picsum.photos/400/300?random=2"
},
{
"id": "3",
"title": "Amazing landscape photography from nature",
"url": "https://example.com/nature-photography",
"image": "https://picsum.photos/400/300?random=3"
},
{
"id": "4",
"title": "How to build a React application from scratch",
"url": "https://example.com/react-guide",
"image": "https://picsum.photos/400/300?random=4"
},
{
"id": "5",
"title": "The future of artificial intelligence in 2023",
"url": "https://example.com/ai-future",
"image": "https://picsum.photos/400/300?random=5"
},
{
"id": "6",
"title": "Delicious recipes for vegetarian meals",
"url": "https://example.com/vegetarian-recipes",
"image": "https://picsum.photos/400/300?random=6"
},
{
"id": "7",
"title": "Top programming languages to learn in 2023",
"url": "https://example.com/programming-languages",
"image": "https://picsum.photos/400/300?random=7"
},
{
"id": "8",
"title": "Space exploration updates from NASA",
"url": "https://example.com/space-exploration",
"image": "https://picsum.photos/400/300?random=8"
},
{
"id": "9",
"title": "Tips for improving your sleep quality",
"url": "https://example.com/sleep-tips",
"image": "https://picsum.photos/400/300?random=9"
},
{
"id": "10",
"title": "Latest trends in web development",
"url": "https://example.com/web-development-trends",
"image": "https://picsum.photos/400/300?random=10"
},
{
"id": "11",
"title": "Understanding blockchain technology and its applications",
"url": "https://example.com/blockchain-guide",
"image": "https://picsum.photos/400/300?random=11"
},
{
"id": "12",
"title": "The health benefits of regular exercise",
"url": "https://example.com/health-exercise",
"image": "https://picsum.photos/400/300?random=12"
},
{
"id": "13",
"title": "Exploring the world's most ancient civilizations",
"url": "https://example.com/ancient-civilizations",
"image": "https://picsum.photos/400/300?random=13"
},
{
"id": "14",
"title": "Sustainable living practices for modern homes",
"url": "https://example.com/sustainable-living",
"image": "https://picsum.photos/400/300?random=14"
},
{
"id": "15",
"title": "The art of coffee brewing: A beginner's guide",
"url": "https://example.com/coffee-brewing",
"image": "https://picsum.photos/400/300?random=15"
},
{
"id": "16",
"title": "Mental health awareness and self-care tips",
"url": "https://example.com/mental-health",
"image": "https://picsum.photos/400/300?random=16"
},
{
"id": "17",
"title": "The rise of renewable energy sources worldwide",
"url": "https://example.com/renewable-energy",
"image": "https://picsum.photos/400/300?random=17"
},
{
"id": "18",
"title": "Travel photography techniques for stunning landscapes",
"url": "https://example.com/travel-photography",
"image": "https://picsum.photos/400/300?random=18"
},
{
"id": "19",
"title": "Building meaningful relationships in the digital age",
"url": "https://example.com/digital-relationships",
"image": "https://picsum.photos/400/300?random=19"
},
{
"id": "20",
"title": "The impact of social media on modern society",
"url": "https://example.com/social-media-impact",
"image": "https://picsum.photos/400/300?random=20"
}
]

View File

@ -1,22 +0,0 @@
import os
import tempfile
import json
from parse_archive import ArchiveParser
def test1():
parser = ArchiveParser()
posts = parser.get_posts(10)
for p in posts:
print(p)
def test2():
parser = ArchiveParser()
posts = parser.get_posts(10, 10)
for p in posts:
print(p)
if __name__ == "__main__":
test1()

View File

@ -15,6 +15,7 @@ body {
transition: transition:
background-color 0.3s, background-color 0.3s,
color 0.3s; color 0.3s;
min-height: 100vh;
} }
body.dark-mode { body.dark-mode {
@ -30,7 +31,7 @@ body.dark-mode {
.header { .header {
background-color: white; background-color: white;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
padding: 15px 0; padding: 15px 0;
position: sticky; position: sticky;
top: 0; top: 0;
@ -38,23 +39,73 @@ body.dark-mode {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
gap: 15px;
flex-wrap: wrap;
} }
.header.dark-mode { .header.dark-mode {
background-color: #2d2d2d; background-color: #2d2d2d;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
} }
.header h1 { .header h1 {
text-align: center; font-size: 2rem;
font-size: 1.8rem;
color: #ff4500; color: #ff4500;
margin: 0;
flex-grow: 0;
flex-shrink: 0;
font-weight: 700;
} }
.header.dark-mode h1 { .header.dark-mode h1 {
color: #ff6b35; color: #ff6b35;
} }
/* Search container */
.search-container {
display: flex;
align-items: center;
gap: 10px;
flex-grow: 1;
max-width: 500px;
}
.search-container input {
flex-grow: 1;
padding: 10px 15px;
border: 1px solid #ddd;
border-radius: 20px;
font-size: 1rem;
background-color: #f8f8f8;
transition: all 0.3s ease;
}
.search-container input.dark-mode {
background-color: #3a3a3a;
border-color: #444;
color: #e0e0e0;
}
.search-container input:focus {
outline: none;
border-color: #ff4500;
box-shadow: 0 0 0 2px rgba(255, 69, 0, 0.2);
}
.search-container input.dark-mode:focus {
border-color: #ff6b35;
box-shadow: 0 0 0 2px rgba(255, 107, 53, 0.2);
}
.search-icon {
color: #999;
font-size: 1.2rem;
}
.search-container .search-icon.dark-mode {
color: #aaa;
}
.content { .content {
padding: 20px 0; padding: 20px 0;
} }
@ -68,45 +119,49 @@ body.dark-mode {
/* New rectangular post card design */ /* New rectangular post card design */
.post-card { .post-card {
background-color: white; background-color: white;
border-radius: 4px; border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden; overflow: hidden;
transition: transition:
transform 0.2s, transform 0.3s ease,
box-shadow 0.2s; box-shadow 0.3s ease;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100px; min-height: 120px;
border: 1px solid #eee;
} }
.post-card.dark-mode { .post-card.dark-mode {
background-color: #2d2d2d; background-color: #2d2d2d;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
border-color: #444;
} }
.post-card:hover { .post-card:hover {
transform: translateY(-1px); transform: translateY(-3px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
} }
.post-card.dark-mode:hover { .post-card.dark-mode:hover {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.5); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
.post-content { .post-content {
padding: 12px; padding: 16px;
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.post-title { .post-title {
font-size: 1.1rem; font-size: 1.2rem;
margin-bottom: 8px; margin-bottom: 12px;
color: #000; color: #000;
text-decoration: none; text-decoration: none;
display: block; display: block;
flex-grow: 1; flex-grow: 1;
font-weight: 600;
line-height: 1.4;
} }
.post-title.dark-mode { .post-title.dark-mode {
@ -115,6 +170,7 @@ body.dark-mode {
.post-title:hover { .post-title:hover {
color: #ff4500; color: #ff4500;
text-decoration: underline;
} }
.post-title.dark-mode:hover { .post-title.dark-mode:hover {
@ -126,23 +182,25 @@ body.dark-mode {
background-color: #f0f0f0; background-color: #f0f0f0;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
width: 40px; width: 44px;
height: 40px; height: 44px;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: background-color 0.3s; transition: all 0.3s ease;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
flex-shrink: 0;
} }
.dark-mode-toggle.dark-mode { .dark-mode-toggle.dark-mode {
background-color: #404040; background-color: #404040;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
} }
.dark-mode-toggle:hover { .dark-mode-toggle:hover {
background-color: #e0e0e0; background-color: #e0e0e0;
transform: scale(1.1);
} }
.dark-mode-toggle.dark-mode:hover { .dark-mode-toggle.dark-mode:hover {
@ -150,14 +208,82 @@ body.dark-mode {
} }
.dark-mode-icon { .dark-mode-icon {
font-size: 1.2rem; font-size: 1.3rem;
transition: transform 0.3s; transition: transform 0.3s ease;
} }
.dark-mode-toggle.dark-mode .dark-mode-icon { .dark-mode-toggle.dark-mode .dark-mode-icon {
transform: rotate(180deg); transform: rotate(180deg);
} }
/* Post buttons */
.post-buttons {
display: flex;
gap: 8px;
padding: 0 16px 16px;
flex-wrap: wrap;
align-self: flex-start;
margin-top: 8px;
}
.post-button {
background-color: #ff4500;
color: white;
text-decoration: none;
padding: 8px 12px;
border-radius: 6px;
font-size: 0.9rem;
transition: all 0.2s ease;
border: none;
cursor: pointer;
flex-shrink: 0;
font-weight: 500;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.post-button.dark-mode {
background-color: #ff6b35;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.post-button:hover {
background-color: #e03d00;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
}
.post-button.dark-mode:hover {
background-color: #ff7b45;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}
/* Search results counter */
.search-results {
margin: 15px 0;
font-size: 1.1rem;
font-weight: 500;
color: #666;
}
.search-results.dark-mode {
color: #aaa;
}
/* Loading and no more messages */
.loading,
.no-more {
text-align: center;
padding: 25px;
font-size: 1.2rem;
color: #666;
font-weight: 500;
}
.loading.dark-mode,
.no-more.dark-mode {
color: #aaa;
}
.post-image-container { .post-image-container {
width: 100%; width: 100%;
height: 130px; height: 130px;
@ -232,11 +358,11 @@ body.dark-mode {
.post-content { .post-content {
max-height: 15vh; /* About 1/6 screen height for wide displays */ max-height: 15vh; /* About 1/6 screen height for wide displays */
overflow-y: auto; overflow-y: auto;
padding: 12px; padding: 16px;
} }
.post-card { .post-card {
min-height: 100px; min-height: 120px;
} }
} }
@ -247,25 +373,53 @@ body.dark-mode {
} }
.header { .header {
padding: 10px 0; padding: 12px 0;
gap: 10px;
} }
.header h1 { .header h1 {
font-size: 1.5rem; font-size: 1.6rem;
}
.search-container {
max-width: 100%;
width: 100%;
} }
.post-content { .post-content {
padding: 10px; padding: 12px;
}
.post-title {
font-size: 1.1rem;
margin-bottom: 10px;
}
.post-buttons {
padding: 0 12px 12px;
}
.post-button {
padding: 6px 10px;
font-size: 0.8rem;
}
}
@media (max-width: 480px) {
.header h1 {
font-size: 1.4rem;
} }
.post-title { .post-title {
font-size: 1rem; font-size: 1rem;
margin-bottom: 6px;
} }
.post-image-container { .post-card {
height: 100px; min-height: 100px;
margin-bottom: 6px; }
.post-content {
padding: 10px;
} }
.post-buttons { .post-buttons {
@ -278,24 +432,6 @@ body.dark-mode {
} }
} }
@media (max-width: 480px) {
.post-image-container {
height: 80px;
}
.header h1 {
font-size: 1.3rem;
}
.post-title {
font-size: 0.9rem;
}
.post-card {
min-height: 90px;
}
}
/* Loading and no more messages */ /* Loading and no more messages */
.loading, .loading,
.no-more { .no-more {