diff --git a/.env.example b/.env.example index 5f1222d..32e4aee 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,10 @@ INDEXNOW_AUTO_SUBMIT=true INDEXNOW_STRICT=false INDEXNOW_FULL_SUBMIT=false +# Gitea (optional) +GITEA_DOMAIN= +GITEA_ROOT_URL= + # Frontend Analytics / Ads (Vite) VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX VITE_PLAUSIBLE_DOMAIN=dociva.io diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3b3ba36..2e54de6 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -181,6 +181,26 @@ services: - frontend_build:/app/dist - indexnow_state:/app/.indexnow + # --- Gitea (self-hosted Git) --- + gitea: + image: gitea/gitea:latest + restart: always + environment: + - USER_UID=1000 + - USER_GID=1000 + # Expose the correct SSH port to users (host maps 2222 -> container 22) + - GITEA__server__SSH_PORT=2222 + # Optional: set these in .env for correct clone URLs + - GITEA__server__DOMAIN=${GITEA_DOMAIN:-} + - GITEA__server__ROOT_URL=${GITEA_ROOT_URL:-} + volumes: + - gitea_data:/data + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + ports: + - "3000:3000" + - "2222:22" + volumes: postgres_data: redis_data: @@ -189,3 +209,4 @@ volumes: db_data: frontend_build: indexnow_state: + gitea_data: diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml index 53fdd47..6ab5cf3 100644 --- a/frontend/public/sitemap.xml +++ b/frontend/public/sitemap.xml @@ -2,22 +2,22 @@ https://dociva.io/sitemaps/static.xml - 2026-04-04 + 2026-04-05 https://dociva.io/sitemaps/blog.xml - 2026-04-04 + 2026-04-05 https://dociva.io/sitemaps/tools.xml - 2026-04-04 + 2026-04-05 https://dociva.io/sitemaps/seo.xml - 2026-04-04 + 2026-04-05 https://dociva.io/sitemaps/comparisons.xml - 2026-04-04 + 2026-04-05 diff --git a/frontend/public/sitemaps/blog.xml b/frontend/public/sitemaps/blog.xml index 2fa54f3..e726b1c 100644 --- a/frontend/public/sitemaps/blog.xml +++ b/frontend/public/sitemaps/blog.xml @@ -2,31 +2,31 @@ https://dociva.io/blog/how-to-compress-pdf-online - 2026-04-04 + 2026-04-05 monthly 0.6 https://dociva.io/blog/convert-images-without-losing-quality - 2026-04-04 + 2026-04-05 monthly 0.6 https://dociva.io/blog/ocr-extract-text-from-images - 2026-04-04 + 2026-04-05 monthly 0.6 https://dociva.io/blog/merge-split-pdf-files - 2026-04-04 + 2026-04-05 monthly 0.6 https://dociva.io/blog/ai-chat-with-pdf-documents - 2026-04-04 + 2026-04-05 monthly 0.6 diff --git a/frontend/public/sitemaps/comparisons.xml b/frontend/public/sitemaps/comparisons.xml index 3d9bcd0..5b6da8b 100644 --- a/frontend/public/sitemaps/comparisons.xml +++ b/frontend/public/sitemaps/comparisons.xml @@ -2,31 +2,31 @@ https://dociva.io/compare/compress-pdf-vs-ilovepdf - 2026-04-04 + 2026-04-05 monthly 0.7 https://dociva.io/compare/merge-pdf-vs-smallpdf - 2026-04-04 + 2026-04-05 monthly 0.7 https://dociva.io/compare/pdf-to-word-vs-adobe-acrobat - 2026-04-04 + 2026-04-05 monthly 0.7 https://dociva.io/compare/compress-image-vs-tinypng - 2026-04-04 + 2026-04-05 monthly 0.7 https://dociva.io/compare/ocr-vs-adobe-scan - 2026-04-04 + 2026-04-05 monthly 0.7 diff --git a/frontend/public/sitemaps/seo.xml b/frontend/public/sitemaps/seo.xml index d98db99..b0b4b60 100644 --- a/frontend/public/sitemaps/seo.xml +++ b/frontend/public/sitemaps/seo.xml @@ -2,1129 +2,1129 @@ https://dociva.io/pdf-to-word - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-word - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/word-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/word-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/compress-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/compress-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/convert-jpg-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/convert-jpg-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/merge-pdf-files - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/merge-pdf-files - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/remove-pdf-password - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/remove-pdf-password - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-word-editable - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-word-editable - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/convert-pdf-to-text - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/convert-pdf-to-text - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/split-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/split-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/jpg-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/jpg-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/png-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/png-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/images-to-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/images-to-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-jpg - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-jpg - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-png - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-png - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/compress-pdf-for-email - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/compress-pdf-for-email - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/compress-scanned-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/compress-scanned-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/merge-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/merge-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/combine-pdf-files - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/combine-pdf-files - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/extract-pages-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/extract-pages-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/reorder-pdf-pages - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/reorder-pdf-pages - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/rotate-pdf-pages - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/rotate-pdf-pages - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/add-page-numbers-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/add-page-numbers-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/protect-pdf-with-password - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/protect-pdf-with-password - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/unlock-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/unlock-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/watermark-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/watermark-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/remove-watermark-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/remove-watermark-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/edit-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/edit-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-excel-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-excel-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/extract-tables-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/extract-tables-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/html-to-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/html-to-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/scan-pdf-to-text - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/scan-pdf-to-text - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/chat-with-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/chat-with-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/summarize-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/summarize-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/translate-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/translate-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/convert-image-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/convert-image-to-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/convert-webp-to-jpg - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/convert-webp-to-jpg - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/resize-image-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/resize-image-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/compress-image-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/compress-image-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/remove-image-background - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/remove-image-background - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-word-editable-free - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-word-editable-free - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/compress-pdf-to-100kb - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/compress-pdf-to-100kb - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/ai-extract-text-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/ai-extract-text-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-excel-accurate-free - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-excel-accurate-free - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/split-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/split-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/compress-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/compress-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/unlock-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/unlock-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/summarize-pdf-ai - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/summarize-pdf-ai - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/convert-pdf-to-text-ai - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/convert-pdf-to-text-ai - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-jpg-high-quality - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-jpg-high-quality - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/jpg-to-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/jpg-to-pdf-online-free - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/reduce-pdf-size-for-email - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/reduce-pdf-size-for-email - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/ocr-for-scanned-pdfs - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/ocr-for-scanned-pdfs - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/remove-watermark-from-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/remove-watermark-from-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/add-watermark-to-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/add-watermark-to-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/repair-corrupted-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/repair-corrupted-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/rotate-pdf-pages-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/rotate-pdf-pages-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/reorder-pdf-pages-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/reorder-pdf-pages-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-png-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-png-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/images-to-pdf-multiple - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/images-to-pdf-multiple - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/split-pdf-by-range-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/split-pdf-by-range-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/compress-scanned-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/compress-scanned-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-metadata-editor-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-metadata-editor-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/add-page-numbers-to-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/add-page-numbers-to-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/protect-pdf-with-password-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/protect-pdf-with-password-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/unlock-encrypted-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/unlock-encrypted-pdf-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/ocr-table-extraction-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/ocr-table-extraction-from-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-excel-converter-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-excel-converter-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/extract-text-from-protected-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/extract-text-from-protected-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/bulk-convert-pdf-to-word - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/bulk-convert-pdf-to-word - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/compress-pdf-for-web-upload - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/compress-pdf-for-web-upload - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/ocr-multi-language-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/ocr-multi-language-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/summarize-long-pdf-ai - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/summarize-long-pdf-ai - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/convert-pdf-to-ppt-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/convert-pdf-to-ppt-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/pdf-to-pptx-free-online - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/pdf-to-pptx-free-online - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/دمج-ملفات-pdf-مجاناً - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/دمج-ملفات-pdf-مجاناً - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/ضغط-بي-دي-اف-اونلاين - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/ضغط-بي-دي-اف-اونلاين - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/تحويل-pdf-الى-word-قابل-للتعديل - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/تحويل-pdf-الى-word-قابل-للتعديل - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/تحويل-jpg-الى-pdf-اونلاين - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/تحويل-jpg-الى-pdf-اونلاين - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/فصل-صفحات-pdf-اونلاين - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/فصل-صفحات-pdf-اونلاين - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/ازالة-كلمة-مرور-من-pdf - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/ازالة-كلمة-مرور-من-pdf - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/تحويل-pdf-الى-نص-باستخدام-ocr - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/تحويل-pdf-الى-نص-باستخدام-ocr - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/تحويل-pdf-الى-excel-اونلاين - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/تحويل-pdf-الى-excel-اونلاين - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/تحويل-pdf-الى-صور - 2026-04-04 + 2026-04-05 weekly 0.88 https://dociva.io/ar/تحويل-pdf-الى-صور - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/best-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/best-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/free-pdf-tools-online - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/free-pdf-tools-online - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/convert-files-online - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/convert-files-online - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/pdf-converter-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/pdf-converter-tools - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/secure-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/secure-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/ai-document-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/ai-document-tools - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/image-to-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/image-to-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/online-image-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/online-image-tools - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/office-to-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/office-to-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/scanned-document-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/scanned-document-tools - 2026-04-04 + 2026-04-05 weekly 0.74 https://dociva.io/arabic-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.82 https://dociva.io/ar/arabic-pdf-tools - 2026-04-04 + 2026-04-05 weekly 0.74 diff --git a/frontend/public/sitemaps/static.xml b/frontend/public/sitemaps/static.xml index 41dd01c..3788ea7 100644 --- a/frontend/public/sitemaps/static.xml +++ b/frontend/public/sitemaps/static.xml @@ -2,61 +2,61 @@ https://dociva.io/ - 2026-04-04 + 2026-04-05 daily 1.0 https://dociva.io/tools - 2026-04-04 + 2026-04-05 weekly 0.8 https://dociva.io/about - 2026-04-04 + 2026-04-05 monthly 0.4 https://dociva.io/contact - 2026-04-04 + 2026-04-05 monthly 0.4 https://dociva.io/privacy - 2026-04-04 + 2026-04-05 yearly 0.3 https://dociva.io/terms - 2026-04-04 + 2026-04-05 yearly 0.3 https://dociva.io/pricing - 2026-04-04 + 2026-04-05 monthly 0.7 https://dociva.io/pricing-transparency - 2026-04-04 + 2026-04-05 monthly 0.7 https://dociva.io/blog - 2026-04-04 + 2026-04-05 weekly 0.6 https://dociva.io/developers - 2026-04-04 + 2026-04-05 monthly 0.5 diff --git a/frontend/src/components/seo/ToolLandingPage.tsx b/frontend/src/components/seo/ToolLandingPage.tsx index 57bd929..edf2ac2 100644 --- a/frontend/src/components/seo/ToolLandingPage.tsx +++ b/frontend/src/components/seo/ToolLandingPage.tsx @@ -39,15 +39,26 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps const toolTitle = t(`tools.${seo.i18nKey}.title`); const toolDesc = t(`tools.${seo.i18nKey}.description`); + const localizedTitleSuffix = i18n.exists(`seo.${seo.i18nKey}.metaTitleSuffix`) + ? t(`seo.${seo.i18nKey}.metaTitleSuffix`) + : seo.titleSuffix; + const localizedMetaDescription = i18n.exists(`seo.${seo.i18nKey}.metaDescription`) + ? t(`seo.${seo.i18nKey}.metaDescription`) + : seo.metaDescription; + const localizedFaqData = t(`seo.${seo.i18nKey}.faq`, { returnObjects: true }) as SEOFAQ[]; + const localizedFaqs = Array.isArray(localizedFaqData) && localizedFaqData.length > 0 + ? localizedFaqData.map((faq) => ({ question: faq.q, answer: faq.a })) + : seo.faqs; const origin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : ''); const path = `/tools/${slug}`; const canonicalUrl = `${origin}${path}`; const socialImageUrl = buildSocialImageUrl(origin); const currentOgLocale = getOgLocale(i18n.language); + const metaTitle = `${toolTitle} — ${localizedTitleSuffix}`; const toolSchema = generateToolSchema({ name: toolTitle, - description: seo.metaDescription, + description: localizedMetaDescription, url: canonicalUrl, category: seo.category === 'PDF' ? 'UtilitiesApplication' : 'WebApplication', ratingValue: ratingData.average, @@ -60,12 +71,12 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps { name: toolTitle, url: canonicalUrl }, ]); - const faqSchema = seo.faqs.length > 0 ? generateFAQ(seo.faqs) : null; + const faqSchema = localizedFaqs.length > 0 ? generateFAQ(localizedFaqs) : null; const howToSteps = t(`seo.${seo.i18nKey}.howToUse`, { returnObjects: true }) as string[]; const howToSchema = Array.isArray(howToSteps) && howToSteps.length > 0 ? generateHowTo({ name: toolTitle, - description: seo.metaDescription, + description: localizedMetaDescription, steps: howToSteps, url: canonicalUrl, }) @@ -74,14 +85,14 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps return ( <> - {toolTitle} — {seo.titleSuffix} | {t('common.appName')} - + {metaTitle} | {t('common.appName')} + {/* Open Graph */} - - + + @@ -90,8 +101,8 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps {/* Twitter */} - - + + @@ -208,11 +219,7 @@ export default function ToolLandingPage({ slug, children }: ToolLandingPageProps {/* FAQ Section */} {(() => { - const faqData = t(`seo.${seo.i18nKey}.faq`, { returnObjects: true }) as SEOFAQ[]; - const faqs = Array.isArray(faqData) - ? faqData.map((f) => ({ question: f.q, answer: f.a })) - : []; - return ; + return ; })()} {/* Related Tools */} diff --git a/frontend/src/config/seoData.ts b/frontend/src/config/seoData.ts index a07fb1a..e034f18 100644 --- a/frontend/src/config/seoData.ts +++ b/frontend/src/config/seoData.ts @@ -79,11 +79,11 @@ export const TOOLS_SEO: ToolSEO[] = [ { i18nKey: 'compressPdf', slug: 'compress-pdf', - titleSuffix: 'Free Online PDF Compressor — Reduce File Size', - metaDescription: 'Compress PDF files online for free. Reduce PDF size by up to 90% while maintaining quality. Fast and secure PDF compression.', + titleSuffix: 'Free Online PDF Compressor to Reduce PDF File Size', + metaDescription: 'Compress PDF files online for free. Reduce PDF file size for email, uploads, and sharing while keeping text readable and document quality under control.', category: 'PDF', relatedSlugs: ['merge-pdf', 'split-pdf', 'pdf-to-word', 'compress-image'], - keywords: 'compress pdf, reduce pdf size, pdf compressor, shrink pdf, make pdf smaller', + keywords: 'compress pdf, pdf compressor, reduce pdf file size, compress pdf online, make pdf smaller', features: [ 'Reduce PDF file size by up to 90%', 'Choose your compression level (low, medium, high)', @@ -92,10 +92,10 @@ export const TOOLS_SEO: ToolSEO[] = [ 'Process files securely on our servers', ], faqs: [ - { question: 'How does PDF compression work?', answer: 'Our tool optimizes images, removes unnecessary metadata, and compresses internal structures to reduce file size while maintaining visual quality.' }, - { question: 'Will compression affect text quality?', answer: 'No. Text remains crisp and searchable. Mainly images within the PDF are optimized to reduce file size.' }, - { question: 'How much can I reduce my PDF size?', answer: 'Depending on the content, you can typically reduce file size by 50-90%, especially for PDFs with many images.' }, - { question: 'Is there a file size limit?', answer: 'You can compress PDFs up to 20MB in size.' }, + { question: 'How do I compress a PDF online?', answer: 'Upload your PDF, choose the compression level you want, start the process, and download the smaller PDF when it is ready.' }, + { question: 'How can I make a PDF smaller for email or uploads?', answer: 'Use the balanced or maximum compression setting to reduce PDF file size until it fits common email and upload limits.' }, + { question: 'Will PDF compression reduce quality?', answer: 'Compression mainly optimizes images and embedded assets. Text usually stays sharp and searchable, while visual quality depends on the compression level you choose.' }, + { question: 'Does compression change my original PDF?', answer: 'No. The tool creates a compressed copy for download and leaves your original file unchanged.' }, ], }, { @@ -123,11 +123,11 @@ export const TOOLS_SEO: ToolSEO[] = [ { i18nKey: 'splitPdf', slug: 'split-pdf', - titleSuffix: 'Free Online PDF Splitter — Extract Pages', - metaDescription: 'Split PDF files into individual pages or extract specific page ranges online for free. Fast, secure, and no signup needed.', + titleSuffix: 'Free Online PDF Splitter to Split or Separate PDF Pages', + metaDescription: 'Split PDF files online for free. Use this PDF splitter to separate PDF pages, cut page ranges, or save selected pages into smaller PDF files without losing quality.', category: 'PDF', relatedSlugs: ['merge-pdf', 'extract-pages', 'rotate-pdf', 'reorder-pdf'], - keywords: 'split pdf, pdf splitter, extract pages from pdf, separate pdf pages, divide pdf', + keywords: 'split pdf, pdf splitter, separate pdf pages, split pdf online, pdf cutter, divide pdf', features: [ 'Split a PDF into individual pages', 'Extract specific page ranges', @@ -136,9 +136,10 @@ export const TOOLS_SEO: ToolSEO[] = [ 'Secure — files deleted after processing', ], faqs: [ - { question: 'How do I split a PDF?', answer: 'Upload your PDF, specify the pages or ranges you want to extract, and click split. Download the resulting PDF instantly.' }, - { question: 'Can I extract specific pages?', answer: 'Yes, you can specify individual pages (e.g., 1, 3, 5) or ranges (e.g., 1-5) to extract.' }, - { question: 'Is splitting a PDF free?', answer: 'Yes, our PDF splitter is completely free with no limitations.' }, + { question: 'How do I split a PDF online?', answer: 'Upload your PDF, choose whether to split every page or only selected page ranges, then download the new PDF files created from your document.' }, + { question: 'Can I separate PDF pages without splitting the whole file?', answer: 'Yes. You can enter exact page numbers or ranges so only the pages you want are saved into new files.' }, + { question: 'Will splitting a PDF reduce quality?', answer: 'No. Splitting is a structural change, so the pages keep their original quality and layout.' }, + { question: 'What is the difference between Split PDF and Extract Pages?', answer: 'Split PDF is best when you want separate output files or broad page separation. Extract Pages is better when you want selected pages combined into one new PDF.' }, ], }, { @@ -315,11 +316,11 @@ export const TOOLS_SEO: ToolSEO[] = [ { i18nKey: 'extractPages', slug: 'extract-pages', - titleSuffix: 'Free Online PDF Page Extractor', - metaDescription: 'Extract specific pages from a PDF into a new document online for free. Select the exact pages you need.', + titleSuffix: 'Free Online PDF Page Extractor to Extract Pages from PDF', + metaDescription: 'Extract pages from PDF online for free. Select exact page numbers or ranges to create a new PDF with only the pages you need.', category: 'PDF', relatedSlugs: ['split-pdf', 'merge-pdf', 'reorder-pdf', 'compress-pdf'], - keywords: 'extract pdf pages, pdf page extractor, select pages from pdf, copy pdf pages', + keywords: 'extract pages from pdf, pdf page extractor, extract pdf pages, pdf extractor, save selected pages from pdf', features: [ 'Extract specific pages from any PDF', 'Select individual pages or ranges', @@ -327,8 +328,10 @@ export const TOOLS_SEO: ToolSEO[] = [ 'Free and completely secure', ], faqs: [ - { question: 'How do I extract pages from a PDF?', answer: 'Upload your PDF, select the pages you want (e.g., 1, 3, 5-10), and download the new PDF containing only those pages.' }, - { question: 'What is the difference between Split and Extract?', answer: 'Split divides a PDF at a specific point, while Extract lets you pick any combination of pages.' }, + { question: 'How do I extract pages from a PDF?', answer: 'Upload your PDF, enter the pages or ranges you want to keep, and download the new PDF containing only those selected pages.' }, + { question: 'Can I extract multiple non-consecutive pages?', answer: 'Yes. You can extract pages like 1,3,7 as well as ranges such as 5-10 in the same request.' }, + { question: 'What is the difference between Extract Pages and Split PDF?', answer: 'Extract Pages creates one new PDF from the exact pages you choose. Split PDF is better when you want broader page separation or multiple outputs.' }, + { question: 'Will the original PDF stay unchanged?', answer: 'Yes. The original file is not edited. The tool creates a separate PDF that contains only the extracted pages.' }, ], }, { diff --git a/frontend/src/i18n/ar.json b/frontend/src/i18n/ar.json index 372549f..9227556 100644 --- a/frontend/src/i18n/ar.json +++ b/frontend/src/i18n/ar.json @@ -673,7 +673,7 @@ }, "compressPdf": { "title": "ضغط PDF", - "description": "قلّل حجم ملف PDF مع الحفاظ على الجودة. اختر مستوى الضغط.", + "description": "اضغط ملفات PDF عبر الإنترنت وقلّل الحجم مع الحفاظ على وضوح المحتوى.", "shortDesc": "ضغط PDF", "qualityLow": "أقصى ضغط", "qualityMedium": "متوازن", @@ -768,7 +768,7 @@ }, "splitPdf": { "title": "تقسيم PDF", - "description": "قسّم ملف PDF إلى صفحات فردية أو استخرج نطاقات صفحات محددة.", + "description": "قسّم صفحات PDF عبر الإنترنت أو افصل نطاقات صفحات محددة في ملفات جديدة.", "shortDesc": "تقسيم PDF", "allPages": "كل الصفحات", "allPagesDesc": "استخراج كل صفحة في ملف PDF مستقل", @@ -1029,7 +1029,7 @@ }, "extractPages": { "title": "استخراج صفحات PDF", - "description": "استخرج صفحات محددة من PDF إلى مستند جديد.", + "description": "استخرج صفحات من PDF إلى ملف جديد باستخدام أرقام صفحات أو نطاقات دقيقة.", "shortDesc": "استخراج الصفحات", "pagesLabel": "الصفحات المطلوبة", "pagesPlaceholder": "مثال: 1,3,5-8", @@ -1335,15 +1335,17 @@ ] }, "compressPdf": { - "whatItDoes": "قلّل حجم ملفات PDF بنسبة تصل إلى 90% مع الحفاظ على قابلية القراءة والجودة العالية. اختر من بين ثلاثة مستويات ضغط لتحقيق التوازن بين الجودة وحجم الملف.", - "howToUse": ["ارفع ملف PDF إلى أداة الضغط.", "اختر مستوى الضغط المفضل: أقصى ضغط، متوازن، أو جودة عالية.", "انقر ضغط وانتظر المعالجة.", "حمّل ملف PDF المضغوط بحجم أصغر بكثير."], - "benefits": ["تقليل حجم الملف بنسبة تصل إلى 90%", "ثلاثة مستويات ضغط للاختيار", "النص يبقى واضحاً وقابلاً للبحث", "مثالي لمرفقات البريد الإلكتروني", "مجاني بدون تسجيل"], - "useCases": ["تصغير ملفات PDF الكبيرة لإرسالها بالبريد الإلكتروني", "تقليل مساحة التخزين للمستندات المؤرشفة", "تسريع رفع ملفات PDF على المواقع", "تحسين ملفات PDF للعرض على الهاتف", "تحضير المستندات للنشر على الويب"], + "metaTitleSuffix": "أداة مجانية عبر الإنترنت لضغط PDF وتقليل حجم الملف", + "metaDescription": "اضغط ملفات PDF عبر الإنترنت مجاناً. قلّل حجم ملف PDF للبريد الإلكتروني والرفع والمشاركة مع الحفاظ على وضوح النص وجودة مناسبة.", + "whatItDoes": "استخدم أداة ضغط PDF هذه لتقليل حجم ملفات PDF قبل إرسالها بالبريد الإلكتروني أو رفعها أو أرشفتها. تقوم الأداة بتحسين الصور وبنية الملف مع الحفاظ على وضوح النص وسهولة القراءة.", + "howToUse": ["ارفع ملف PDF الذي تريد ضغطه.", "اختر مستوى الضغط المناسب: أقصى ضغط أو متوازن أو جودة عالية.", "ابدأ الضغط وانتظر إنشاء الملف الأصغر.", "حمّل ملف PDF المضغوط وشاركه أو ارفعه مباشرة."], + "benefits": ["تصغير ملفات PDF الكبيرة لمرفقات البريد ونماذج الرفع", "اختيار توازن مناسب بين الحجم الصغير والجودة البصرية", "الحفاظ على النص واضحاً وقابلاً للبحث بعد الضغط", "العمل مباشرة من المتصفح بدون تسجيل", "معالجة آمنة مع حذف تلقائي للملفات"], + "useCases": ["تقليل حجم PDF قبل إرساله كمرفق بريد إلكتروني", "تجاوز حدود الرفع في النماذج والمنصات المختلفة", "تصغير ملفات PDF الممسوحة ضوئياً والغنية بالصور", "توفير مساحة التخزين للملفات المؤرشفة", "تجهيز ملفات PDF لتنزيل أسرع على الهاتف"], "faq": [ - {"q": "كيف يعمل ضغط PDF؟", "a": "تقوم الأداة بتحسين الصور وإزالة البيانات الوصفية غير الضرورية وضغط الهياكل الداخلية لتقليل حجم الملف مع الحفاظ على الجودة المرئية."}, - {"q": "هل سيؤثر الضغط على جودة النص؟", "a": "لا. يبقى النص واضحاً وقابلاً للبحث. يتم تحسين الصور بشكل أساسي لتقليل الحجم."}, - {"q": "كم يمكنني تقليل حجم PDF؟", "a": "حسب المحتوى، يمكنك عادةً تقليل الحجم بنسبة 50-90%، خاصةً للملفات التي تحتوي على صور كثيرة."}, - {"q": "هل يوجد حد لحجم الملف؟", "a": "يمكنك ضغط ملفات PDF بحجم يصل إلى 20 ميجابايت."} + {"q": "كيف أضغط ملف PDF عبر الإنترنت؟", "a": "ارفع ملف PDF، اختر مستوى الضغط المطلوب، ابدأ المعالجة، ثم حمّل الملف الأصغر عندما يصبح جاهزاً."}, + {"q": "كيف أجعل ملف PDF أصغر للبريد الإلكتروني أو الرفع؟", "a": "استخدم الإعداد المتوازن أو أقصى ضغط لتقليل حجم ملف PDF حتى يناسب حدود البريد الإلكتروني أو الرفع الشائعة."}, + {"q": "هل يقلل ضغط PDF من الجودة؟", "a": "يركز الضغط بشكل أساسي على تحسين الصور والعناصر المضمنة. يبقى النص غالباً واضحاً وقابلاً للبحث، بينما تعتمد الجودة البصرية على مستوى الضغط الذي تختاره."}, + {"q": "هل يغيّر الضغط ملف PDF الأصلي؟", "a": "لا. تنشئ الأداة نسخة مضغوطة للتحميل وتترك الملف الأصلي بدون تغيير."} ] }, "mergePdf": { @@ -1359,14 +1361,17 @@ ] }, "splitPdf": { - "whatItDoes": "قسّم مستند PDF إلى ملفات منفصلة. يمكنك تقسيم كل صفحة إلى ملف فردي أو استخراج نطاقات صفحات محددة. مثالي لعزل أقسام من مستندات كبيرة.", - "howToUse": ["ارفع مستند PDF.", "اختر تقسيم جميع الصفحات أو تحديد صفحات/نطاقات محددة.", "أدخل أرقام الصفحات (مثل 1,3,5-8) لاستخراج صفحات محددة.", "حمّل ملفات PDF الناتجة."], - "benefits": ["تقسيم إلى صفحات فردية أو نطاقات مخصصة", "صيغة بسيطة لنطاقات الصفحات", "بدون فقدان الجودة", "مجاني بدون تسجيل", "يعمل مع أي مستند PDF"], - "useCases": ["استخراج فصل معين من كتاب إلكتروني", "إرسال صفحات محددة فقط لزميل", "تقسيم دليل كبير إلى أقسام", "عزل صفحة واحدة للطباعة", "فصل مستند ممسوح ضوئياً متعدد الصفحات"], + "metaTitleSuffix": "أداة مجانية عبر الإنترنت لتقسيم PDF وفصل الصفحات", + "metaDescription": "قسّم ملفات PDF عبر الإنترنت مجاناً. افصل صفحات PDF أو قص نطاقات صفحات محددة وأنشئ ملفات أصغر بدون فقدان الجودة.", + "whatItDoes": "استخدم أداة تقسيم PDF هذه لتقسيم الصفحات إلى ملفات منفصلة أو لتجزئة مستند طويل إلى أقسام أصغر. يمكنك فصل صفحات PDF صفحة بصفحة أو حفظ النطاقات التي تحتاجها فقط.", + "howToUse": ["ارفع ملف PDF.", "اختر ما إذا كنت تريد تقسيم كل الصفحات أو فصل صفحات أو نطاقات محددة فقط.", "أدخل أرقام الصفحات مثل 1,3,5-8 عندما تريد ناتجاً مخصصاً.", "حمّل ملفات PDF الجديدة التي تم إنشاؤها من الصفحات المختارة."], + "benefits": ["تقسيم صفحات PDF بشكل فردي أو حسب نطاق مخصص", "فصل صفحات PDF بدون تغيير الجودة الأصلية", "إرسال الصفحات المطلوبة فقط بدلاً من المستند الكامل", "معالجة سريعة من المتصفح بدون تسجيل", "مناسب للتقارير والعقود والملفات الممسوحة ضوئياً"], + "useCases": ["تقسيم ملف PDF كبير إلى ملفات أصغر للزملاء أو العملاء", "فصل فصل أو ملحق من تقرير طويل", "قص صفحات محددة من مستند ممسوح ضوئياً", "إنشاء ملفات PDF أصغر لتناسب البريد أو الرفع", "الاحتفاظ بالصفحات المطلوبة فقط للمراجعة أو الطباعة"], "faq": [ - {"q": "كيف أقسّم ملف PDF؟", "a": "ارفع PDF، حدد الصفحات أو النطاقات المطلوبة، وانقر تقسيم. حمّل PDF الناتج فوراً."}, - {"q": "هل يمكنني استخراج صفحات محددة؟", "a": "نعم، يمكنك تحديد صفحات فردية (مثل 1, 3, 5) أو نطاقات (مثل 1-5) للاستخراج."}, - {"q": "هل تقسيم PDF مجاني؟", "a": "نعم، أداة تقسيم PDF مجانية تماماً بدون قيود."} + {"q": "كيف أقسّم ملف PDF عبر الإنترنت؟", "a": "ارفع ملف PDF، اختر ما إذا كنت تريد تقسيم كل الصفحات أو نطاقات محددة فقط، ثم حمّل ملفات PDF الجديدة الناتجة من المستند."}, + {"q": "هل يمكنني فصل صفحات PDF بدون تقسيم الملف بالكامل؟", "a": "نعم. يمكنك إدخال أرقام صفحات أو نطاقات دقيقة بحيث يتم حفظ الصفحات المطلوبة فقط في ملفات جديدة."}, + {"q": "هل يؤدي تقسيم PDF إلى تقليل الجودة؟", "a": "لا. تقسيم PDF هو تغيير في بنية الملف فقط، لذلك تحتفظ الصفحات بجودتها وتخطيطها الأصليين."}, + {"q": "ما الفرق بين تقسيم PDF واستخراج الصفحات؟", "a": "تقسيم PDF مناسب عندما تريد ملفات خرج منفصلة أو فصل الصفحات بشكل واسع. أما استخراج الصفحات فهو أفضل عندما تريد دمج الصفحات المختارة في ملف PDF جديد واحد."} ] }, "rotatePdf": { @@ -1469,14 +1474,17 @@ ] }, "extractPages": { - "whatItDoes": "استخرج صفحات محددة من PDF وأنشئ مستنداً جديداً يحتوي فقط على الصفحات التي اخترتها. اختر صفحات فردية أو نطاقات صفحات بصيغة بسيطة.", - "howToUse": ["ارفع مستند PDF.", "أدخل أرقام الصفحات أو النطاقات (مثل 1,3,5-8).", "انقر استخراج لإنشاء PDF جديد.", "حمّل PDF بالصفحات المختارة فقط."], - "benefits": ["استخراج صفحات فردية أو نطاقات", "صيغة بسيطة بفواصل", "المستند الأصلي يبقى بدون تغيير", "مجاني وآمن تماماً", "معالجة سريعة"], - "useCases": ["استخراج فصل واحد من كتاب إلكتروني", "الحصول على صفحات محددة لعرض تقديمي", "إنشاء مستند فرعي للمراجعة", "سحب صفحات من مستند ممسوح ضوئياً متعدد الصفحات", "عزل صفحة مهمة لمشاركتها بشكل منفصل"], + "metaTitleSuffix": "أداة مجانية عبر الإنترنت لاستخراج صفحات من PDF", + "metaDescription": "استخرج صفحات من PDF عبر الإنترنت مجاناً. حدّد أرقام الصفحات أو النطاقات الدقيقة لإنشاء ملف PDF جديد يحتوي فقط على الصفحات المطلوبة.", + "whatItDoes": "تتيح لك أداة استخراج صفحات PDF هذه سحب صفحات محددة من ملف PDF ودمجها في ملف جديد واحد. وهي مناسبة عندما تحتاج إلى استخراج صفحات من PDF بدون تقسيم كل صفحة.", + "howToUse": ["ارفع مستند PDF.", "أدخل الصفحات الدقيقة أو النطاقات التي تريد الاحتفاظ بها مثل 2,4,7-10.", "انقر استخراج لإنشاء PDF جديد يحتوي فقط على تلك الصفحات.", "حمّل ملف PDF المستخرج وشاركه أو أكمل العمل عليه."], + "benefits": ["استخراج الصفحات التي تحتاجها فقط في ملف PDF نظيف واحد", "دعم أرقام الصفحات الدقيقة ونطاقات الصفحات", "ترك ملف PDF الأصلي بدون تغيير", "مفيد للنماذج والعقود والفصول والملفات الممسوحة ضوئياً", "معالجة سريعة وآمنة مع تنظيف تلقائي"], + "useCases": ["إرسال عدة صفحات مطلوبة من حزمة مستندات طويلة", "إنشاء نسخة مراجعة تحتوي على فصول مختارة فقط", "حفظ فاتورة أو نموذج أو ملحق من ملف PDF أكبر", "سحب الصفحات المهمة من مستند ممسوح ضوئياً متعدد الصفحات", "تحضير مستند أصغر قبل الدمج أو التوقيع"], "faq": [ - {"q": "كيف أستخرج صفحات من PDF؟", "a": "ارفع PDF، أدخل الصفحات المطلوبة (مثل 1,3,5-8)، وحمّل PDF الجديد الذي يحتوي فقط على تلك الصفحات."}, - {"q": "ما الفرق بين التقسيم والاستخراج؟", "a": "التقسيم يقسم كل صفحة إلى ملفات منفصلة، بينما الاستخراج يتيح لك اختيار أي مجموعة من الصفحات المحددة في مستند واحد جديد."}, - {"q": "هل يمكنني استخراج الصفحات بترتيب مختلف؟", "a": "يتم استخراج الصفحات بالترتيب المحدد. استخدم أداة إعادة الترتيب لمزيد من التحكم في ترتيب الصفحات."} + {"q": "كيف أستخرج صفحات من PDF؟", "a": "ارفع ملف PDF، أدخل الصفحات أو النطاقات التي تريد الاحتفاظ بها، ثم حمّل ملف PDF الجديد الذي يحتوي فقط على تلك الصفحات المختارة."}, + {"q": "هل يمكنني استخراج عدة صفحات غير متتالية؟", "a": "نعم. يمكنك استخراج صفحات مثل 1,3,7 بالإضافة إلى نطاقات مثل 5-10 في الطلب نفسه."}, + {"q": "ما الفرق بين استخراج الصفحات وتقسيم PDF؟", "a": "استخراج الصفحات ينشئ ملف PDF جديداً واحداً من الصفحات التي تحددها بدقة. أما تقسيم PDF فهو أفضل عندما تريد فصل الصفحات على نطاق أوسع أو إنشاء عدة ملفات."}, + {"q": "هل يبقى ملف PDF الأصلي بدون تغيير؟", "a": "نعم. لا يتم تعديل الملف الأصلي. تنشئ الأداة ملف PDF منفصلاً يحتوي فقط على الصفحات المستخرجة."} ] }, "pdfEditor": { diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index d8f3207..e563dd5 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -673,7 +673,7 @@ }, "compressPdf": { "title": "Compress PDF", - "description": "Reduce PDF file size while maintaining quality. Choose your compression level.", + "description": "Compress PDF files online and reduce file size without sacrificing readability.", "shortDesc": "Compress PDF", "qualityLow": "Maximum Compression", "qualityMedium": "Balanced", @@ -768,7 +768,7 @@ }, "splitPdf": { "title": "Split PDF", - "description": "Split a PDF into individual pages or extract specific page ranges.", + "description": "Split PDF pages online or separate selected page ranges into new files.", "shortDesc": "Split PDF", "allPages": "All Pages", "allPagesDesc": "Extract every page as a separate PDF file", @@ -1029,7 +1029,7 @@ }, "extractPages": { "title": "Extract PDF Pages", - "description": "Extract specific pages from a PDF into a new document.", + "description": "Extract pages from a PDF into a new document with exact page numbers or ranges.", "shortDesc": "Extract Pages", "pagesLabel": "Pages to Extract", "pagesPlaceholder": "e.g. 1,3,5-8", @@ -1335,15 +1335,17 @@ ] }, "compressPdf": { - "whatItDoes": "Reduce the file size of your PDF documents by up to 90% while keeping them readable and high quality. Choose between three compression levels to balance quality and file size according to your needs.", - "howToUse": ["Upload your PDF file to the compressor.", "Select your preferred compression level: Maximum, Balanced, or High Quality.", "Click compress and wait for processing.", "Download your compressed PDF with a significantly smaller file size."], - "benefits": ["Reduce file size by up to 90%", "Three compression levels to choose from", "Text remains crisp and searchable", "Ideal for email attachments and uploads", "Free with no registration needed"], - "useCases": ["Making large PDFs small enough to email", "Reducing storage space for archived documents", "Speeding up PDF uploads to websites", "Optimizing PDFs for mobile viewing", "Preparing documents for web publishing"], + "metaTitleSuffix": "Free Online PDF Compressor to Reduce PDF File Size", + "metaDescription": "Compress PDF files online for free. Reduce PDF file size for email, uploads, and sharing while keeping text readable and document quality under control.", + "whatItDoes": "Use this online PDF compressor to reduce PDF file size for email, uploads, web sharing, and storage. It optimizes images and document structure while keeping text readable and the layout usable.", + "howToUse": ["Upload the PDF you want to compress.", "Choose Maximum Compression, Balanced, or High Quality depending on how small the file needs to be.", "Start compression and wait for the smaller PDF to be generated.", "Download the compressed PDF and share it or upload it anywhere."], + "benefits": ["Make large PDFs smaller for email and form uploads", "Choose the right trade-off between small size and visual quality", "Keep text sharp and searchable after compression", "Works directly in the browser with no signup", "Original document is processed securely and deleted automatically"], + "useCases": ["Reducing a PDF before sending it as an email attachment", "Meeting upload limits on job portals, CRMs, or government forms", "Shrinking scanned image-heavy PDFs for faster sharing", "Saving cloud storage space for archived reports", "Preparing PDFs for quicker mobile downloads"], "faq": [ - {"q": "How does PDF compression work?", "a": "Our tool optimizes images, removes unnecessary metadata, and compresses internal structures to reduce file size while maintaining visual quality."}, - {"q": "Will compression affect text quality?", "a": "No. Text remains crisp and searchable. Mainly images within the PDF are optimized to reduce file size."}, - {"q": "How much can I reduce my PDF size?", "a": "Depending on the content, you can typically reduce file size by 50-90%, especially for PDFs with many images."}, - {"q": "Is there a file size limit?", "a": "You can compress PDFs up to 20MB in size."} + {"q": "How do I compress a PDF online?", "a": "Upload your PDF, choose the compression level you want, start the process, and download the smaller PDF when it is ready."}, + {"q": "How can I make a PDF smaller for email or uploads?", "a": "Use the balanced or maximum compression setting to reduce PDF file size until it fits common email and upload limits."}, + {"q": "Will PDF compression reduce quality?", "a": "Compression mainly optimizes images and embedded assets. Text usually stays sharp and searchable, while visual quality depends on the compression level you choose."}, + {"q": "Does compression change my original PDF?", "a": "No. The tool creates a compressed copy for download and leaves your original file unchanged."} ] }, "mergePdf": { @@ -1359,14 +1361,17 @@ ] }, "splitPdf": { - "whatItDoes": "Divide a PDF document into separate files. You can split every page into an individual file or extract specific page ranges. Perfect for isolating sections from large documents.", - "howToUse": ["Upload your PDF document.", "Choose to split all pages or select specific pages/ranges.", "Enter page numbers (e.g. 1,3,5-8) if extracting specific pages.", "Download the resulting PDF files."], - "benefits": ["Split into individual pages or custom ranges", "Simple page range syntax (e.g. 1,3,5-8)", "No quality loss", "Free and no signup required", "Works with any PDF document"], - "useCases": ["Extracting a specific chapter from an e-book", "Sending only relevant pages to a colleague", "Breaking up a large manual into sections", "Isolating a single page for printing", "Separating a multi-page scanned document"], + "metaTitleSuffix": "Free Online PDF Splitter to Split or Separate PDF Pages", + "metaDescription": "Split PDF files online for free. Use this PDF splitter to separate PDF pages, cut page ranges, or save selected pages into smaller PDF files without losing quality.", + "whatItDoes": "Use this PDF splitter to split PDF pages into separate files or break a long document into smaller sections. You can separate PDF pages one by one or save only the ranges you want.", + "howToUse": ["Upload your PDF file.", "Choose whether to split every page or only separate specific pages or ranges.", "Enter page numbers such as 1,3,5-8 when you want custom output.", "Download the new PDF files created from your selected pages."], + "benefits": ["Split PDF pages individually or by custom range", "Separate PDF pages without changing the original quality", "Useful for sending only the pages someone needs", "Fast browser-based processing with no signup", "Works for reports, scans, contracts, and other multi-page PDFs"], + "useCases": ["Breaking a large PDF into smaller files for clients or teammates", "Separating one chapter or appendix from a long report", "Cutting PDF pages out of a scanned batch document", "Creating smaller PDFs for email or upload limits", "Saving only the pages you need for review or printing"], "faq": [ - {"q": "How do I split a PDF?", "a": "Upload your PDF, specify the pages or ranges you want to extract, and click split. Download the resulting PDF instantly."}, - {"q": "Can I extract specific pages?", "a": "Yes, you can specify individual pages (e.g. 1, 3, 5) or ranges (e.g. 1-5) to extract."}, - {"q": "Is splitting a PDF free?", "a": "Yes, our PDF splitter is completely free with no limitations."} + {"q": "How do I split a PDF online?", "a": "Upload your PDF, choose whether to split every page or only selected page ranges, then download the new PDF files created from your document."}, + {"q": "Can I separate PDF pages without splitting the whole file?", "a": "Yes. You can enter exact page numbers or ranges so only the pages you want are saved into new files."}, + {"q": "Will splitting a PDF reduce quality?", "a": "No. Splitting is a structural change, so the pages keep their original quality and layout."}, + {"q": "What is the difference between Split PDF and Extract Pages?", "a": "Split PDF is best when you want separate output files or broad page separation. Extract Pages is better when you want selected pages combined into one new PDF."} ] }, "rotatePdf": { @@ -1469,14 +1474,17 @@ ] }, "extractPages": { - "whatItDoes": "Extract specific pages from a PDF and create a new document containing only the pages you selected. Choose individual pages or page ranges using simple syntax. The perfect tool when you only need certain pages from a large document.", - "howToUse": ["Upload your PDF document.", "Enter the page numbers or ranges (e.g. 1,3,5-8).", "Click Extract to create a new PDF.", "Download the PDF with only your selected pages."], - "benefits": ["Extract individual pages or ranges", "Simple comma-separated syntax", "Original document stays unchanged", "Free and completely secure", "Fast processing"], - "useCases": ["Extracting a single chapter from an e-book", "Getting specific pages for a presentation", "Creating a subset document for review", "Pulling pages from a scanned multi-page document", "Isolating an important page to share separately"], + "metaTitleSuffix": "Free Online PDF Page Extractor to Extract Pages from PDF", + "metaDescription": "Extract pages from PDF online for free. Select exact page numbers or ranges to create a new PDF with only the pages you need.", + "whatItDoes": "This PDF page extractor lets you pull specific pages from a PDF and combine them into one new file. It is ideal when you need to extract pages from PDF documents without splitting every page.", + "howToUse": ["Upload your PDF document.", "Enter the exact pages or page ranges you want to keep, such as 2,4,7-10.", "Click Extract to create a new PDF containing only those pages.", "Download the extracted-pages PDF and share it or continue editing it."], + "benefits": ["Extract only the pages you need into one clean PDF", "Supports exact page numbers and page ranges", "Leaves the original PDF unchanged", "Useful for forms, contracts, chapters, and scanned packets", "Fast secure processing with automatic cleanup"], + "useCases": ["Sending a few required pages from a long application packet", "Creating a review copy with only selected chapters", "Saving one invoice, form, or appendix from a larger PDF", "Pulling key pages out of a scanned document bundle", "Preparing a smaller document before merging or signing"], "faq": [ - {"q": "How do I extract pages from a PDF?", "a": "Upload your PDF, enter the pages you want (e.g. 1,3,5-8), and download the new PDF containing only those pages."}, - {"q": "What is the difference between Split and Extract?", "a": "Split divides every page into separate files, while Extract lets you pick any combination of specific pages into one new document."}, - {"q": "Can I extract pages in a different order?", "a": "The pages are extracted in the order specified. Use our Reorder tool for more control over page arrangement."} + {"q": "How do I extract pages from a PDF?", "a": "Upload your PDF, enter the pages or ranges you want to keep, and download the new PDF containing only those selected pages."}, + {"q": "Can I extract multiple non-consecutive pages?", "a": "Yes. You can extract pages like 1,3,7 as well as ranges such as 5-10 in the same request."}, + {"q": "What is the difference between Extract Pages and Split PDF?", "a": "Extract Pages creates one new PDF from the exact pages you choose. Split PDF is better when you want broader page separation or multiple outputs."}, + {"q": "Will the original PDF stay unchanged?", "a": "Yes. The original file is not edited. The tool creates a separate PDF that contains only the extracted pages."} ] }, "pdfEditor": { diff --git a/frontend/src/i18n/fr.json b/frontend/src/i18n/fr.json index 8ea2593..b63f63f 100644 --- a/frontend/src/i18n/fr.json +++ b/frontend/src/i18n/fr.json @@ -673,7 +673,7 @@ }, "compressPdf": { "title": "Compresser PDF", - "description": "Réduisez la taille du fichier PDF tout en maintenant la qualité. Choisissez votre niveau de compression.", + "description": "Compressez des fichiers PDF en ligne et réduisez leur taille sans nuire à la lisibilité.", "shortDesc": "Compresser PDF", "qualityLow": "Compression maximale", "qualityMedium": "Équilibré", @@ -768,7 +768,7 @@ }, "splitPdf": { "title": "Diviser PDF", - "description": "Divisez un PDF en pages individuelles ou extrayez des plages de pages spécifiques.", + "description": "Divisez des pages PDF en ligne ou séparez des plages précises dans de nouveaux fichiers.", "shortDesc": "Diviser PDF", "allPages": "Toutes les pages", "allPagesDesc": "Extraire chaque page dans un fichier PDF séparé", @@ -1029,7 +1029,7 @@ }, "extractPages": { "title": "Extraire des pages PDF", - "description": "Extrayez des pages spécifiques d'un PDF dans un nouveau document.", + "description": "Extrayez des pages d'un PDF dans un nouveau document avec des numéros ou plages précis.", "shortDesc": "Extraire les pages", "pagesLabel": "Pages à extraire", "pagesPlaceholder": "ex. 1,3,5-8", @@ -1335,15 +1335,17 @@ ] }, "compressPdf": { - "whatItDoes": "Réduisez la taille des fichiers PDF jusqu'à 90% tout en maintenant la lisibilité et une haute qualité. Choisissez parmi trois niveaux de compression pour équilibrer qualité et taille de fichier.", - "howToUse": ["Téléchargez votre fichier PDF dans l'outil de compression.", "Sélectionnez votre niveau de compression préféré : compression maximale, équilibré ou haute qualité.", "Cliquez sur Compresser et attendez le traitement.", "Téléchargez votre PDF compressé avec une taille considérablement réduite."], - "benefits": ["Réduction de taille jusqu'à 90%", "Trois niveaux de compression au choix", "Le texte reste net et consultable", "Parfait pour les pièces jointes d'e-mail", "Gratuit sans inscription"], - "useCases": ["Réduire des PDF volumineux pour l'envoi par e-mail", "Réduire l'espace de stockage pour les documents archivés", "Accélérer le téléchargement de PDF sur les sites web", "Optimiser les PDF pour la visualisation mobile", "Préparer des documents pour la publication web"], + "metaTitleSuffix": "Compresseur PDF gratuit en ligne pour réduire la taille d'un fichier", + "metaDescription": "Compressez des fichiers PDF en ligne gratuitement. Réduisez la taille d'un PDF pour l'e-mail, les formulaires et le partage tout en conservant un texte lisible.", + "whatItDoes": "Utilisez ce compresseur PDF en ligne pour réduire la taille d'un PDF avant l'envoi par e-mail, le téléversement ou l'archivage. L'outil optimise les images et la structure du document tout en conservant un texte lisible.", + "howToUse": ["Téléchargez le PDF à compresser.", "Choisissez Compression maximale, Équilibré ou Haute qualité selon le niveau de réduction souhaité.", "Lancez la compression et attendez la génération du PDF plus léger.", "Téléchargez le PDF compressé puis partagez-le ou téléversez-le où vous voulez."], + "benefits": ["Réduire les PDF volumineux pour l'e-mail et les formulaires en ligne", "Choisir le bon compromis entre taille réduite et qualité visuelle", "Conserver un texte net et consultable après compression", "Fonctionner directement dans le navigateur sans inscription", "Traitement sécurisé avec suppression automatique des fichiers"], + "useCases": ["Réduire un PDF avant de l'envoyer en pièce jointe", "Respecter les limites de taille sur les portails et formulaires", "Alléger des PDF numérisés riches en images", "Économiser de l'espace de stockage pour les archives", "Préparer des PDF plus rapides à télécharger sur mobile"], "faq": [ - {"q": "Comment fonctionne la compression PDF ?", "a": "Notre outil optimise les images, supprime les métadonnées inutiles et compresse les structures internes pour réduire la taille du fichier tout en maintenant la qualité visuelle."}, - {"q": "La compression affectera-t-elle la qualité du texte ?", "a": "Non. Le texte reste net et consultable. Principalement les images sont optimisées pour réduire la taille."}, - {"q": "De combien puis-je réduire la taille d'un PDF ?", "a": "Selon le contenu, vous pouvez généralement réduire la taille de 50 à 90%, surtout pour les fichiers contenant beaucoup d'images."}, - {"q": "Y a-t-il une limite de taille de fichier ?", "a": "Vous pouvez compresser des fichiers PDF jusqu'à 20 Mo."} + {"q": "Comment compresser un PDF en ligne ?", "a": "Téléchargez votre PDF, choisissez le niveau de compression souhaité, lancez le traitement puis récupérez le fichier plus léger lorsqu'il est prêt."}, + {"q": "Comment réduire la taille d'un PDF pour l'e-mail ou le téléversement ?", "a": "Utilisez le mode équilibré ou la compression maximale pour faire passer le fichier sous les limites habituelles d'envoi ou de dépôt."}, + {"q": "La compression PDF réduit-elle la qualité ?", "a": "La compression agit surtout sur les images et les ressources intégrées. Le texte reste généralement net et consultable, tandis que la qualité visuelle dépend du niveau choisi."}, + {"q": "La compression modifie-t-elle mon PDF original ?", "a": "Non. L'outil crée une copie compressée à télécharger et laisse le fichier d'origine intact."} ] }, "mergePdf": { @@ -1359,14 +1361,17 @@ ] }, "splitPdf": { - "whatItDoes": "Divisez un document PDF en fichiers séparés. Vous pouvez scinder chaque page en fichiers individuels ou extraire des plages de pages spécifiques. Idéal pour isoler des sections de documents volumineux.", - "howToUse": ["Téléchargez votre document PDF.", "Choisissez de diviser toutes les pages ou de spécifier des pages/plages particulières.", "Saisissez les numéros de pages (ex. 1,3,5-8) pour une extraction sélective.", "Téléchargez les fichiers PDF résultants."], - "benefits": ["Diviser en pages individuelles ou plages personnalisées", "Syntaxe simple pour les plages de pages", "Sans perte de qualité", "Gratuit sans inscription", "Fonctionne avec tout document PDF"], - "useCases": ["Extraire un chapitre spécifique d'un e-book", "Envoyer uniquement certaines pages à un collègue", "Diviser un manuel volumineux en sections", "Isoler une seule page pour l'impression", "Séparer un document numérisé de plusieurs pages"], + "metaTitleSuffix": "Outil gratuit en ligne pour diviser un PDF et séparer des pages", + "metaDescription": "Divisez des fichiers PDF en ligne gratuitement. Séparez des pages PDF, découpez des plages et créez des PDF plus petits sans perte de qualité.", + "whatItDoes": "Utilisez cet outil pour diviser des pages PDF en fichiers séparés ou découper un document long en sections plus petites. Vous pouvez séparer les pages une par une ou conserver uniquement les plages nécessaires.", + "howToUse": ["Téléchargez votre fichier PDF.", "Choisissez si vous voulez diviser toutes les pages ou seulement séparer certaines pages ou plages.", "Saisissez des numéros comme 1,3,5-8 lorsque vous voulez un résultat personnalisé.", "Téléchargez les nouveaux fichiers PDF créés à partir des pages sélectionnées."], + "benefits": ["Diviser des pages PDF individuellement ou par plage personnalisée", "Séparer des pages PDF sans altérer la qualité d'origine", "Envoyer uniquement les pages utiles au lieu du document complet", "Traitement rapide dans le navigateur sans inscription", "Adapté aux rapports, contrats et PDF numérisés"], + "useCases": ["Découper un PDF volumineux en fichiers plus petits pour des collègues ou clients", "Séparer un chapitre ou une annexe d'un long rapport", "Isoler des pages utiles d'un document numérisé", "Créer des PDF plus légers pour l'e-mail ou le dépôt", "Conserver uniquement les pages nécessaires pour la relecture ou l'impression"], "faq": [ - {"q": "Comment diviser un fichier PDF ?", "a": "Téléchargez votre PDF, sélectionnez les pages ou plages souhaitées et cliquez sur Diviser. Téléchargez le PDF résultant immédiatement."}, - {"q": "Puis-je extraire des pages spécifiques ?", "a": "Oui, vous pouvez spécifier des pages individuelles (ex. 1, 3, 5) ou des plages (ex. 1-5) pour l'extraction."}, - {"q": "La division de PDF est-elle gratuite ?", "a": "Oui, notre outil de division PDF est entièrement gratuit sans restrictions."} + {"q": "Comment diviser un PDF en ligne ?", "a": "Téléchargez votre PDF, choisissez si vous voulez diviser toutes les pages ou seulement certaines plages, puis téléchargez les nouveaux fichiers générés."}, + {"q": "Puis-je séparer des pages PDF sans diviser tout le fichier ?", "a": "Oui. Vous pouvez saisir des numéros de pages ou des plages précises afin d'enregistrer uniquement les pages voulues dans de nouveaux fichiers."}, + {"q": "La division d'un PDF réduit-elle la qualité ?", "a": "Non. La division modifie uniquement la structure du fichier, donc les pages conservent leur qualité et leur mise en page d'origine."}, + {"q": "Quelle est la différence entre Diviser PDF et Extraire des pages ?", "a": "Diviser PDF convient mieux lorsque vous voulez plusieurs sorties ou une séparation large des pages. Extraire des pages est préférable lorsque vous voulez réunir des pages choisies dans un seul nouveau PDF."} ] }, "rotatePdf": { @@ -1469,14 +1474,17 @@ ] }, "extractPages": { - "whatItDoes": "Extrayez des pages spécifiques d'un PDF et créez un nouveau document contenant uniquement les pages sélectionnées. Choisissez des pages individuelles ou des plages de pages avec une syntaxe simple.", - "howToUse": ["Téléchargez votre document PDF.", "Saisissez les numéros de pages ou plages (ex. 1,3,5-8).", "Cliquez sur Extraire pour créer un nouveau PDF.", "Téléchargez le PDF contenant uniquement les pages choisies."], - "benefits": ["Extraction de pages individuelles ou par plages", "Syntaxe simple séparée par des virgules", "Le document original reste inchangé", "Gratuit et totalement sécurisé", "Traitement rapide"], - "useCases": ["Extraire un seul chapitre d'un e-book", "Obtenir des pages spécifiques pour une présentation", "Créer un sous-document pour révision", "Extraire des pages d'un document numérisé multi-pages", "Isoler une page importante pour un partage séparé"], + "metaTitleSuffix": "Extracteur de pages PDF gratuit en ligne", + "metaDescription": "Extrayez des pages d'un PDF en ligne gratuitement. Sélectionnez des numéros ou plages exacts pour créer un nouveau PDF avec uniquement les pages utiles.", + "whatItDoes": "Cet extracteur de pages PDF vous permet de récupérer des pages précises d'un PDF et de les réunir dans un nouveau fichier. Il convient parfaitement lorsque vous devez extraire des pages d'un PDF sans séparer chaque page du document.", + "howToUse": ["Téléchargez votre document PDF.", "Saisissez les pages ou plages exactes à conserver, par exemple 2,4,7-10.", "Cliquez sur Extraire pour créer un nouveau PDF contenant uniquement ces pages.", "Téléchargez le PDF extrait puis partagez-le ou poursuivez votre traitement."], + "benefits": ["Extraire uniquement les pages nécessaires dans un PDF propre", "Prendre en charge les numéros de pages précis et les plages", "Laisser le PDF original inchangé", "Utile pour les formulaires, contrats, chapitres et lots numérisés", "Traitement rapide et sécurisé avec nettoyage automatique"], + "useCases": ["Envoyer seulement quelques pages d'un dossier volumineux", "Créer une copie de relecture avec des chapitres sélectionnés", "Conserver une facture, un formulaire ou une annexe d'un PDF plus grand", "Retirer des pages clés d'un document numérisé multi-pages", "Préparer un document plus léger avant fusion ou signature"], "faq": [ - {"q": "Comment extraire des pages d'un PDF ?", "a": "Téléchargez votre PDF, saisissez les pages souhaitées (ex. 1,3,5-8) et téléchargez le nouveau PDF contenant uniquement ces pages."}, - {"q": "Quelle est la différence entre diviser et extraire ?", "a": "La division sépare chaque page en fichiers distincts, tandis que l'extraction vous permet de choisir n'importe quelle combinaison de pages spécifiques dans un nouveau document unique."}, - {"q": "Puis-je extraire les pages dans un ordre différent ?", "a": "Les pages sont extraites dans l'ordre spécifié. Utilisez l'outil de réorganisation pour plus de contrôle sur l'ordre des pages."} + {"q": "Comment extraire des pages d'un PDF ?", "a": "Téléchargez votre PDF, saisissez les pages ou plages à conserver, puis téléchargez le nouveau PDF contenant uniquement ces pages sélectionnées."}, + {"q": "Puis-je extraire plusieurs pages non consécutives ?", "a": "Oui. Vous pouvez extraire des pages comme 1,3,7 ainsi que des plages comme 5-10 dans la même demande."}, + {"q": "Quelle est la différence entre Extraire des pages et Diviser PDF ?", "a": "Extraire des pages crée un seul nouveau PDF à partir des pages choisies avec précision. Diviser PDF est préférable lorsque vous voulez une séparation plus large ou plusieurs fichiers de sortie."}, + {"q": "Le PDF d'origine reste-t-il inchangé ?", "a": "Oui. Le fichier original n'est pas modifié. L'outil crée un PDF séparé qui contient uniquement les pages extraites."} ] }, "pdfEditor": { diff --git a/scripts/build_keyword_portfolio.py b/scripts/build_keyword_portfolio.py new file mode 100644 index 0000000..23e36c1 --- /dev/null +++ b/scripts/build_keyword_portfolio.py @@ -0,0 +1,1152 @@ +#!/usr/bin/env python3 +""" +Build a multilingual keyword portfolio from Google Ads exports. + +Usage: + python scripts/build_keyword_portfolio.py + python scripts/build_keyword_portfolio.py --output-dir docs/keyword-research/2026-04-05 +""" + +from __future__ import annotations + +import argparse +import csv +import math +import re +import unicodedata +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +BASE_INPUTS = [ + ROOT / "docs" / "KeywordStats_4_5_2026.csv", + ROOT / "docs" / "Keyword Stats 2026-04-05 at 10_02_37.csv", +] +DEFAULT_OUTPUT_DIR = ROOT / "docs" / "keyword-research" / "2026-04-05" +SUPPLEMENTAL_INPUT_DIR = DEFAULT_OUTPUT_DIR / "Keywords" + +SUPPORTED_LANGUAGES = {"en", "ar", "fr"} +GROWTH_LANGUAGES = {"es"} +WATCHLIST_LANGUAGES = {"zh", "it", "pt", "other"} + +HOW_TO_MARKERS = {"how to", "comment ", "كيفية", "كيف ", "how do i"} +FREE_MARKERS = {"free", "gratis", "gratuit"} +ONLINE_MARKERS = {"online", "en ligne"} +PAGE_MARKERS = {"page", "pages", "pagina", "paginas", "página", "páginas"} +FILE_MARKERS = {"file", "files", "document", "documents", "archivo", "archivos", "fichier", "fichiers"} + +SPLIT_MARKERS = { + "split", + "splitter", + "splitpdf", + "pdfsplit", + "separate", + "separator", + "divide", + "divider", + "cut", + "cutter", + "slicer", + "trimmer", + "breaker", + "unmerge", + "dividir", + "separar", + "separa", + "separador", + "cortar", + "diviser", + "séparer", + "separer", + "fractionner", + "decouper", + "découper", + "couper", + "dividi", + "تقسيم", + "فصل", + "拆分", + "分割", +} +EXTRACT_MARKERS = {"extract", "extractor", "extraction", "extract pages", "استخراج"} +MERGE_MARKERS = {"merge", "merger", "combine", "join", "fusionner", "fusion", "دمج"} +COMPRESS_MARKERS = {"compress", "compressor", "compression", "reduce size", "reduce pdf", "ضغط"} +CONVERT_MARKERS = {"convert", "converter", "conversion", "to pdf", "pdf to", "تحويل"} +EDIT_MARKERS = {"edit", "editor", "editing", "software"} +IMAGE_TO_PDF_MARKERS = {"image pdf", "images to pdf", "image to pdf", "add image to pdf", "photo to pdf", "jpg to pdf", "png to pdf"} +PDF_TOOL_MARKERS = {"pdf tools", "tool pdf", "pdf tool"} +PDF_TO_WORD_MARKERS = {"pdf to word", "pdf to doc", "pdf to docx", "convert pdf to word"} +WORD_TO_PDF_MARKERS = {"word to pdf", "doc to pdf", "docx to pdf", "convert word to pdf"} +OCR_MARKERS = { + "ocr", + "text recognition", + "extract text from image", + "extract text from pdf", + "image to text", + "pdf to text", + "scan to text", + "optical character recognition", + "استخراج النص", +} + +SPANISH_MARKERS = {"dividir", "separar", "separa", "separador", "gratis", "cortar"} +FRENCH_MARKERS = {"diviser", "séparer", "separer", "fractionner", "decouper", "découper", "couper", "gratuit"} +ITALIAN_MARKERS = {"dividi"} +PORTUGUESE_MARKERS = {"separador"} + +SPLIT_VALID_PATTERNS = [ + re.compile(r"^(?:online )?split pdf(?: free| online| free online| pages| pages free| file| files| document)?$"), + re.compile(r"^pdf split(?: online| free)?$"), + re.compile(r"^pdf splitter(?: online| free| free online)?$"), + re.compile(r"^splitter pdf$"), + re.compile(r"^separate pdf(?: pages| files| free| pages free)?$"), + re.compile(r"^pdf separate(?: pages)?$"), + re.compile(r"^pdf separator$"), + re.compile(r"^pdf page separator$"), + re.compile(r"^cut pdf(?: pages)?$"), + re.compile(r"^pdf cutter(?: online)?$"), + re.compile(r"^pdf divider$"), + re.compile(r"^unmerge pdf(?: free| online)?$"), + re.compile(r"^dividir pdf(?: gratis| online)?$"), + re.compile(r"^separar pdf$"), + re.compile(r"^separa pdf$"), + re.compile(r"^separador de pdf$"), + re.compile(r"^cortar pdf$"), + re.compile(r"^diviser pdf$"), + re.compile(r"^séparer pdf$"), + re.compile(r"^separer pdf$"), + re.compile(r"^fractionner pdf$"), + re.compile(r"^decouper pdf$"), + re.compile(r"^découper pdf$"), + re.compile(r"^couper pdf$"), + re.compile(r"^pdfsplit$"), + re.compile(r"^splitpdf$"), + re.compile(r"^(?:拆分pdf|pdf拆分|分割pdf|pdf分割)$"), +] +EXTRACT_VALID_PATTERNS = [ + re.compile(r"^extract pages? from pdf$"), + re.compile(r"^pdf extractor$"), + re.compile(r"^extract pdf$"), + re.compile(r"^extract pdf pages$"), + re.compile(r"^pdf extract(?:or)?$"), +] +MERGE_VALID_PATTERNS = [ + re.compile(r"^merge pdf(?: files| documents| free| online)?$"), + re.compile(r"^pdf merge$"), + re.compile(r"^pdf merger$"), + re.compile(r"^دمج pdf$"), +] +COMPRESS_VALID_PATTERNS = [ + re.compile(r"^compress pdf(?: file| document| online| free| online free)?$"), + re.compile(r"^pdf compressor(?: free| online)?$"), + re.compile(r"^pdf compression$"), + re.compile(r"^ضغط pdf$"), +] +CONVERSION_VALID_PATTERNS = [ + re.compile(r"^pdf converter$"), + re.compile(r"^convert (?:file|file type|document|documents|image|images|photo|photos|word|doc|docx|excel|xls|xlsx|ppt|pptx|powerpoint|html|text|txt) to pdf$"), + re.compile(r"^(?:word|doc|docx|excel|xls|xlsx|ppt|pptx|powerpoint|html|image|images|photo|photos|jpg|jpeg|png) to pdf$"), + re.compile(r"^pdf to (?:word|excel|ppt|pptx|powerpoint|images?|jpg|jpeg|png)$"), +] +EDITOR_VALID_PATTERNS = [ + re.compile(r"^pdf editor$"), + re.compile(r"^edit pdf$"), + re.compile(r"^pdf editing software$"), + re.compile(r"^online pdf editor$"), +] +IMAGE_TO_PDF_VALID_PATTERNS = [ + re.compile(r"^image pdf$"), + re.compile(r"^image to pdf$"), + re.compile(r"^images to pdf$"), + re.compile(r"^add image to pdf(?: document)?$"), + re.compile(r"^photo to pdf$"), + re.compile(r"^jpg to pdf$"), + re.compile(r"^png to pdf$"), +] +PDF_TO_WORD_VALID_PATTERNS = [ + re.compile(r"^pdf to (?:word|doc|docx)$"), + re.compile(r"^convert pdf to (?:word|doc|docx)$"), + re.compile(r"^تحويل pdf (?:الى|إلى) (?:word|وورد)$"), + re.compile(r"^تحويل من pdf (?:الى|إلى) (?:word|وورد)$"), + re.compile(r"^(?:pdf|بي دي اف) (?:الى|إلى) (?:word|وورد)$"), +] +WORD_TO_PDF_VALID_PATTERNS = [ + re.compile(r"^(?:word|doc|docx) to pdf$"), + re.compile(r"^convert (?:word|doc|docx) to pdf$"), + re.compile(r"^تحويل (?:word|وورد|doc|docx) (?:الى|إلى) pdf$"), + re.compile(r"^تحويل من (?:word|وورد|doc|docx) (?:الى|إلى) pdf$"), +] +OCR_VALID_PATTERNS = [ + re.compile(r"^ocr(?: pdf| image| scanner)?$"), + re.compile(r"^text recognition$"), + re.compile(r"^extract text from (?:image|pdf|scan|scanned pdf)$"), + re.compile(r"^image to text$"), + re.compile(r"^pdf to text$"), + re.compile(r"^scan to text$"), + re.compile(r"^optical character recognition$"), + re.compile(r"^استخراج النص من (?:pdf|صورة)$"), + re.compile(r"^تحويل (?:pdf|صورة) (?:الى|إلى) نص$"), +] + +BRAND_PATTERNS = { + "ilovepdf": re.compile(r"\bi\s*love\s*pdf\b|\bilovepdf\b", re.IGNORECASE), + "smallpdf": re.compile(r"\bsmall\s*pdf\b|\bsmallpdf\b", re.IGNORECASE), + "sejda": re.compile(r"\bsejda\b", re.IGNORECASE), + "adobe": re.compile(r"\badobe\b|\bacrobat\b", re.IGNORECASE), + "cutepdf": re.compile(r"\bcute\s*pdf\b|\bcutepdf\b", re.IGNORECASE), + "pdf24": re.compile(r"\bpdf\s*24\b|\bpdf24\b", re.IGNORECASE), +} + +AMBIGUOUS_EXACT = { + "split", + "pdf", + "pd f", + "pdf file", + "pdf format", + "pdf online", + "split pages", + "split online", + "page separator", + "pdf to split", + "pdf smart", +} + +CLUSTER_METADATA = { + "split-pdf": { + "label": "Split PDF", + "recommended_target": "/tools/split-pdf", + "target_type": "live_tool", + "implementation_note": "Prioritize this existing landing page with unbranded transactional terms and page-focused variants.", + }, + "extract-pages": { + "label": "Extract Pages", + "recommended_target": "/tools/extract-pages", + "target_type": "live_tool", + "implementation_note": "Use as a secondary page cluster for extraction-specific and page-removal intent.", + }, + "merge-pdf": { + "label": "Merge PDF", + "recommended_target": "/tools/merge-pdf", + "target_type": "live_tool", + "implementation_note": "Target merge-specific queries separately from split keywords to avoid mixed intent pages.", + }, + "compress-pdf": { + "label": "Compress PDF", + "recommended_target": "/tools/compress-pdf", + "target_type": "live_tool", + "implementation_note": "This cluster broadens reach beyond split and should be treated as a parallel priority pillar.", + }, + "pdf-to-word": { + "label": "PDF to Word", + "recommended_target": "/tools/pdf-to-word", + "target_type": "live_tool", + "implementation_note": "Map direct PDF-to-Word conversion intent to the existing converter page rather than a generic conversion hub.", + }, + "word-to-pdf": { + "label": "Word to PDF", + "recommended_target": "/tools/word-to-pdf", + "target_type": "live_tool", + "implementation_note": "Route Word-to-PDF terms to the dedicated converter page because the intent is specific and high value.", + }, + "ocr": { + "label": "OCR / Text Extraction", + "recommended_target": "/tools/ocr", + "target_type": "live_tool", + "implementation_note": "Send OCR and text-extraction intent to the OCR tool page instead of mixing it into broad AI copy.", + }, + "pdf-conversion": { + "label": "PDF Conversion Hub", + "recommended_target": "homepage-or-future-conversion-hub", + "target_type": "hub_or_future_page", + "implementation_note": "Use these keywords to justify a collection page for generic converter intent.", + }, + "pdf-editor": { + "label": "PDF Editor", + "recommended_target": "/tools/pdf-editor", + "target_type": "live_tool", + "implementation_note": "Position editor and editing-software terms on the live PDF editor page.", + }, + "images-to-pdf": { + "label": "Images to PDF", + "recommended_target": "/tools/images-to-pdf", + "target_type": "live_tool", + "implementation_note": "Capture image-to-PDF phrasing and upload intent on the existing converter tool.", + }, + "mixed-pdf-operations": { + "label": "Mixed PDF Operations", + "recommended_target": "homepage-or-future-pdf-tools-hub", + "target_type": "hub_or_future_page", + "implementation_note": "Mixed split-and-merge intent should point to a tools hub, not a single-action landing page.", + }, + "pdf-tools-hub": { + "label": "PDF Tools Hub", + "recommended_target": "homepage-or-future-pdf-tools-hub", + "target_type": "hub_or_future_page", + "implementation_note": "Reserve this cluster for clear hub-style terms such as pdf tools.", + }, + "unclear": { + "label": "Manual Review", + "recommended_target": "manual-review", + "target_type": "manual_review", + "implementation_note": "Keep unclear terms out of the primary portfolio until manually validated.", + }, +} + +RECOMMENDATION_ORDER = { + "target_now": 0, + "target_after_localization": 1, + "supporting_content": 2, + "watchlist": 3, + "exclude": 4, +} + + +@dataclass +class SourceRow: + keyword: str + normalized: str + source_name: str + source_path: str + volume: int + raw_metric_name: str + competition: str = "" + competition_index: int = 0 + raw_trends: str = "" + + +@dataclass +class KeywordAggregate: + keyword: str + normalized: str + source_names: set[str] = field(default_factory=set) + source_paths: set[str] = field(default_factory=set) + file1_impressions: int = 0 + file2_avg_monthly_searches: int = 0 + competitions: set[str] = field(default_factory=set) + competition_index_max: int = 0 + raw_trends: list[str] = field(default_factory=list) + + +def clean_int(value: str | None) -> int: + if not value: + return 0 + digits = re.sub(r"[^0-9]", "", str(value)) + return int(digits) if digits else 0 + + +def normalize_keyword(value: str) -> str: + text = unicodedata.normalize("NFKC", value or "") + text = re.sub(r"[\u200e\u200f\u202a-\u202e\u2066-\u2069]", "", text) + text = text.lower().replace("_", " ") + text = text.replace("&", " and ") + text = re.sub(r"[|/+]+", " ", text) + text = re.sub(r"[^\w\s\u0600-\u06FF\u4E00-\u9FFF-]", " ", text, flags=re.UNICODE) + text = re.sub(r"\s+", " ", text) + return text.strip() + + +def contains_any(text: str, markers: set[str]) -> bool: + tokens = set(text.split()) + for marker in markers: + if re.search(r"[\u0600-\u06FF\u4E00-\u9FFF]", marker): + if marker in text: + return True + continue + if " " in marker and marker in text: + return True + if marker in tokens: + return True + return False + + +def has_token_or_phrase(keyword: str, markers: set[str]) -> bool: + return contains_any(keyword, markers) + + +def matches_any_pattern(keyword: str, patterns: list[re.Pattern[str]]) -> bool: + return any(pattern.search(keyword) for pattern in patterns) + + +def discover_default_inputs() -> list[Path]: + input_paths = [path for path in BASE_INPUTS if path.exists()] + seen_names = {path.name for path in input_paths} + + if SUPPLEMENTAL_INPUT_DIR.exists(): + for path in sorted(SUPPLEMENTAL_INPUT_DIR.glob("*.csv")): + if path.name in seen_names: + continue + input_paths.append(path) + seen_names.add(path.name) + + return input_paths + + +DEFAULT_INPUTS = discover_default_inputs() + + +def strip_informational_prefix(keyword: str) -> str: + for prefix in ("how to ", "comment ", "كيفية ", "كيف ", "how do i "): + if keyword.startswith(prefix): + return keyword[len(prefix):].strip() + return keyword + + +def detect_language(keyword: str) -> str: + if re.search(r"[\u0600-\u06FF]", keyword): + return "ar" + if re.search(r"[\u4E00-\u9FFF]", keyword): + return "zh" + + if has_token_or_phrase(keyword, FRENCH_MARKERS): + return "fr" + if has_token_or_phrase(keyword, SPANISH_MARKERS): + return "es" + if has_token_or_phrase(keyword, ITALIAN_MARKERS): + return "it" + if has_token_or_phrase(keyword, PORTUGUESE_MARKERS): + return "pt" + return "en" + + +def detect_brands(keyword: str) -> list[str]: + hits = [] + for brand, pattern in BRAND_PATTERNS.items(): + if pattern.search(keyword): + hits.append(brand) + return sorted(hits) + + +def extract_modifiers(keyword: str, brand_hits: list[str]) -> list[str]: + modifiers = [] + if contains_any(keyword, HOW_TO_MARKERS): + modifiers.append("how_to") + if contains_any(keyword, FREE_MARKERS): + modifiers.append("free") + if contains_any(keyword, ONLINE_MARKERS): + modifiers.append("online") + if contains_any(keyword, PAGE_MARKERS): + modifiers.append("pages") + if contains_any(keyword, FILE_MARKERS): + modifiers.append("files") + if brand_hits: + modifiers.append("brand") + return modifiers + + +def classify_cluster(keyword: str) -> str: + has_pdf_to_word = contains_any(keyword, PDF_TO_WORD_MARKERS) or matches_any_pattern(keyword, PDF_TO_WORD_VALID_PATTERNS) + has_word_to_pdf = contains_any(keyword, WORD_TO_PDF_MARKERS) or matches_any_pattern(keyword, WORD_TO_PDF_VALID_PATTERNS) + has_ocr = contains_any(keyword, OCR_MARKERS) or matches_any_pattern(keyword, OCR_VALID_PATTERNS) + has_split = contains_any(keyword, SPLIT_MARKERS) + has_extract = contains_any(keyword, EXTRACT_MARKERS) + has_merge = contains_any(keyword, MERGE_MARKERS) + has_compress = contains_any(keyword, COMPRESS_MARKERS) + has_convert = contains_any(keyword, CONVERT_MARKERS) + has_edit = contains_any(keyword, EDIT_MARKERS) + has_image_to_pdf = contains_any(keyword, IMAGE_TO_PDF_MARKERS) + has_pdf_tool = contains_any(keyword, PDF_TOOL_MARKERS) + + if has_pdf_to_word: + return "pdf-to-word" + if has_word_to_pdf: + return "word-to-pdf" + if has_ocr: + return "ocr" + if has_split and has_merge: + return "mixed-pdf-operations" + if has_extract: + return "extract-pages" + if has_split or "to pages" in keyword: + return "split-pdf" + if has_merge: + return "merge-pdf" + if has_compress: + return "compress-pdf" + if has_image_to_pdf: + return "images-to-pdf" + if has_edit: + return "pdf-editor" + if has_convert or keyword.startswith("pdf to ") or keyword.endswith(" to pdf"): + return "pdf-conversion" + if has_pdf_tool: + return "pdf-tools-hub" + return "unclear" + + +def repeated_phrase(tokens: list[str]) -> bool: + if len(tokens) < 4: + return False + for size in range(1, len(tokens) // 2 + 1): + if len(tokens) % size: + continue + chunk = tokens[:size] + repeats = len(tokens) // size + if repeats > 1 and chunk * repeats == tokens: + return True + return False + + +def detect_noise_reason(keyword: str, cluster: str, brand_hits: list[str], file1_impressions: int, file2_searches: int) -> str: + tokens = keyword.split() + + if keyword in AMBIGUOUS_EXACT: + return "too_broad_or_ambiguous" + + if keyword == "page separator": + return "not_pdf_specific" + + if repeated_phrase(tokens): + return "repeated_phrase_spam" + + if tokens and max(Counter(tokens).values()) >= 3 and len(set(tokens)) <= 3: + return "repeated_tokens_spam" + + if brand_hits: + return "" + + if "pdf" not in keyword and cluster not in {"pdf-tools-hub", "pdf-editor", "images-to-pdf", "ocr"}: + return "not_pdf_specific" + + if cluster == "unclear" and max(file1_impressions, file2_searches) < 500: + return "unclear_low_value" + + if keyword.startswith("pd f") or keyword.endswith("pd f"): + return "malformed_keyword" + + if cluster == "unclear": + return "manual_review_required" + + cluster_phrase_issue = detect_cluster_phrase_issue(keyword, cluster) + if cluster_phrase_issue: + return cluster_phrase_issue + + return "" + + +def detect_cluster_phrase_issue(keyword: str, cluster: str) -> str: + candidate = strip_informational_prefix(keyword) + + if cluster == "split-pdf": + if candidate.count("pdf") > 1 and candidate not in {"pdf split", "pdf splitter", "pdf separator", "pdf page separator", "pdf separate", "pdf divider", "pdf cutter"}: + return "unnatural_cluster_phrase" + if any(pattern.search(candidate) for pattern in SPLIT_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "extract-pages": + if any(pattern.search(candidate) for pattern in EXTRACT_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "merge-pdf": + if candidate.count("pdf") > 1: + return "unnatural_cluster_phrase" + if any(pattern.search(candidate) for pattern in MERGE_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "compress-pdf": + if candidate.count("pdf") > 1 or candidate.count("compress") > 1 or candidate.count("compressor") > 1: + return "unnatural_cluster_phrase" + if any(pattern.search(candidate) for pattern in COMPRESS_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "pdf-to-word": + if any(pattern.search(candidate) for pattern in PDF_TO_WORD_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "word-to-pdf": + if any(pattern.search(candidate) for pattern in WORD_TO_PDF_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "ocr": + if any(pattern.search(candidate) for pattern in OCR_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "pdf-conversion": + if candidate == "pdf converter": + return "" + if candidate.count("pdf") > 1: + return "unnatural_cluster_phrase" + if any(pattern.search(candidate) for pattern in CONVERSION_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "pdf-editor": + if candidate.count("pdf") > 1: + return "unnatural_cluster_phrase" + if any(pattern.search(candidate) for pattern in EDITOR_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "images-to-pdf": + if candidate.count("pdf") > 1: + return "unnatural_cluster_phrase" + if any(pattern.search(candidate) for pattern in IMAGE_TO_PDF_VALID_PATTERNS): + return "" + return "unnatural_cluster_phrase" + + if cluster == "mixed-pdf-operations": + if candidate in {"pdf split and merge", "split and merge pdf"}: + return "" + return "unnatural_cluster_phrase" + + if cluster == "pdf-tools-hub": + if "pdf tools" in keyword: + return "" + return "unnatural_cluster_phrase" + + return "" + + +def detect_intent(keyword: str, brand_hits: list[str]) -> str: + if brand_hits: + return "competitor" + if contains_any(keyword, HOW_TO_MARKERS): + return "informational" + if "pdf tools" in keyword: + return "commercial_investigation" + return "transactional" + + +def market_bucket(language: str) -> str: + if language == "en": + return "core_en" + if language == "es": + return "growth_es" + if language == "ar": + return "expansion_ar" + if language == "fr": + return "expansion_fr" + return "watchlist_other" + + +def recommendation_for(language: str, intent: str, cluster: str, brand_hits: list[str], noise_reason: str) -> tuple[str, str]: + if noise_reason: + return "exclude", noise_reason + + if brand_hits: + return "watchlist", "competitor_branded" + + if language in WATCHLIST_LANGUAGES: + return "watchlist", "unsupported_language_market" + + if language in GROWTH_LANGUAGES: + if intent == "informational": + return "supporting_content", "spanish_content_after_localization" + return "target_after_localization", "spanish_localization_required" + + if cluster == "pdf-tools-hub": + return "supporting_content", "homepage_or_tools_hub" + + if intent == "informational": + return "supporting_content", "blog_or_faq_support" + + return "target_now", "mapped_to_live_page_or_current_i18n" + + +def score_keyword(file1_impressions: int, file2_searches: int, max_file1: int, max_file2: int) -> float: + file1_score = 0.0 + file2_score = 0.0 + if max_file1: + file1_score = math.log10(file1_impressions + 1) / math.log10(max_file1 + 1) + if max_file2: + file2_score = math.log10(file2_searches + 1) / math.log10(max_file2 + 1) + return round(file1_score * 45 + file2_score * 55, 2) + + +def load_keyword_stats(path: Path) -> list[SourceRow]: + rows: list[SourceRow] = [] + with path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + for row in reader: + keyword = (row.get("Keyword") or "").strip() + if not keyword: + continue + rows.append( + SourceRow( + keyword=keyword, + normalized=normalize_keyword(keyword), + source_name="keyword_trends_export", + source_path=str(path.relative_to(ROOT)).replace("\\", "/"), + volume=clean_int(row.get("Impressions")), + raw_metric_name="impressions", + raw_trends=(row.get("Trends") or "").strip(), + ) + ) + return rows + + +def load_keyword_planner(path: Path) -> list[SourceRow]: + rows: list[SourceRow] = [] + with path.open("r", encoding="utf-16") as handle: + lines = handle.read().splitlines() + + reader = csv.DictReader(lines[2:], delimiter="\t") + for row in reader: + keyword = (row.get("Keyword") or "").strip() + if not keyword: + continue + rows.append( + SourceRow( + keyword=keyword, + normalized=normalize_keyword(keyword), + source_name="keyword_planner_export", + source_path=str(path.relative_to(ROOT)).replace("\\", "/"), + volume=clean_int(row.get("Avg. monthly searches")), + raw_metric_name="avg_monthly_searches", + competition=(row.get("Competition") or "").strip(), + competition_index=clean_int(row.get("Competition (indexed value)")), + ) + ) + return rows + + +def aggregate_rows(rows: list[SourceRow]) -> list[KeywordAggregate]: + aggregates: dict[str, KeywordAggregate] = {} + + for row in rows: + if not row.normalized: + continue + + aggregate = aggregates.get(row.normalized) + if aggregate is None: + aggregate = KeywordAggregate(keyword=row.keyword, normalized=row.normalized) + aggregates[row.normalized] = aggregate + + current_best = max(aggregate.file1_impressions, aggregate.file2_avg_monthly_searches) + incoming_best = row.volume + if incoming_best > current_best: + aggregate.keyword = row.keyword + + aggregate.source_names.add(row.source_name) + aggregate.source_paths.add(row.source_path) + if row.source_name == "keyword_trends_export": + aggregate.file1_impressions += row.volume + if row.raw_trends: + aggregate.raw_trends.append(row.raw_trends) + else: + aggregate.file2_avg_monthly_searches += row.volume + if row.competition: + aggregate.competitions.add(row.competition) + aggregate.competition_index_max = max(aggregate.competition_index_max, row.competition_index) + + return list(aggregates.values()) + + +def build_keyword_rows(aggregates: list[KeywordAggregate]) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + max_file1 = max((item.file1_impressions for item in aggregates), default=0) + max_file2 = max((item.file2_avg_monthly_searches for item in aggregates), default=0) + rows: list[dict[str, str]] = [] + excluded: list[dict[str, str]] = [] + + for aggregate in aggregates: + normalized = aggregate.normalized + brand_hits = detect_brands(normalized) + language = detect_language(normalized) + cluster = classify_cluster(normalized) + metadata = CLUSTER_METADATA[cluster] + modifiers = extract_modifiers(normalized, brand_hits) + noise_reason = detect_noise_reason( + normalized, + cluster, + brand_hits, + aggregate.file1_impressions, + aggregate.file2_avg_monthly_searches, + ) + intent = detect_intent(normalized, brand_hits) + recommendation, rationale = recommendation_for(language, intent, cluster, brand_hits, noise_reason) + priority_score = score_keyword( + aggregate.file1_impressions, + aggregate.file2_avg_monthly_searches, + max_file1, + max_file2, + ) + + row = { + "keyword": aggregate.keyword, + "normalized_keyword": normalized, + "language": language, + "market_bucket": market_bucket(language), + "intent": intent, + "cluster": cluster, + "cluster_label": metadata["label"], + "recommended_target": metadata["recommended_target"], + "target_type": metadata["target_type"], + "recommendation": recommendation, + "recommendation_reason": rationale, + "priority_score": f"{priority_score:.2f}", + "file1_impressions": str(aggregate.file1_impressions), + "file2_avg_monthly_searches": str(aggregate.file2_avg_monthly_searches), + "competition_levels": ", ".join(sorted(aggregate.competitions)), + "competition_index_max": str(aggregate.competition_index_max), + "brands": ", ".join(brand_hits), + "modifiers": ", ".join(modifiers), + "source_count": str(len(aggregate.source_names)), + "sources": ", ".join(sorted(aggregate.source_names)), + "source_paths": ", ".join(sorted(aggregate.source_paths)), + "notes": metadata["implementation_note"], + } + + if recommendation == "exclude": + excluded.append(row) + else: + rows.append(row) + + rows.sort( + key=lambda item: ( + RECOMMENDATION_ORDER[item["recommendation"]], + -float(item["priority_score"]), + item["normalized_keyword"], + ) + ) + excluded.sort(key=lambda item: (-float(item["priority_score"]), item["normalized_keyword"])) + + for index, row in enumerate(rows, start=1): + row["priority_rank"] = str(index) + + return rows, excluded + + +def build_cluster_rows(rows: list[dict[str, str]], excluded: list[dict[str, str]]) -> list[dict[str, str]]: + grouped: dict[str, list[dict[str, str]]] = {} + for row in rows + excluded: + grouped.setdefault(row["cluster"], []).append(row) + + cluster_rows = [] + for cluster, items in grouped.items(): + metadata = CLUSTER_METADATA[cluster] + targetable = [item for item in items if item["recommendation"] != "exclude"] + sorted_items = sorted(items, key=lambda item: -float(item["priority_score"])) + top_candidates = sorted(targetable, key=lambda item: -float(item["priority_score"])) + top_item = top_candidates[0] if top_candidates else sorted_items[0] + cluster_rows.append( + { + "cluster": cluster, + "cluster_label": metadata["label"], + "recommended_target": metadata["recommended_target"], + "target_type": metadata["target_type"], + "cluster_score": f"{sum(float(item['priority_score']) for item in targetable):.2f}", + "keywords_total": str(len(items)), + "target_now_keywords": str(sum(item["recommendation"] == "target_now" for item in items)), + "target_after_localization_keywords": str(sum(item["recommendation"] == "target_after_localization" for item in items)), + "supporting_content_keywords": str(sum(item["recommendation"] == "supporting_content" for item in items)), + "watchlist_keywords": str(sum(item["recommendation"] == "watchlist" for item in items)), + "excluded_keywords": str(sum(item["recommendation"] == "exclude" for item in items)), + "top_keyword": top_item["keyword"], + "top_language": top_item["language"], + "file1_impressions": str(sum(int(item["file1_impressions"]) for item in items)), + "file2_avg_monthly_searches": str(sum(int(item["file2_avg_monthly_searches"]) for item in items)), + "implementation_note": metadata["implementation_note"], + } + ) + + cluster_rows.sort(key=lambda item: -float(item["cluster_score"])) + return cluster_rows + + +def to_markdown_table(rows: list[dict[str, str]], headers: list[tuple[str, str]]) -> str: + if not rows: + return "_No rows._" + header_row = "| " + " | ".join(label for _, label in headers) + " |" + separator = "| " + " | ".join("---" for _ in headers) + " |" + body = [ + "| " + " | ".join(str(row.get(key, "")) for key, _ in headers) + " |" + for row in rows + ] + return "\n".join([header_row, separator, *body]) + + +def write_csv(path: Path, rows: list[dict[str, str]], fieldnames: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def write_summary( + output_path: Path, + input_paths: list[Path], + raw_rows: list[SourceRow], + aggregates: list[KeywordAggregate], + rows: list[dict[str, str]], + excluded: list[dict[str, str]], + clusters: list[dict[str, str]], +) -> None: + counts_by_recommendation = Counter(row["recommendation"] for row in rows) + counts_by_recommendation.update(row["recommendation"] for row in excluded) + + visible_rows = [row for row in rows if row["recommendation"] != "watchlist"] + language_counts = Counter(row["language"] for row in visible_rows) + market_counts = Counter(row["market_bucket"] for row in visible_rows) + + top_target_now = [row for row in rows if row["recommendation"] == "target_now"][:15] + top_localization = [row for row in rows if row["recommendation"] == "target_after_localization"][:15] + top_supporting = [row for row in rows if row["recommendation"] == "supporting_content"][:12] + top_watchlist = [row for row in rows if row["recommendation"] == "watchlist"][:10] + top_clusters = clusters[:10] + top_excluded = excluded[:10] + + input_paths_display = [str(path.relative_to(ROOT)).replace("\\", "/") for path in input_paths] + input_list = "\n".join(f"- {path}" for path in input_paths_display) + + market_table_rows = [ + { + "bucket": "core_en", + "execution": "Target now", + "notes": "Current product and current data both support this market immediately.", + "count": str(market_counts.get("core_en", 0)), + }, + { + "bucket": "growth_es", + "execution": "Target after localization", + "notes": "Highest upside after English, but the site needs Spanish landing-page coverage first.", + "count": str(market_counts.get("growth_es", 0)), + }, + { + "bucket": "expansion_fr", + "execution": "Target now where demand exists", + "notes": "Supported in product and ready for selective rollout where the uploads show clear intent.", + "count": str(market_counts.get("expansion_fr", 0)), + }, + { + "bucket": "expansion_ar", + "execution": "Target supported terms, expand with native research", + "notes": "Product support exists and the latest uploads surface Arabic conversion intent, but category coverage still needs broader native-language research.", + "count": str(market_counts.get("expansion_ar", 0)), + }, + ] + + content = f"""# Keyword Portfolio - 2026-04-05 + +Generated with `scripts/build_keyword_portfolio.py` from the latest Google Ads exports. + +## Source Files + +{input_list} + +## Source Overview + +- Raw rows processed: {len(raw_rows)} +- Unique normalized keywords: {len(aggregates)} +- Included or watchlist keywords: {len(rows)} +- Excluded keywords: {len(excluded)} +- `target_now`: {counts_by_recommendation.get('target_now', 0)} +- `target_after_localization`: {counts_by_recommendation.get('target_after_localization', 0)} +- `supporting_content`: {counts_by_recommendation.get('supporting_content', 0)} +- `watchlist`: {counts_by_recommendation.get('watchlist', 0)} +- `exclude`: {counts_by_recommendation.get('exclude', 0)} + +## Recommended Market Mix + +{to_markdown_table(market_table_rows, [('bucket', 'Market Bucket'), ('execution', 'Execution'), ('count', 'Keywords'), ('notes', 'Notes')])} + +## Language Distribution (Non-Watchlist) + +{to_markdown_table([ + {'language': language, 'count': str(count)} + for language, count in sorted(language_counts.items(), key=lambda item: (-item[1], item[0])) +], [('language', 'Language'), ('count', 'Keywords')])} + +## Priority Clusters + +{to_markdown_table(top_clusters, [ + ('cluster_label', 'Cluster'), + ('recommended_target', 'Recommended Target'), + ('cluster_score', 'Cluster Score'), + ('target_now_keywords', 'Target Now'), + ('target_after_localization_keywords', 'Target After Localization'), + ('watchlist_keywords', 'Watchlist'), + ('top_keyword', 'Top Keyword'), +])} + +## Top Keywords to Target Now + +{to_markdown_table(top_target_now, [ + ('priority_rank', 'Rank'), + ('keyword', 'Keyword'), + ('language', 'Language'), + ('cluster_label', 'Cluster'), + ('file2_avg_monthly_searches', 'Avg Monthly Searches'), + ('file1_impressions', 'Impressions'), + ('priority_score', 'Score'), + ('recommended_target', 'Target'), +])} + +## Spanish Growth Keywords + +{to_markdown_table(top_localization, [ + ('priority_rank', 'Rank'), + ('keyword', 'Keyword'), + ('cluster_label', 'Cluster'), + ('file2_avg_monthly_searches', 'Avg Monthly Searches'), + ('file1_impressions', 'Impressions'), + ('priority_score', 'Score'), + ('recommendation_reason', 'Why'), +])} + +## Supporting Content Keywords + +{to_markdown_table(top_supporting, [ + ('priority_rank', 'Rank'), + ('keyword', 'Keyword'), + ('language', 'Language'), + ('cluster_label', 'Cluster'), + ('priority_score', 'Score'), + ('recommendation_reason', 'Why'), +])} + +## Watchlist + +{to_markdown_table(top_watchlist, [ + ('priority_rank', 'Rank'), + ('keyword', 'Keyword'), + ('language', 'Language'), + ('brands', 'Brands'), + ('priority_score', 'Score'), + ('recommendation_reason', 'Why'), +])} + +## Excluded Samples + +{to_markdown_table(top_excluded, [ + ('keyword', 'Keyword'), + ('language', 'Language'), + ('cluster_label', 'Cluster'), + ('priority_score', 'Score'), + ('recommendation_reason', 'Exclusion Reason'), +])} + +## Implementation Notes + +- The combined exports now show immediate live-page opportunities across `split pdf`, `compress pdf`, `merge pdf`, `pdf to word`, `word to pdf`, and adjacent OCR/conversion intent. +- Spanish is the strongest growth market in the uploaded data, but those keywords are intentionally separated into `target_after_localization` until the site ships Spanish landing pages. +- Arabic and French remain strategically valid because the product already supports both languages. Use the current dataset for targeted pages now, then supplement with native-language research before scaling site-wide coverage. +- Competitor-branded phrases are kept in the watchlist only. They should not be mixed into the core unbranded landing-page portfolio. +- Generic or malformed terms are excluded when they are too broad, not PDF-specific, or obviously generated noise from Keyword Planner suggestions. + +## Output Files + +- `prioritized_keywords.csv` - master portfolio with recommendation status, market bucket, cluster mapping, and source metrics. +- `keyword_clusters.csv` - cluster-level rollup for page planning. +- `excluded_keywords.csv` - excluded or noisy terms with reasons. +""" + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(content, encoding="utf-8") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build a keyword portfolio from Google Ads exports.") + parser.add_argument( + "--output-dir", + default=str(DEFAULT_OUTPUT_DIR), + help="Directory where the generated deliverables will be written.", + ) + parser.add_argument( + "--inputs", + nargs="*", + default=[str(path) for path in DEFAULT_INPUTS], + help="Input export files. Supports the repository's CSV and UTF-16 TSV Google Ads formats.", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + input_paths = [Path(path) if Path(path).is_absolute() else ROOT / path for path in args.inputs] + output_dir = Path(args.output_dir) if Path(args.output_dir).is_absolute() else ROOT / args.output_dir + + if not input_paths: + raise FileNotFoundError("No keyword input files were found. Add exports under docs/keyword-research/2026-04-05/Keywords or pass --inputs explicitly.") + + raw_rows: list[SourceRow] = [] + for path in input_paths: + if path.name == "KeywordStats_4_5_2026.csv": + raw_rows.extend(load_keyword_stats(path)) + else: + raw_rows.extend(load_keyword_planner(path)) + + aggregates = aggregate_rows(raw_rows) + rows, excluded = build_keyword_rows(aggregates) + clusters = build_cluster_rows(rows, excluded) + + prioritized_fields = [ + "priority_rank", + "recommendation", + "recommendation_reason", + "market_bucket", + "keyword", + "normalized_keyword", + "language", + "intent", + "cluster", + "cluster_label", + "recommended_target", + "target_type", + "priority_score", + "file2_avg_monthly_searches", + "file1_impressions", + "competition_levels", + "competition_index_max", + "brands", + "modifiers", + "source_count", + "sources", + "source_paths", + "notes", + ] + excluded_fields = [ + "keyword", + "normalized_keyword", + "language", + "intent", + "cluster", + "cluster_label", + "priority_score", + "file2_avg_monthly_searches", + "file1_impressions", + "brands", + "modifiers", + "recommendation_reason", + "sources", + "source_paths", + ] + cluster_fields = [ + "cluster", + "cluster_label", + "recommended_target", + "target_type", + "cluster_score", + "keywords_total", + "target_now_keywords", + "target_after_localization_keywords", + "supporting_content_keywords", + "watchlist_keywords", + "excluded_keywords", + "top_keyword", + "top_language", + "file1_impressions", + "file2_avg_monthly_searches", + "implementation_note", + ] + + write_csv(output_dir / "prioritized_keywords.csv", rows, prioritized_fields) + write_csv(output_dir / "excluded_keywords.csv", excluded, excluded_fields) + write_csv(output_dir / "keyword_clusters.csv", clusters, cluster_fields) + write_summary(output_dir / "keyword_strategy.md", input_paths, raw_rows, aggregates, rows, excluded, clusters) + + print(f"Generated keyword portfolio in {output_dir}") + print(f"Included rows: {len(rows)}") + print(f"Excluded rows: {len(excluded)}") + + +if __name__ == "__main__": + main() \ No newline at end of file