feat: Enhance task access control and session management
- Implemented API and web task access assertions in the task status polling endpoint. - Added functions to remember and check task access in user sessions. - Updated task status tests to validate access control based on session data. - Enhanced download route tests to ensure proper access checks. - Improved SEO metadata handling with dynamic social preview images. - Updated sitemap generation to include blog posts and new tools. - Added a social preview SVG for better sharing on social media platforms.
This commit is contained in:
@@ -16,7 +16,7 @@ CELERY_BROKER_URL=redis://redis:6379/0
|
||||
CELERY_RESULT_BACKEND=redis://redis:6379/1
|
||||
|
||||
# OpenRouter AI
|
||||
OPENROUTER_API_KEY=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
|
||||
OPENROUTER_API_KEY=sk-or-v1-your-openrouter-api-key
|
||||
OPENROUTER_MODEL=nvidia/nemotron-3-super-120b-a12b:free
|
||||
OPENROUTER_BASE_URL=https://openrouter.ai/api/v1/chat/completions
|
||||
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -54,6 +54,3 @@ htmlcov/
|
||||
.coverage
|
||||
coverage/
|
||||
|
||||
# Celery
|
||||
celerybeat-schedule
|
||||
backend/celerybeat-schedule
|
||||
@@ -3,6 +3,14 @@ import os
|
||||
|
||||
from flask import Blueprint, send_file, abort, request, current_app
|
||||
|
||||
from app.services.policy_service import (
|
||||
PolicyError,
|
||||
assert_api_task_access,
|
||||
assert_web_task_access,
|
||||
resolve_api_actor,
|
||||
resolve_web_actor,
|
||||
)
|
||||
|
||||
download_bp = Blueprint("download", __name__)
|
||||
|
||||
|
||||
@@ -20,6 +28,16 @@ def download_file(task_id: str, filename: str):
|
||||
if ".." in filename or "/" in filename or "\\" in filename:
|
||||
abort(400, "Invalid filename.")
|
||||
|
||||
try:
|
||||
if request.headers.get("X-API-Key", "").strip():
|
||||
actor = resolve_api_actor()
|
||||
assert_api_task_access(actor, task_id)
|
||||
else:
|
||||
actor = resolve_web_actor()
|
||||
assert_web_task_access(actor, task_id)
|
||||
except PolicyError as exc:
|
||||
abort(exc.status_code, exc.message)
|
||||
|
||||
output_dir = current_app.config["OUTPUT_FOLDER"]
|
||||
file_path = os.path.join(output_dir, task_id, filename)
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
"""Task status polling endpoint."""
|
||||
from flask import Blueprint, jsonify
|
||||
from flask import Blueprint, jsonify, request
|
||||
from celery.result import AsyncResult
|
||||
|
||||
from app.extensions import celery
|
||||
from app.middleware.rate_limiter import limiter
|
||||
from app.services.policy_service import (
|
||||
PolicyError,
|
||||
assert_api_task_access,
|
||||
assert_web_task_access,
|
||||
resolve_api_actor,
|
||||
resolve_web_actor,
|
||||
)
|
||||
|
||||
tasks_bp = Blueprint("tasks", __name__)
|
||||
|
||||
@@ -17,6 +24,16 @@ def get_task_status(task_id: str):
|
||||
Returns:
|
||||
JSON with task state and result (if completed)
|
||||
"""
|
||||
try:
|
||||
if request.headers.get("X-API-Key", "").strip():
|
||||
actor = resolve_api_actor()
|
||||
assert_api_task_access(actor, task_id)
|
||||
else:
|
||||
actor = resolve_web_actor()
|
||||
assert_web_task_access(actor, task_id)
|
||||
except PolicyError as exc:
|
||||
return jsonify({"error": exc.message}), exc.status_code
|
||||
|
||||
result = AsyncResult(task_id, app=celery)
|
||||
|
||||
response = {
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.services.account_service import (
|
||||
record_usage_event,
|
||||
)
|
||||
from app.utils.auth import get_current_user_id, logout_user_session
|
||||
from app.utils.auth import has_session_task_access, remember_task_access
|
||||
from app.utils.file_validator import validate_file
|
||||
|
||||
FREE_PLAN = "free"
|
||||
@@ -202,6 +203,9 @@ def assert_quota_available(actor: ActorContext):
|
||||
|
||||
def record_accepted_usage(actor: ActorContext, tool: str, celery_task_id: str):
|
||||
"""Record one accepted usage event after task dispatch succeeds."""
|
||||
if actor.source == "web":
|
||||
remember_task_access(celery_task_id)
|
||||
|
||||
record_usage_event(
|
||||
user_id=actor.user_id,
|
||||
source=actor.source,
|
||||
@@ -225,3 +229,14 @@ def assert_api_task_access(actor: ActorContext, task_id: str):
|
||||
"""Ensure one API actor can poll one task id."""
|
||||
if actor.user_id is None or not has_task_access(actor.user_id, "api", task_id):
|
||||
raise PolicyError("Task not found.", 404)
|
||||
|
||||
|
||||
def assert_web_task_access(actor: ActorContext, task_id: str):
|
||||
"""Ensure one web browser session can access one task id."""
|
||||
if actor.user_id is not None and has_task_access(actor.user_id, "web", task_id):
|
||||
return
|
||||
|
||||
if has_session_task_access(task_id):
|
||||
return
|
||||
|
||||
raise PolicyError("Task not found.", 404)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"""Session helpers for authenticated routes."""
|
||||
from flask import session
|
||||
|
||||
TASK_ACCESS_SESSION_KEY = "task_access_ids"
|
||||
MAX_TRACKED_TASK_IDS = 200
|
||||
|
||||
|
||||
def get_current_user_id() -> int | None:
|
||||
"""Return the authenticated user id from session storage."""
|
||||
@@ -8,11 +11,31 @@ def get_current_user_id() -> int | None:
|
||||
return user_id if isinstance(user_id, int) else None
|
||||
|
||||
|
||||
def remember_task_access(task_id: str):
|
||||
"""Persist one web task id in the active browser session."""
|
||||
tracked = session.get(TASK_ACCESS_SESSION_KEY, [])
|
||||
if not isinstance(tracked, list):
|
||||
tracked = []
|
||||
|
||||
normalized = [value for value in tracked if isinstance(value, str) and value != task_id]
|
||||
normalized.append(task_id)
|
||||
session[TASK_ACCESS_SESSION_KEY] = normalized[-MAX_TRACKED_TASK_IDS:]
|
||||
|
||||
|
||||
def has_session_task_access(task_id: str) -> bool:
|
||||
"""Return whether the active browser session owns one web task id."""
|
||||
tracked = session.get(TASK_ACCESS_SESSION_KEY, [])
|
||||
return isinstance(tracked, list) and task_id in tracked
|
||||
|
||||
|
||||
def login_user_session(user_id: int):
|
||||
"""Persist the authenticated user in the Flask session."""
|
||||
tracked_task_ids = session.get(TASK_ACCESS_SESSION_KEY, [])
|
||||
session.clear()
|
||||
session.permanent = True
|
||||
session["user_id"] = user_id
|
||||
if isinstance(tracked_task_ids, list) and tracked_task_ids:
|
||||
session[TASK_ACCESS_SESSION_KEY] = tracked_task_ids[-MAX_TRACKED_TASK_IDS:]
|
||||
|
||||
|
||||
def logout_user_session():
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for file download route."""
|
||||
import os
|
||||
|
||||
from app.utils.auth import TASK_ACCESS_SESSION_KEY
|
||||
|
||||
|
||||
class TestDownload:
|
||||
def test_download_nonexistent_file(self, client):
|
||||
@@ -31,6 +33,9 @@ class TestDownload:
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(b'%PDF-1.4 test content')
|
||||
|
||||
with client.session_transaction() as session:
|
||||
session[TASK_ACCESS_SESSION_KEY] = [task_id]
|
||||
|
||||
response = client.get(f'/api/download/{task_id}/{filename}')
|
||||
assert response.status_code == 200
|
||||
assert response.data == b'%PDF-1.4 test content'
|
||||
@@ -45,5 +50,21 @@ class TestDownload:
|
||||
with open(os.path.join(output_dir, filename), 'wb') as f:
|
||||
f.write(b'%PDF-1.4')
|
||||
|
||||
with client.session_transaction() as session:
|
||||
session[TASK_ACCESS_SESSION_KEY] = [task_id]
|
||||
|
||||
response = client.get(f'/api/download/{task_id}/{filename}?name=my-document.pdf')
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_download_requires_task_access(self, client, app):
|
||||
"""Should not serve an existing file without session or API ownership."""
|
||||
task_id = 'protected-download-id'
|
||||
filename = 'output.pdf'
|
||||
|
||||
output_dir = os.path.join(app.config['OUTPUT_FOLDER'], task_id)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
with open(os.path.join(output_dir, filename), 'wb') as f:
|
||||
f.write(b'%PDF-1.4 protected')
|
||||
|
||||
response = client.get(f'/api/download/{task_id}/{filename}')
|
||||
assert response.status_code == 404
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Tests for task status polling route."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from app.utils.auth import TASK_ACCESS_SESSION_KEY
|
||||
|
||||
|
||||
class TestTaskStatus:
|
||||
def test_pending_task(self, client, monkeypatch):
|
||||
@@ -9,6 +11,9 @@ class TestTaskStatus:
|
||||
mock_result.state = 'PENDING'
|
||||
mock_result.info = None
|
||||
|
||||
with client.session_transaction() as session:
|
||||
session[TASK_ACCESS_SESSION_KEY] = ['test-task-id']
|
||||
|
||||
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
|
||||
response = client.get('/api/tasks/test-task-id/status')
|
||||
|
||||
@@ -24,6 +29,9 @@ class TestTaskStatus:
|
||||
mock_result.state = 'PROCESSING'
|
||||
mock_result.info = {'step': 'Converting page 3 of 10...'}
|
||||
|
||||
with client.session_transaction() as session:
|
||||
session[TASK_ACCESS_SESSION_KEY] = ['processing-id']
|
||||
|
||||
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
|
||||
response = client.get('/api/tasks/processing-id/status')
|
||||
|
||||
@@ -42,6 +50,9 @@ class TestTaskStatus:
|
||||
'filename': 'output.pdf',
|
||||
}
|
||||
|
||||
with client.session_transaction() as session:
|
||||
session[TASK_ACCESS_SESSION_KEY] = ['success-id']
|
||||
|
||||
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
|
||||
response = client.get('/api/tasks/success-id/status')
|
||||
|
||||
@@ -57,6 +68,9 @@ class TestTaskStatus:
|
||||
mock_result.state = 'FAILURE'
|
||||
mock_result.info = Exception('Conversion failed due to corrupt PDF.')
|
||||
|
||||
with client.session_transaction() as session:
|
||||
session[TASK_ACCESS_SESSION_KEY] = ['failed-id']
|
||||
|
||||
with patch('app.routes.tasks.AsyncResult', return_value=mock_result):
|
||||
response = client.get('/api/tasks/failed-id/status')
|
||||
|
||||
@@ -64,3 +78,9 @@ class TestTaskStatus:
|
||||
data = response.get_json()
|
||||
assert data['state'] == 'FAILURE'
|
||||
assert 'error' in data
|
||||
|
||||
def test_unknown_task_without_access_returns_404(self, client):
|
||||
"""Should not expose task state without session or API ownership."""
|
||||
response = client.get('/api/tasks/unknown-task/status')
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -12,12 +12,16 @@
|
||||
<meta property="og:title" content="Dociva — Free Online File Tools" />
|
||||
<meta property="og:description" content="30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." />
|
||||
<meta property="og:site_name" content="Dociva" />
|
||||
<meta property="og:image" content="/social-preview.svg" />
|
||||
<meta property="og:image:alt" content="Dociva social preview" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:locale:alternate" content="ar_SA" />
|
||||
<meta property="og:locale:alternate" content="fr_FR" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Dociva — Free Online File Tools" />
|
||||
<meta name="twitter:description" content="30+ free tools: merge, split, compress, convert PDFs, images, videos & text. No signup required." />
|
||||
<meta name="twitter:image" content="/social-preview.svg" />
|
||||
<meta name="twitter:image:alt" content="Dociva social preview" />
|
||||
<link rel="dns-prefetch" href="https://fonts.googleapis.com" />
|
||||
<link rel="dns-prefetch" href="https://fonts.gstatic.com" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
|
||||
@@ -1,212 +1,286 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://dociva.io/</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/about</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/contact</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/privacy</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/terms</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/pricing</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
<!-- Blog Posts -->
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/how-to-compress-pdf-online</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/convert-images-without-losing-quality</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/ocr-extract-text-from-images</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/merge-split-pdf-files</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
<!-- PDF Tools -->
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-to-word</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/word-to-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/compress-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/merge-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/split-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/rotate-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-to-images</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/images-to-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/watermark-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/remove-watermark-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/protect-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/unlock-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/page-numbers</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/reorder-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/extract-pages</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-editor</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-flowchart</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-to-excel</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/sign-pdf</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/crop-pdf</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/flatten-pdf</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/repair-pdf</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-metadata</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
<!-- Image Tools -->
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-converter</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-resize</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/compress-image</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/remove-background</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-crop</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-rotate-flip</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
|
||||
<!-- AI Tools -->
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/ocr</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/chat-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/summarize-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/translate-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/extract-tables</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
@@ -214,32 +288,56 @@
|
||||
<!-- Utility Tools -->
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/html-to-pdf</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/qr-code</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/video-to-gif</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/word-counter</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/text-cleaner</loc>
|
||||
<lastmod>2026-03-14</lastmod>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-to-pptx</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/excel-to-pdf</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pptx-to-pdf</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/barcode-generator</loc>
|
||||
<lastmod>2026-03-17</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
</urlset>
|
||||
35
frontend/public/social-preview.svg
Normal file
35
frontend/public/social-preview.svg
Normal file
@@ -0,0 +1,35 @@
|
||||
<svg width="1200" height="630" viewBox="0 0 1200 630" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="1200" height="630" fill="#F8FAFC"/>
|
||||
<rect x="40" y="40" width="1120" height="550" rx="36" fill="url(#bg)"/>
|
||||
<circle cx="964" cy="138" r="132" fill="#DBEAFE" fill-opacity="0.9"/>
|
||||
<circle cx="1040" cy="506" r="96" fill="#D1FAE5" fill-opacity="0.85"/>
|
||||
<circle cx="182" cy="518" r="122" fill="#FCE7F3" fill-opacity="0.9"/>
|
||||
<rect x="118" y="150" width="114" height="114" rx="28" fill="#0F172A"/>
|
||||
<path d="M154 206C154 182.804 172.804 164 196 164H208C231.196 164 250 182.804 250 206V208C250 231.196 231.196 250 208 250H196C172.804 250 154 231.196 154 208V206Z" fill="#38BDF8"/>
|
||||
<path d="M172 196C172 186.059 180.059 178 190 178H202C211.941 178 220 186.059 220 196V218C220 227.941 211.941 236 202 236H190C180.059 236 172 227.941 172 218V196Z" fill="#E0F2FE"/>
|
||||
<text x="278" y="205" fill="#E2E8F0" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="700" letter-spacing="6">DOCIVA</text>
|
||||
<text x="118" y="304" fill="white" font-family="Inter, Arial, sans-serif" font-size="64" font-weight="800">Online PDF, Image, and AI Tools</text>
|
||||
<text x="118" y="360" fill="#CBD5E1" font-family="Inter, Arial, sans-serif" font-size="28" font-weight="500">Convert, compress, edit, OCR, and automate document workflows from one workspace.</text>
|
||||
<g>
|
||||
<rect x="118" y="420" width="256" height="54" rx="27" fill="#0EA5E9"/>
|
||||
<text x="160" y="454" fill="white" font-family="Inter, Arial, sans-serif" font-size="24" font-weight="700">dociva.io</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="118" y="500" width="220" height="40" rx="20" fill="#FFFFFF" fill-opacity="0.12"/>
|
||||
<text x="143" y="526" fill="#E2E8F0" font-family="Inter, Arial, sans-serif" font-size="20" font-weight="600">No signup required</text>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="770" y="210" width="270" height="186" rx="28" fill="#FFFFFF" fill-opacity="0.12" stroke="#E2E8F0" stroke-opacity="0.16"/>
|
||||
<rect x="800" y="246" width="150" height="16" rx="8" fill="#E0F2FE"/>
|
||||
<rect x="800" y="282" width="204" height="16" rx="8" fill="#C7D2FE"/>
|
||||
<rect x="800" y="318" width="126" height="16" rx="8" fill="#A7F3D0"/>
|
||||
<rect x="800" y="354" width="174" height="16" rx="8" fill="#FDE68A"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="40" y1="40" x2="1160" y2="590" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0F172A"/>
|
||||
<stop offset="0.55" stop-color="#111827"/>
|
||||
<stop offset="1" stop-color="#0F766E"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -1,6 +1,6 @@
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildLanguageAlternates, getOgLocale } from '@/utils/seo';
|
||||
import { buildLanguageAlternates, buildSocialImageUrl, getOgLocale } from '@/utils/seo';
|
||||
|
||||
const SITE_NAME = 'Dociva';
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function SEOHead({ title, description, path, type = 'website', js
|
||||
const { i18n } = useTranslation();
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const canonicalUrl = `${origin}${path}`;
|
||||
const socialImageUrl = buildSocialImageUrl(origin);
|
||||
const fullTitle = `${title} — ${SITE_NAME}`;
|
||||
const languageAlternates = buildLanguageAlternates(origin, path);
|
||||
const currentOgLocale = getOgLocale(i18n.language);
|
||||
@@ -55,6 +56,8 @@ export default function SEOHead({ title, description, path, type = 'website', js
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:type" content={type} />
|
||||
<meta property="og:site_name" content={SITE_NAME} />
|
||||
<meta property="og:image" content={socialImageUrl} />
|
||||
<meta property="og:image:alt" content={`${fullTitle} social preview`} />
|
||||
<meta property="og:locale" content={currentOgLocale} />
|
||||
{languageAlternates
|
||||
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
|
||||
@@ -63,9 +66,11 @@ export default function SEOHead({ title, description, path, type = 'website', js
|
||||
))}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={fullTitle} />
|
||||
<meta name="twitter:description" content={description} />
|
||||
<meta name="twitter:image" content={socialImageUrl} />
|
||||
<meta name="twitter:image:alt" content={`${fullTitle} social preview`} />
|
||||
|
||||
{/* JSON-LD Structured Data */}
|
||||
{schemas.map((schema, i) => (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Helmet } from 'react-helmet-async';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle } from 'lucide-react';
|
||||
import { getToolSEO } from '@/config/seoData';
|
||||
import { buildLanguageAlternates, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo';
|
||||
import { buildLanguageAlternates, buildSocialImageUrl, generateToolSchema, generateBreadcrumbs, generateFAQ, getOgLocale } from '@/utils/seo';
|
||||
import FAQSection from './FAQSection';
|
||||
import RelatedTools from './RelatedTools';
|
||||
import ToolRating from '@/components/shared/ToolRating';
|
||||
@@ -40,6 +40,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const path = `/tools/${slug}`;
|
||||
const canonicalUrl = `${origin}${path}`;
|
||||
const socialImageUrl = buildSocialImageUrl(origin);
|
||||
const languageAlternates = buildLanguageAlternates(origin, path);
|
||||
const currentOgLocale = getOgLocale(i18n.language);
|
||||
|
||||
@@ -82,6 +83,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
<meta property="og:description" content={seo.metaDescription} />
|
||||
<meta property="og:url" content={canonicalUrl} />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:image" content={socialImageUrl} />
|
||||
<meta property="og:image:alt" content={`${toolTitle} social preview`} />
|
||||
<meta property="og:locale" content={currentOgLocale} />
|
||||
{languageAlternates
|
||||
.filter((alternate) => alternate.ogLocale !== currentOgLocale)
|
||||
@@ -90,9 +93,11 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps
|
||||
))}
|
||||
|
||||
{/* Twitter */}
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content={`${toolTitle} — ${seo.titleSuffix}`} />
|
||||
<meta name="twitter:description" content={seo.metaDescription} />
|
||||
<meta name="twitter:image" content={socialImageUrl} />
|
||||
<meta name="twitter:image:alt" content={`${toolTitle} social preview`} />
|
||||
|
||||
{/* Structured Data */}
|
||||
<script type="application/ld+json">{JSON.stringify(toolSchema)}</script>
|
||||
|
||||
@@ -37,17 +37,28 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasReliableUsageStats = stats.total_files_processed >= 25;
|
||||
const hasReliableRating = stats.rating_count >= 3;
|
||||
|
||||
const topTools = stats.top_tools.slice(0, 3).map((tool) => {
|
||||
const seo = getToolSEO(tool.tool);
|
||||
return seo ? t(`tools.${seo.i18nKey}.title`) : tool.tool;
|
||||
});
|
||||
|
||||
const cards = [
|
||||
{ label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() },
|
||||
{ label: t('socialProof.successRate'), value: `${stats.success_rate}%` },
|
||||
{ label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() },
|
||||
{ label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` },
|
||||
];
|
||||
hasReliableUsageStats
|
||||
? { label: t('socialProof.processedFiles'), value: stats.total_files_processed.toLocaleString() }
|
||||
: null,
|
||||
hasReliableUsageStats
|
||||
? { label: t('socialProof.successRate'), value: `${stats.success_rate}%` }
|
||||
: null,
|
||||
hasReliableUsageStats
|
||||
? { label: t('socialProof.last24h'), value: stats.files_last_24h.toLocaleString() }
|
||||
: null,
|
||||
hasReliableRating
|
||||
? { label: t('socialProof.averageRating'), value: `${stats.average_rating.toFixed(1)} / 5` }
|
||||
: null,
|
||||
].filter((card): card is { label: string; value: string } => Boolean(card));
|
||||
|
||||
return (
|
||||
<section className={`rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70 ${className}`.trim()}>
|
||||
@@ -73,6 +84,7 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cards.length > 0 ? (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:min-w-[420px]">
|
||||
{cards.map((card) => (
|
||||
<div key={card.label} className="rounded-2xl bg-slate-50 p-4 dark:bg-slate-800/70">
|
||||
@@ -81,12 +93,22 @@ export default function SocialProofStrip({ className = '' }: SocialProofStripPro
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl bg-slate-50 p-5 text-sm leading-7 text-slate-600 dark:bg-slate-800/70 dark:text-slate-300 lg:max-w-md">
|
||||
{t(
|
||||
'socialProof.pendingSummary',
|
||||
'Public activity metrics appear here after we collect enough completed jobs and verified ratings.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 border-t border-slate-200 pt-4 sm:flex-row sm:items-center sm:justify-between dark:border-slate-700">
|
||||
<p className="inline-flex items-center gap-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
<Star className="h-4 w-4 text-amber-500" />
|
||||
{t('socialProof.basedOnRatings', { count: stats.rating_count })}
|
||||
{hasReliableRating
|
||||
? t('socialProof.basedOnRatings', { count: stats.rating_count })
|
||||
: t('socialProof.pendingRatings', 'Ratings summary will unlock after enough verified feedback.')}
|
||||
</p>
|
||||
<Link to="/developers" className="text-sm font-semibold text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
{t('socialProof.viewDevelopers')}
|
||||
|
||||
@@ -22,15 +22,14 @@ const ENDPOINT_GROUPS = [
|
||||
},
|
||||
];
|
||||
|
||||
const CURL_UPLOAD = `curl -X POST https://your-domain.example/api/v1/convert/pdf-to-word \\
|
||||
-H "X-API-Key: spdf_your_api_key" \\
|
||||
-F "file=@./sample.pdf"`;
|
||||
|
||||
const CURL_POLL = `curl https://your-domain.example/api/v1/tasks/<task_id>/status \\
|
||||
-H "X-API-Key: spdf_your_api_key"`;
|
||||
|
||||
export default function DevelopersPage() {
|
||||
const { t } = useTranslation();
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://dociva.io';
|
||||
const curlUpload = `curl -X POST ${origin}/api/v1/convert/pdf-to-word \\
|
||||
-H "X-API-Key: spdf_your_api_key" \\
|
||||
-F "file=@./sample.pdf"`;
|
||||
const curlPoll = `curl ${origin}/api/tasks/<task_id>/status \\
|
||||
-H "X-API-Key: spdf_your_api_key"`;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -91,12 +90,12 @@ export default function DevelopersPage() {
|
||||
<article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.authExampleTitle')}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.authExampleSubtitle')}</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-sky-100"><code>{CURL_UPLOAD}</code></pre>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-sky-100"><code>{curlUpload}</code></pre>
|
||||
</article>
|
||||
<article className="rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">{t('pages.developers.pollExampleTitle')}</h2>
|
||||
<p className="mt-2 text-sm leading-7 text-slate-600 dark:text-slate-400">{t('pages.developers.pollExampleSubtitle')}</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100"><code>{CURL_POLL}</code></pre>
|
||||
<pre className="mt-4 overflow-x-auto rounded-2xl bg-slate-950 p-4 text-sm text-emerald-100"><code>{curlPoll}</code></pre>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export interface LanguageAlternate {
|
||||
ogLocale: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SOCIAL_IMAGE_PATH = '/social-preview.svg';
|
||||
|
||||
const LANGUAGE_CONFIG: Record<'en' | 'ar' | 'fr', { hrefLang: string; ogLocale: string }> = {
|
||||
en: { hrefLang: 'en', ogLocale: 'en_US' },
|
||||
ar: { hrefLang: 'ar', ogLocale: 'ar_SA' },
|
||||
@@ -42,6 +44,10 @@ export function buildLanguageAlternates(origin: string, path: string): LanguageA
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildSocialImageUrl(origin: string): string {
|
||||
return `${origin}${DEFAULT_SOCIAL_IMAGE_PATH}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate WebApplication JSON-LD structured data for a tool page.
|
||||
*/
|
||||
|
||||
@@ -11,7 +11,9 @@ Usage:
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ─── Route definitions with priority and changefreq ──────────────────────────
|
||||
|
||||
@@ -95,9 +97,21 @@ TOOL_GROUPS = [
|
||||
]
|
||||
|
||||
|
||||
def get_blog_slugs() -> list[str]:
|
||||
repo_root = Path(__file__).resolve().parents[1]
|
||||
blog_articles_path = repo_root / 'frontend' / 'src' / 'content' / 'blogArticles.ts'
|
||||
|
||||
if not blog_articles_path.exists():
|
||||
return []
|
||||
|
||||
content = blog_articles_path.read_text(encoding='utf-8')
|
||||
return list(dict.fromkeys(re.findall(r"slug:\s*'([^']+)'", content)))
|
||||
|
||||
|
||||
def generate_sitemap(domain: str) -> str:
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
urls = []
|
||||
blog_slugs = get_blog_slugs()
|
||||
|
||||
# Static pages
|
||||
for page in PAGES:
|
||||
@@ -108,6 +122,16 @@ def generate_sitemap(domain: str) -> str:
|
||||
<priority>{page["priority"]}</priority>
|
||||
</url>''')
|
||||
|
||||
if blog_slugs:
|
||||
urls.append('\n <!-- Blog Posts -->')
|
||||
for slug in blog_slugs:
|
||||
urls.append(f''' <url>
|
||||
<loc>{domain}/blog/{slug}</loc>
|
||||
<lastmod>{today}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>''')
|
||||
|
||||
# Tool pages by category
|
||||
for label, routes in TOOL_GROUPS:
|
||||
urls.append(f'\n <!-- {label} -->')
|
||||
@@ -143,7 +167,7 @@ def main():
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
f.write(sitemap)
|
||||
|
||||
total = len(PAGES) + sum(len(routes) for _, routes in TOOL_GROUPS)
|
||||
total = len(PAGES) + len(get_blog_slugs()) + sum(len(routes) for _, routes in TOOL_GROUPS)
|
||||
print(f"Sitemap generated: {args.output}")
|
||||
print(f"Total URLs: {total}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user