mirror of
https://github.com/wahyd4/links.git
synced 2026-08-08 21:04:53 +10:00
Add logic to display thumbnails
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
# Install Chrome and dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
wget \
|
||||||
|
gnupg \
|
||||||
|
chromium \
|
||||||
|
chromium-driver \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE 1
|
||||||
|
ENV PYTHONUNBUFFERED 1
|
||||||
|
|
||||||
|
# Set work directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy project
|
||||||
|
COPY . .
|
||||||
@@ -180,3 +180,34 @@ Flower provides:
|
|||||||
- Runtime
|
- Runtime
|
||||||
- Result
|
- Result
|
||||||
- Stack traces for failed tasks
|
- Stack traces for failed tasks
|
||||||
|
|
||||||
|
|
||||||
|
## Why we have `core` folder and what it is?
|
||||||
|
|
||||||
|
The core folder is the Django project's root configuration directory, created when you first run django-admin startproject core. It serves several essential purposes:
|
||||||
|
Project-wide settings (settings.py):
|
||||||
|
Database configuration
|
||||||
|
Installed apps
|
||||||
|
Middleware
|
||||||
|
Static/media files settings
|
||||||
|
Other global settings
|
||||||
|
Root URL configuration (urls.py):
|
||||||
|
Main URL routing
|
||||||
|
Admin site URLs
|
||||||
|
Including app-specific URLs
|
||||||
|
3. WSGI/ASGI configuration:
|
||||||
|
For production deployment
|
||||||
|
Server integration
|
||||||
|
Celery configuration (celery.py):
|
||||||
|
Project-wide Celery settings
|
||||||
|
Task discovery
|
||||||
|
We can't completely remove it because Django requires this project-level configuration. The links folder is an app within the project, and you could have multiple apps in one project.
|
||||||
|
Think of it this way:
|
||||||
|
core/ = Project configuration (can contain multiple apps)
|
||||||
|
links/ = One specific app's code
|
||||||
|
This separation allows you to:
|
||||||
|
1. Have multiple apps in one project
|
||||||
|
Reuse apps across different projects
|
||||||
|
Keep project-wide settings separate from app-specific code
|
||||||
|
If you want to simplify, you could rename core to something shorter like config or core, but you can't eliminate it entirely as it's a fundamental part of Django's project structure.
|
||||||
|
Would you like me to show you how to rename it to something shorter and cleaner?
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
set -eu
|
set -eu
|
||||||
source 3.12/bin/activate
|
source 3.12/bin/activate
|
||||||
echo "Starting Django development server..."
|
echo "Starting Django development server..."
|
||||||
celery -A url_manager beat -l info
|
celery -A core beat -l info
|
||||||
|
|||||||
Binary file not shown.
@@ -1,8 +1,8 @@
|
|||||||
import os
|
import os
|
||||||
from celery import Celery
|
from celery import Celery
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'url_manager.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
app = Celery('url_manager')
|
app = Celery('core')
|
||||||
app.config_from_object('django.conf:settings', namespace='CELERY')
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
app.autodiscover_tasks()
|
app.autodiscover_tasks()
|
||||||
@@ -22,12 +22,12 @@ INSTALLED_APPS = [
|
|||||||
'markdown', # 只需要基本的markdown包
|
'markdown', # 只需要基本的markdown包
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'url_manager.urls'
|
ROOT_URLCONF = 'core.urls'
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'url_manager.middleware.CustomLocaleMiddleware', # 替换原来的 LocaleMiddleware
|
'core.middleware.CustomLocaleMiddleware', # 替换原来的 LocaleMiddleware
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
@@ -97,7 +97,7 @@ USE_TZ = True
|
|||||||
|
|
||||||
# ... 其他设置 ...
|
# ... 其他设置 ...
|
||||||
|
|
||||||
ROOT_URLCONF = 'url_manager.urls'
|
ROOT_URLCONF = 'core.urls'
|
||||||
|
|
||||||
# ... 其他设置 ...
|
# ... 其他设置 ...
|
||||||
|
|
||||||
@@ -176,7 +176,7 @@ LOCALE_INDEPENDENT_PATHS = [
|
|||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'url_manager.middleware.CustomLocaleMiddleware', # 使用自定义中间件
|
'core.middleware.CustomLocaleMiddleware', # 使用自定义中间件
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
@@ -212,3 +212,10 @@ CELERY_BEAT_SCHEDULE = {
|
|||||||
'schedule': 60.0, # 每60秒运行一次
|
'schedule': 60.0, # 每60秒运行一次
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Media files configuration
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
|
||||||
|
|
||||||
|
# Ensure media files are served in all environments
|
||||||
|
SERVE_MEDIA = True
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path, include
|
from django.urls import path, include, re_path
|
||||||
from django.conf.urls.i18n import i18n_patterns
|
from django.conf.urls.i18n import i18n_patterns
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from links.views import LinkDetailView, LinkUpdateView, CustomLinkView
|
from links.views import LinkDetailView, LinkUpdateView, CustomLinkView
|
||||||
|
from django.views.static import serve
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('i18n/', include('django.conf.urls.i18n')),
|
path('i18n/', include('django.conf.urls.i18n')),
|
||||||
@@ -11,8 +12,11 @@ urlpatterns = [
|
|||||||
path('link/<int:pk>/', LinkDetailView.as_view(), name='link_detail'),
|
path('link/<int:pk>/', LinkDetailView.as_view(), name='link_detail'),
|
||||||
path('link/<int:pk>/edit/', LinkUpdateView.as_view(), name='link_update'),
|
path('link/<int:pk>/edit/', LinkUpdateView.as_view(), name='link_update'),
|
||||||
path('custom/<slug:alias>/', CustomLinkView.as_view(), name='custom_link'),
|
path('custom/<slug:alias>/', CustomLinkView.as_view(), name='custom_link'),
|
||||||
|
# Serve media files directly
|
||||||
|
re_path(r'^media/(?P<path>.*)$', serve, {
|
||||||
|
'document_root': settings.MEDIA_ROOT,
|
||||||
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
urlpatterns += i18n_patterns(
|
urlpatterns += i18n_patterns(
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
path('', include('links.urls')),
|
path('', include('links.urls')),
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import os
|
import os
|
||||||
from django.core.wsgi import get_wsgi_application
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'url_manager.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
application = get_wsgi_application()
|
application = get_wsgi_application()
|
||||||
Binary file not shown.
+18
-9
@@ -1,3 +1,5 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
@@ -6,10 +8,11 @@ services:
|
|||||||
python manage.py runserver 0.0.0.0:8000"
|
python manage.py runserver 0.0.0.0:8000"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- ./media:/app/media
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_SETTINGS_MODULE=url_manager.settings
|
- DJANGO_SETTINGS_MODULE=core.settings
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -19,27 +22,29 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
celery_worker:
|
celery_worker:
|
||||||
build: .
|
build:
|
||||||
command: celery -A url_manager worker --loglevel=info
|
context: .
|
||||||
|
dockerfile: Dockerfile.worker
|
||||||
|
command: celery -A core worker --loglevel=info
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
- ./media:/app/media
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_SETTINGS_MODULE=url_manager.settings
|
- DJANGO_SETTINGS_MODULE=core.settings
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- web
|
|
||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
celery_beat:
|
celery_beat:
|
||||||
build: .
|
build: .
|
||||||
command: celery -A url_manager beat --loglevel=info
|
command: celery -A core beat --loglevel=info
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_SETTINGS_MODULE=url_manager.settings
|
- DJANGO_SETTINGS_MODULE=core.settings
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -67,13 +72,13 @@ services:
|
|||||||
|
|
||||||
flower:
|
flower:
|
||||||
build: .
|
build: .
|
||||||
command: celery -A url_manager flower --port=5555
|
command: celery -A core flower --port=5555
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
ports:
|
ports:
|
||||||
- "5555:5555"
|
- "5555:5555"
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_SETTINGS_MODULE=url_manager.settings
|
- DJANGO_SETTINGS_MODULE=core.settings
|
||||||
- CELERY_BROKER_URL=redis://redis:6379/0
|
- CELERY_BROKER_URL=redis://redis:6379/0
|
||||||
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
- CELERY_RESULT_BACKEND=redis://redis:6379/0
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -82,6 +87,10 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- app-network
|
- app-network
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
media_volume:
|
||||||
|
static_volume:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
app-network:
|
app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.0.9 on 2024-11-04 03:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('links', '0011_page_error_message_page_last_retry_at_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='page',
|
||||||
|
name='screenshot_path',
|
||||||
|
field=models.CharField(blank=True, max_length=255),
|
||||||
|
),
|
||||||
|
]
|
||||||
+11
-1
@@ -4,6 +4,8 @@ from django.urls import reverse
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django.conf import settings
|
||||||
|
import os
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -151,6 +153,9 @@ class Page(models.Model):
|
|||||||
last_retry_at = models.DateTimeField(null=True, blank=True)
|
last_retry_at = models.DateTimeField(null=True, blank=True)
|
||||||
error_message = models.TextField(blank=True)
|
error_message = models.TextField(blank=True)
|
||||||
|
|
||||||
|
# New field for screenshot
|
||||||
|
screenshot_path = models.CharField(max_length=255, blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ['-updated_at']
|
ordering = ['-updated_at']
|
||||||
verbose_name = _('Page')
|
verbose_name = _('Page')
|
||||||
@@ -163,6 +168,11 @@ class Page(models.Model):
|
|||||||
return reverse('page-detail', kwargs={'pk': self.pk})
|
return reverse('page-detail', kwargs={'pk': self.pk})
|
||||||
|
|
||||||
def needs_processing(self):
|
def needs_processing(self):
|
||||||
return (not self.title or not self.summary) and \
|
return (not self.title or not self.summary or not self.screenshot_path) and \
|
||||||
self.process_status != self.ProcessStatus.FAILED and \
|
self.process_status != self.ProcessStatus.FAILED and \
|
||||||
self.retry_count < 3
|
self.retry_count < 3
|
||||||
|
|
||||||
|
def get_screenshot_url(self):
|
||||||
|
if self.screenshot_path:
|
||||||
|
return f'/media/{self.screenshot_path}'
|
||||||
|
return None
|
||||||
|
|||||||
+49
-11
@@ -3,7 +3,7 @@ from django.urls import reverse_lazy
|
|||||||
from django.http import JsonResponse
|
from django.http import JsonResponse
|
||||||
import requests
|
import requests
|
||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse, quote
|
||||||
import re
|
import re
|
||||||
from rest_framework import viewsets, status
|
from rest_framework import viewsets, status
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
@@ -11,6 +11,13 @@ from rest_framework.pagination import PageNumberPagination
|
|||||||
from .models import Page
|
from .models import Page
|
||||||
from .forms import PageForm
|
from .forms import PageForm
|
||||||
from .serializers import PageSerializer
|
from .serializers import PageSerializer
|
||||||
|
import os
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
from django.conf import settings
|
||||||
|
import time
|
||||||
|
import base64
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
class PageListView(ListView):
|
class PageListView(ListView):
|
||||||
model = Page
|
model = Page
|
||||||
@@ -29,7 +36,25 @@ class PageCreateView(CreateView):
|
|||||||
success_url = reverse_lazy('page-list')
|
success_url = reverse_lazy('page-list')
|
||||||
|
|
||||||
def form_valid(self, form):
|
def form_valid(self, form):
|
||||||
# 如果标题和摘要都已经自动填充,则将状态设置为已完成
|
# Handle screenshot data
|
||||||
|
screenshot_data = self.request.POST.get('screenshot_data')
|
||||||
|
if screenshot_data and screenshot_data.startswith('data:image/png;base64,'):
|
||||||
|
# Extract the base64 data
|
||||||
|
image_data = screenshot_data.split(',')[1]
|
||||||
|
# Create filename
|
||||||
|
filename = f"page_screenshot_{form.instance.id}.png"
|
||||||
|
# Save the image
|
||||||
|
image_content = ContentFile(base64.b64decode(image_data))
|
||||||
|
form.instance.screenshot_path = f'screenshots/{filename}'
|
||||||
|
|
||||||
|
# Ensure directory exists
|
||||||
|
os.makedirs(os.path.join(settings.MEDIA_ROOT, 'screenshots'), exist_ok=True)
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
with open(os.path.join(settings.MEDIA_ROOT, form.instance.screenshot_path), 'wb') as f:
|
||||||
|
f.write(base64.b64decode(image_data))
|
||||||
|
|
||||||
|
# Set status
|
||||||
if form.instance.title and form.instance.summary:
|
if form.instance.title and form.instance.summary:
|
||||||
form.instance.process_status = Page.ProcessStatus.COMPLETED
|
form.instance.process_status = Page.ProcessStatus.COMPLETED
|
||||||
else:
|
else:
|
||||||
@@ -48,12 +73,30 @@ class PageDeleteView(DeleteView):
|
|||||||
template_name = 'links/page_confirm_delete.html'
|
template_name = 'links/page_confirm_delete.html'
|
||||||
success_url = reverse_lazy('page-list')
|
success_url = reverse_lazy('page-list')
|
||||||
|
|
||||||
|
def take_screenshot(url):
|
||||||
|
chrome_options = Options()
|
||||||
|
chrome_options.add_argument('--headless')
|
||||||
|
chrome_options.add_argument('--no-sandbox')
|
||||||
|
chrome_options.add_argument('--disable-dev-shm-usage')
|
||||||
|
chrome_options.add_argument('--window-size=1920,1080')
|
||||||
|
|
||||||
|
driver = webdriver.Chrome(options=chrome_options)
|
||||||
|
try:
|
||||||
|
driver.set_page_load_timeout(10) # Set page load timeout
|
||||||
|
driver.get(url)
|
||||||
|
time.sleep(2) # Wait for any dynamic content
|
||||||
|
|
||||||
|
# Take screenshot and convert to base64
|
||||||
|
screenshot = driver.get_screenshot_as_base64()
|
||||||
|
return f"data:image/png;base64,{screenshot}"
|
||||||
|
finally:
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
def fetch_page_info(request):
|
def fetch_page_info(request):
|
||||||
url = request.GET.get('url')
|
url = request.GET.get('url')
|
||||||
if not url:
|
if not url:
|
||||||
return JsonResponse({'error': 'URL is required'}, status=400)
|
return JsonResponse({'error': 'URL is required'}, status=400)
|
||||||
|
|
||||||
# 移除URL开头可能的@符号
|
|
||||||
url = url.lstrip('@')
|
url = url.lstrip('@')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -63,14 +106,13 @@ def fetch_page_info(request):
|
|||||||
'Accept-Language': 'en-US,en;q=0.5',
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
}
|
}
|
||||||
|
|
||||||
response = requests.get(url, headers=headers, timeout=10, verify=False)
|
response = requests.get(url, headers=headers, timeout=5, verify=False) # Reduced timeout to 5 seconds
|
||||||
|
|
||||||
if response.encoding == 'ISO-8859-1':
|
if response.encoding == 'ISO-8859-1':
|
||||||
response.encoding = response.apparent_encoding or 'utf-8'
|
response.encoding = response.apparent_encoding or 'utf-8'
|
||||||
|
|
||||||
soup = BeautifulSoup(response.text, 'html.parser')
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
|
||||||
# 获取标题
|
# Get title
|
||||||
title = None
|
title = None
|
||||||
if soup.title:
|
if soup.title:
|
||||||
title = soup.title.string
|
title = soup.title.string
|
||||||
@@ -85,7 +127,7 @@ def fetch_page_info(request):
|
|||||||
else:
|
else:
|
||||||
title = urlparse(url).netloc
|
title = urlparse(url).netloc
|
||||||
|
|
||||||
# 获取描述
|
# Get description
|
||||||
description = None
|
description = None
|
||||||
meta_desc = soup.find('meta', {'name': 'description'}) or soup.find('meta', {'property': 'og:description'})
|
meta_desc = soup.find('meta', {'name': 'description'}) or soup.find('meta', {'property': 'og:description'})
|
||||||
if meta_desc:
|
if meta_desc:
|
||||||
@@ -113,10 +155,6 @@ def fetch_page_info(request):
|
|||||||
'summary': description
|
'summary': description
|
||||||
})
|
})
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
return JsonResponse({
|
|
||||||
'error': f'Failed to fetch page: {str(e)}'
|
|
||||||
}, status=400)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
'error': f'Error processing page: {str(e)}'
|
'error': f'Error processing page: {str(e)}'
|
||||||
|
|||||||
+45
-3
@@ -5,6 +5,13 @@ import requests
|
|||||||
from bs4 import BeautifulSoup
|
from bs4 import BeautifulSoup
|
||||||
import logging
|
import logging
|
||||||
from .models import Page
|
from .models import Page
|
||||||
|
from selenium import webdriver
|
||||||
|
from selenium.webdriver.chrome.options import Options
|
||||||
|
import os
|
||||||
|
from django.conf import settings
|
||||||
|
from urllib.parse import quote
|
||||||
|
import time
|
||||||
|
from core.celery import app
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -19,16 +26,43 @@ def fibonacci(n):
|
|||||||
a, b = b, a + b
|
a, b = b, a + b
|
||||||
return b
|
return b
|
||||||
|
|
||||||
|
def take_screenshot(url, page_id):
|
||||||
|
chrome_options = Options()
|
||||||
|
chrome_options.add_argument('--headless')
|
||||||
|
chrome_options.add_argument('--no-sandbox')
|
||||||
|
chrome_options.add_argument('--disable-dev-shm-usage')
|
||||||
|
chrome_options.add_argument('--window-size=1920,1080')
|
||||||
|
|
||||||
|
driver = webdriver.Chrome(options=chrome_options)
|
||||||
|
try:
|
||||||
|
driver.get(url)
|
||||||
|
# Wait for page to load
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
# Create screenshots directory if it doesn't exist
|
||||||
|
screenshots_dir = os.path.join(settings.MEDIA_ROOT, 'screenshots')
|
||||||
|
os.makedirs(screenshots_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Create a safe filename using the page ID and encoded URL
|
||||||
|
safe_url = quote(url, safe='')[:50] # Limit URL length in filename
|
||||||
|
filename = f"page_{page_id}_{safe_url}.png"
|
||||||
|
filepath = os.path.join('screenshots', filename)
|
||||||
|
full_path = os.path.join(settings.MEDIA_ROOT, filepath)
|
||||||
|
|
||||||
|
# Take screenshot
|
||||||
|
driver.save_screenshot(full_path)
|
||||||
|
return filepath
|
||||||
|
finally:
|
||||||
|
driver.quit()
|
||||||
|
|
||||||
@shared_task(bind=True, max_retries=3)
|
@shared_task(bind=True, max_retries=3)
|
||||||
def process_page(self, page_id):
|
def process_page(self, page_id):
|
||||||
try:
|
try:
|
||||||
page = Page.objects.get(id=page_id)
|
page = Page.objects.get(id=page_id)
|
||||||
|
|
||||||
# Check if page still needs processing
|
|
||||||
if not page.needs_processing():
|
if not page.needs_processing():
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update status to processing
|
|
||||||
page.process_status = Page.ProcessStatus.PROCESSING
|
page.process_status = Page.ProcessStatus.PROCESSING
|
||||||
page.save()
|
page.save()
|
||||||
|
|
||||||
@@ -38,6 +72,7 @@ def process_page(self, page_id):
|
|||||||
'Accept-Language': 'en-US,en;q=0.5',
|
'Accept-Language': 'en-US,en;q=0.5',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Fetch page content
|
||||||
response = requests.get(page.url, headers=headers, timeout=10, verify=False)
|
response = requests.get(page.url, headers=headers, timeout=10, verify=False)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
|
|
||||||
@@ -80,6 +115,14 @@ def process_page(self, page_id):
|
|||||||
if description:
|
if description:
|
||||||
page.summary = description[:500]
|
page.summary = description[:500]
|
||||||
|
|
||||||
|
# Take screenshot if not present
|
||||||
|
if not page.screenshot_path:
|
||||||
|
try:
|
||||||
|
screenshot_path = take_screenshot(page.url, page.id)
|
||||||
|
page.screenshot_path = screenshot_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to take screenshot for page {page_id}: {e}")
|
||||||
|
|
||||||
page.process_status = Page.ProcessStatus.COMPLETED
|
page.process_status = Page.ProcessStatus.COMPLETED
|
||||||
page.save()
|
page.save()
|
||||||
logger.info(f"Successfully processed page {page_id}")
|
logger.info(f"Successfully processed page {page_id}")
|
||||||
@@ -94,7 +137,6 @@ def process_page(self, page_id):
|
|||||||
logger.error(f"Failed to process page {page_id} after 3 retries: {exc}")
|
logger.error(f"Failed to process page {page_id} after 3 retries: {exc}")
|
||||||
else:
|
else:
|
||||||
page.process_status = Page.ProcessStatus.PENDING
|
page.process_status = Page.ProcessStatus.PENDING
|
||||||
# Schedule retry with fibonacci backoff
|
|
||||||
retry_delay = fibonacci(page.retry_count)
|
retry_delay = fibonacci(page.retry_count)
|
||||||
logger.info(f"Scheduling retry for page {page_id} in {retry_delay} seconds")
|
logger.info(f"Scheduling retry for page {page_id} in {retry_delay} seconds")
|
||||||
self.retry(exc=exc, countdown=retry_delay)
|
self.retry(exc=exc, countdown=retry_delay)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
{% load markdown_extras %}
|
{% load markdown_extras %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
@@ -108,6 +109,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Screenshot -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-sm font-medium text-gray-500 mb-2">{% trans "Screenshot" %}</h2>
|
||||||
|
<div class="rounded-lg overflow-hidden border border-gray-200">
|
||||||
|
{% if page.screenshot_path %}
|
||||||
|
<img src="{{ page.get_screenshot_url }}"
|
||||||
|
alt="Page screenshot"
|
||||||
|
class="w-full h-auto"
|
||||||
|
loading="lazy">
|
||||||
|
{% else %}
|
||||||
|
<img src="{% static 'images/default-screenshot.png' %}"
|
||||||
|
alt="Default screenshot"
|
||||||
|
class="w-full h-auto">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,14 +3,32 @@
|
|||||||
|
|
||||||
{% block extra_css %}
|
{% block extra_css %}
|
||||||
<style>
|
<style>
|
||||||
.loading-spinner {
|
.overlay {
|
||||||
display: none;
|
display: none;
|
||||||
width: 20px;
|
position: fixed;
|
||||||
height: 20px;
|
top: 0;
|
||||||
border: 2px solid #f3f3f3;
|
left: 0;
|
||||||
border-top: 2px solid #3498db;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.loading-container {
|
||||||
|
background: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.loading-spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #3498db;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 1rem auto;
|
||||||
}
|
}
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
@@ -20,18 +38,25 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<!-- Loading Overlay -->
|
||||||
|
<div id="loadingOverlay" class="overlay">
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p class="text-gray-700">{% trans "Fetching page information..." %}</p>
|
||||||
|
<p class="text-sm text-gray-500">{% trans "This may take a few seconds" %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
|
<div class="bg-white shadow-sm rounded-lg overflow-hidden">
|
||||||
<div class="px-4 py-5 sm:p-6">
|
<div class="px-4 py-5 sm:p-6">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<h1 class="text-2xl font-bold text-gray-900 mb-6">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">
|
{% if form.instance.pk %}
|
||||||
{% if form.instance.pk %}
|
{% trans "Edit Page" %}
|
||||||
{% trans "Edit Page" %}
|
{% else %}
|
||||||
{% else %}
|
{% trans "New Page" %}
|
||||||
{% trans "New Page" %}
|
{% endif %}
|
||||||
{% endif %}
|
</h1>
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form method="post" class="space-y-6">
|
<form method="post" class="space-y-6">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -40,17 +65,13 @@
|
|||||||
<!-- URL Field -->
|
<!-- URL Field -->
|
||||||
<div>
|
<div>
|
||||||
<label for="{{ form.url.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
<label for="{{ form.url.id_for_label }}" class="block text-sm font-medium text-gray-700">
|
||||||
{{ form.url.label }} <span class="text-red-500">*</span>
|
{{ form.url.label }}
|
||||||
</label>
|
</label>
|
||||||
<div class="mt-1 relative">
|
<div class="mt-1">
|
||||||
<input type="url" name="{{ form.url.name }}" id="{{ form.url.id_for_label }}"
|
<input type="url" name="{{ form.url.name }}" id="{{ form.url.id_for_label }}"
|
||||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md"
|
||||||
value="{{ form.url.value|default:'' }}"
|
value="{{ form.url.value|default:'' }}"
|
||||||
placeholder="https://example.com"
|
placeholder="https://example.com">
|
||||||
required>
|
|
||||||
<div class="absolute inset-y-0 right-0 flex items-center pr-3">
|
|
||||||
<div class="loading-spinner" id="urlSpinner"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="urlError" class="mt-2 text-sm text-red-600 hidden"></div>
|
<div id="urlError" class="mt-2 text-sm text-red-600 hidden"></div>
|
||||||
{% if form.url.errors %}
|
{% if form.url.errors %}
|
||||||
@@ -130,8 +151,8 @@ let fetchTimeout;
|
|||||||
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
const urlInput = document.getElementById('{{ form.url.id_for_label }}');
|
||||||
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
const titleInput = document.getElementById('{{ form.title.id_for_label }}');
|
||||||
const summaryInput = document.getElementById('{{ form.summary.id_for_label }}');
|
const summaryInput = document.getElementById('{{ form.summary.id_for_label }}');
|
||||||
const spinner = document.getElementById('urlSpinner');
|
|
||||||
const errorDiv = document.getElementById('urlError');
|
const errorDiv = document.getElementById('urlError');
|
||||||
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
||||||
|
|
||||||
urlInput.addEventListener('input', function(e) {
|
urlInput.addEventListener('input', function(e) {
|
||||||
clearTimeout(fetchTimeout);
|
clearTimeout(fetchTimeout);
|
||||||
@@ -139,7 +160,6 @@ urlInput.addEventListener('input', function(e) {
|
|||||||
|
|
||||||
if (!urlInput.value) return;
|
if (!urlInput.value) return;
|
||||||
|
|
||||||
// 等待用户停止输入500ms后再发起请求
|
|
||||||
fetchTimeout = setTimeout(() => {
|
fetchTimeout = setTimeout(() => {
|
||||||
if (urlInput.checkValidity()) {
|
if (urlInput.checkValidity()) {
|
||||||
fetchPageInfo(urlInput.value);
|
fetchPageInfo(urlInput.value);
|
||||||
@@ -148,15 +168,23 @@ urlInput.addEventListener('input', function(e) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function fetchPageInfo(url) {
|
async function fetchPageInfo(url) {
|
||||||
spinner.style.display = 'block';
|
loadingOverlay.style.display = 'flex';
|
||||||
errorDiv.classList.add('hidden');
|
errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
// Set timeout
|
||||||
|
const timeoutPromise = new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error('Request timed out')), 5000); // 5 second timeout
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/fetch-page-info/?url=${encodeURIComponent(url)}`);
|
const response = await Promise.race([
|
||||||
|
fetch(`/fetch-page-info/?url=${encodeURIComponent(url)}`),
|
||||||
|
timeoutPromise
|
||||||
|
]);
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// 只在字段为空时填充数据
|
|
||||||
if (!titleInput.value) {
|
if (!titleInput.value) {
|
||||||
titleInput.value = data.title || '';
|
titleInput.value = data.title || '';
|
||||||
}
|
}
|
||||||
@@ -170,7 +198,7 @@ async function fetchPageInfo(url) {
|
|||||||
errorDiv.textContent = error.message || 'Failed to fetch page information';
|
errorDiv.textContent = error.message || 'Failed to fetch page information';
|
||||||
errorDiv.classList.remove('hidden');
|
errorDiv.classList.remove('hidden');
|
||||||
} finally {
|
} finally {
|
||||||
spinner.style.display = 'none';
|
loadingOverlay.style.display = 'none';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load static %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
@@ -13,63 +14,89 @@
|
|||||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
<ul class="divide-y divide-gray-200">
|
<ul class="divide-y divide-gray-200">
|
||||||
{% for page in pages %}
|
{% for page in pages %}
|
||||||
<li>
|
<li class="p-4">
|
||||||
<div class="px-4 py-4 sm:px-6">
|
<div class="flex space-x-4">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Thumbnail Container -->
|
||||||
<div class="flex-1">
|
<div class="flex-shrink-0 w-[200px] h-[200px] rounded-lg overflow-hidden bg-gray-100">
|
||||||
<a href="{{ page.get_absolute_url }}" class="text-lg font-medium text-blue-600 hover:text-blue-800">
|
{% if page.screenshot_path %}
|
||||||
{{ page.title|default:"Untitled" }}
|
<img src="{{ page.get_screenshot_url }}"
|
||||||
</a>
|
alt="Page thumbnail"
|
||||||
<a href="{{ page.url }}" target="_blank" rel="noopener noreferrer"
|
class="w-[200px] h-[200px] object-cover"
|
||||||
class="block mt-1 text-sm text-gray-600 hover:text-gray-900 break-all">
|
loading="lazy">
|
||||||
{{ page.url }}
|
{% else %}
|
||||||
</a>
|
<img src="{% static 'images/default-screenshot.png' %}"
|
||||||
</div>
|
alt="Default thumbnail"
|
||||||
<div class="flex items-center space-x-4">
|
class="w-[200px] h-[200px] object-cover">
|
||||||
<!-- Status Badge -->
|
{% endif %}
|
||||||
{% if page.process_status == 'completed' %}
|
</div>
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
|
||||||
{% trans "Completed" %}
|
|
||||||
</span>
|
|
||||||
{% elif page.process_status == 'processing' %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
|
||||||
{% trans "Processing" %}
|
|
||||||
</span>
|
|
||||||
{% elif page.process_status == 'failed' %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
|
||||||
{% trans "Failed" %}
|
|
||||||
{% if page.retry_count > 0 %}
|
|
||||||
({{ page.retry_count }}/3)
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
|
||||||
{% trans "Pending" %}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Content Container -->
|
||||||
<div class="flex space-x-2">
|
<div class="flex-1 min-w-0 flex flex-col">
|
||||||
<a href="{% url 'page-update' page.pk %}" class="text-gray-600 hover:text-gray-900">
|
<!-- Header -->
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex justify-between items-start">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<!-- Title and URL -->
|
||||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
<div class="min-w-0 flex-1 pr-4">
|
||||||
</svg>
|
<h2 class="text-lg font-medium text-blue-600 hover:text-blue-800 truncate">
|
||||||
</a>
|
<a href="{{ page.get_absolute_url }}">{{ page.title|default:"Untitled" }}</a>
|
||||||
<a href="{% url 'page-delete' page.pk %}" class="text-red-600 hover:text-red-800">
|
</h2>
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<a href="{{ page.url }}"
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
target="_blank"
|
||||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
rel="noopener noreferrer"
|
||||||
</svg>
|
class="mt-1 text-sm text-gray-600 hover:text-gray-900 break-all line-clamp-1">
|
||||||
|
{{ page.url }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Status and Actions -->
|
||||||
|
<div class="flex flex-col items-end space-y-2">
|
||||||
|
<!-- Status Badge -->
|
||||||
|
{% if page.process_status == 'completed' %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
{% trans "Completed" %}
|
||||||
|
</span>
|
||||||
|
{% elif page.process_status == 'processing' %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
{% trans "Processing" %}
|
||||||
|
</span>
|
||||||
|
{% elif page.process_status == 'failed' %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||||
|
{% trans "Failed" %}{% if page.retry_count > 0 %} ({{ page.retry_count }}/3){% endif %}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
{% trans "Pending" %}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<a href="{% url 'page-update' page.pk %}"
|
||||||
|
class="p-1 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a href="{% url 'page-delete' page.pk %}"
|
||||||
|
class="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary -->
|
||||||
|
<div class="mt-2 flex-1">
|
||||||
|
<p class="text-gray-600 line-clamp-3">{{ page.summary }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="mt-2 text-sm text-gray-500">
|
||||||
|
{% trans "Updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="mt-2">
|
|
||||||
<p class="text-gray-600">{{ page.summary }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 text-sm text-gray-500">
|
|
||||||
{% trans "Updated" %}: {{ page.updated_at|date:"Y-m-d H:i" }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -85,7 +112,8 @@
|
|||||||
<div class="mt-4 flex justify-center">
|
<div class="mt-4 flex justify-center">
|
||||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||||
{% if page_obj.has_previous %}
|
{% if page_obj.has_previous %}
|
||||||
<a href="?page={{ page_obj.previous_page_number }}" class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
<a href="?page={{ page_obj.previous_page_number }}"
|
||||||
|
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||||
{% trans "Previous" %}
|
{% trans "Previous" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -95,7 +123,8 @@
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
{% if page_obj.has_next %}
|
||||||
<a href="?page={{ page_obj.next_page_number }}" class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
<a href="?page={{ page_obj.next_page_number }}"
|
||||||
|
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||||
{% trans "Next" %}
|
{% trans "Next" %}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
from django.urls import path, include
|
from django.urls import path, include
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from django.urls import re_path
|
||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
router = DefaultRouter(trailing_slash=False)
|
router = DefaultRouter(trailing_slash=False)
|
||||||
@@ -21,7 +22,7 @@ urlpatterns = [
|
|||||||
path('ui/help/', views.HelpView.as_view(), name='help'),
|
path('ui/help/', views.HelpView.as_view(), name='help'),
|
||||||
path('export/', views.export_links, name='export_links'),
|
path('export/', views.export_links, name='export_links'),
|
||||||
|
|
||||||
# Pages UI
|
# Pages
|
||||||
path('ui/pages/', views.PageListView.as_view(), name='page-list'),
|
path('ui/pages/', views.PageListView.as_view(), name='page-list'),
|
||||||
path('ui/pages/new/', views.PageCreateView.as_view(), name='page-create'),
|
path('ui/pages/new/', views.PageCreateView.as_view(), name='page-create'),
|
||||||
path('ui/pages/<int:pk>/', views.PageDetailView.as_view(), name='page-detail'),
|
path('ui/pages/<int:pk>/', views.PageDetailView.as_view(), name='page-detail'),
|
||||||
|
|||||||
@@ -18,57 +18,6 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/forms.py:6
|
|
||||||
msgid "Search files"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:5
|
|
||||||
msgid "Select an image to embed"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:27
|
|
||||||
msgid "Browse for the image you want, then click 'Embed Image' to continue..."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:29
|
|
||||||
msgid ""
|
|
||||||
"No images found. Upload images using the 'Image Button' dialog's 'Upload' "
|
|
||||||
"tab."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:50
|
|
||||||
msgid "Images in: "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:62
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:80
|
|
||||||
msgid "Embed Image"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:146
|
|
||||||
msgid "Play Slideshow"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:147
|
|
||||||
msgid "Pause Slideshow"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:148
|
|
||||||
msgid "‹ Previous Photo"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:149
|
|
||||||
msgid "Next Photo ›"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:150
|
|
||||||
msgid "Next ›"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:151
|
|
||||||
msgid "‹ Prev"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/click/_termui_impl.py:518
|
#: 3.12/lib/python3.12/site-packages/click/_termui_impl.py:518
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "{editor}: Editing failed"
|
msgid "{editor}: Editing failed"
|
||||||
@@ -1842,10 +1791,10 @@ msgstr ""
|
|||||||
msgid "No results found"
|
msgid "No results found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: url_manager/settings.py:81
|
#: core/settings.py:81
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: url_manager/settings.py:82
|
#: core/settings.py:82
|
||||||
msgid "Simplified Chinese"
|
msgid "Simplified Chinese"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@@ -18,59 +18,6 @@ msgstr ""
|
|||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/forms.py:6
|
|
||||||
#, fuzzy
|
|
||||||
#| msgid "Search Links"
|
|
||||||
msgid "Search files"
|
|
||||||
msgstr "搜索链接"
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:5
|
|
||||||
msgid "Select an image to embed"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:27
|
|
||||||
msgid "Browse for the image you want, then click 'Embed Image' to continue..."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:29
|
|
||||||
msgid ""
|
|
||||||
"No images found. Upload images using the 'Image Button' dialog's 'Upload' "
|
|
||||||
"tab."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:50
|
|
||||||
msgid "Images in: "
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:62
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:80
|
|
||||||
msgid "Embed Image"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:146
|
|
||||||
msgid "Play Slideshow"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:147
|
|
||||||
msgid "Pause Slideshow"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:148
|
|
||||||
msgid "‹ Previous Photo"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:149
|
|
||||||
msgid "Next Photo ›"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:150
|
|
||||||
msgid "Next ›"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/ckeditor_uploader/templates/ckeditor/browse.html:151
|
|
||||||
msgid "‹ Prev"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: 3.12/lib/python3.12/site-packages/click/_termui_impl.py:518
|
#: 3.12/lib/python3.12/site-packages/click/_termui_impl.py:518
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "{editor}: Editing failed"
|
msgid "{editor}: Editing failed"
|
||||||
@@ -1894,11 +1841,11 @@ msgstr "搜索别名..."
|
|||||||
msgid "No results found"
|
msgid "No results found"
|
||||||
msgstr "未找到结果。"
|
msgstr "未找到结果。"
|
||||||
|
|
||||||
#: url_manager/settings.py:81
|
#: core/settings.py:81
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "英语"
|
msgstr "英语"
|
||||||
|
|
||||||
#: url_manager/settings.py:82
|
#: core/settings.py:82
|
||||||
msgid "Simplified Chinese"
|
msgid "Simplified Chinese"
|
||||||
msgstr "简体中文"
|
msgstr "简体中文"
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
case "$1" in
|
case "$1" in
|
||||||
"start")
|
"start")
|
||||||
|
mkdir -p media/screenshots
|
||||||
|
chmod -R 777 media
|
||||||
docker-compose up -d
|
docker-compose up -d
|
||||||
echo "Services started. Access:"
|
echo "Services started. Access:"
|
||||||
echo "- Web: http://localhost:8000"
|
echo "- Web: http://localhost:8000"
|
||||||
@@ -27,10 +29,8 @@ case "$1" in
|
|||||||
docker-compose exec web python manage.py shell
|
docker-compose exec web python manage.py shell
|
||||||
;;
|
;;
|
||||||
"migrate")
|
"migrate")
|
||||||
docker-compose exec web python manage.py migrate
|
|
||||||
;;
|
|
||||||
"makemigrations")
|
|
||||||
docker-compose exec web python manage.py makemigrations
|
docker-compose exec web python manage.py makemigrations
|
||||||
|
docker-compose exec web python manage.py migrate
|
||||||
;;
|
;;
|
||||||
"tailwind-logs")
|
"tailwind-logs")
|
||||||
docker-compose logs -f tailwind
|
docker-compose logs -f tailwind
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import sys
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Run administrative tasks."""
|
"""Run administrative tasks."""
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'url_manager.settings')
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
try:
|
try:
|
||||||
from django.core.management import execute_from_command_line
|
from django.core.management import execute_from_command_line
|
||||||
except ImportError as exc:
|
except ImportError as exc:
|
||||||
|
|||||||
@@ -17,3 +17,4 @@ djangorestframework==3.15.2
|
|||||||
celery>=5.3.0
|
celery>=5.3.0
|
||||||
redis>=4.5.0
|
redis>=4.5.0
|
||||||
flower>=2.0.0
|
flower>=2.0.0
|
||||||
|
selenium>=4.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user