Merge pull request #42 from wasi-master/copilot/feat-make-strings-localisable

feat: make UI strings localisable via LOCALE environment variable
This commit is contained in:
Wasi Master
2026-05-12 00:54:25 +06:00
committed by GitHub
7 changed files with 263 additions and 56 deletions
+3
View File
@@ -0,0 +1,3 @@
__pycache__
*.pyc
*.pyo
+22
View File
@@ -166,6 +166,28 @@ WantedBy=multi-user.target
</VirtualHost> </VirtualHost>
``` ```
## Localisation
The UI strings can be localised by setting the `LOCALE` environment variable before starting the app. Locale files live in `app/locales/<locale>.json`.
Built-in locales:
- `en` (default)
- `de`
- `fr`
Examples:
```sh
# from app
LOCALE=de python portable.py
# or with gunicorn
LOCALE=fr python -m gunicorn 'portable:app'
```
To add a new locale, copy `app/locales/en.json` to a new file such as `app/locales/es.json` and translate the values.
## Screenshots ## Screenshots
### Step 1 ### Step 1
Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
{
"heading": "Website-Link eingeben",
"label": "Link der Website, deren Paywall Sie entfernen möchten:",
"submit": "Absenden",
"toggle_dark_mode": "Dunkelmodus umschalten",
"status_heading": "Artikel wird geladen",
"error_title": "Etwas ist schiefgelaufen",
"retry_button": "Andere URL versuchen",
"elapsed_template": "{seconds}s vergangen",
"connection_lost_error": "Verbindung zum Server verloren. Das Laden der Seite dauert möglicherweise zu lange.",
"step_connect": "Verbindung zur Website wird hergestellt...",
"step_fetch": "Seiteninhalt wird heruntergeladen...",
"step_detect": "Es wird nach Anti-Bot-Schutz gesucht...",
"step_fallback_freedium": "Freedium wird versucht (Medium-Umgehung)...",
"step_fallback_org": "Es wird ein archive.org-Schnappschuss versucht...",
"step_fallback_ph": "Es wird ein archive.today-/archive.ph-Schnappschuss versucht...",
"step_process": "Artikel wird verarbeitet...",
"step_cleanup": "Ansicht wird bereinigt und vorbereitet...",
"step_done": "Fertig!",
"invalid_url": "Ungültige URL",
"medium_challenge_error": "Medium hat eine Anti-Bot-Prüfung ausgeliefert und Freedium / archive.org / archive.today lieferten kein Ergebnis. Der Artikel ist möglicherweise noch zu neu, um archiviert worden zu sein.",
"challenge_error": "Die Website hat eine Anti-Bot-Prüfung (Cloudflare oder ähnlich) ausgeliefert und es wurde kein brauchbarer Schnappschuss auf archive.org oder archive.today gefunden. Diese Website blockiert aktiv automatisierte Anfragen.",
"timeout_error": "Die Website hat zu lange zum Antworten gebraucht (30s-Timeout). Bitte versuchen Sie es später erneut.",
"connection_error": "Die Verbindung zur Website konnte nicht hergestellt werden. Prüfen Sie die URL und versuchen Sie es erneut.",
"request_error": "Die Seite konnte nicht geladen werden.",
"unexpected_error": "Unerwarteter Fehler beim Laden der Seite."
}
+27
View File
@@ -0,0 +1,27 @@
{
"heading": "Enter Website Link",
"label": "Link of the website you want to remove paywall for:",
"submit": "Submit",
"toggle_dark_mode": "Toggle Dark Mode",
"status_heading": "Fetching Article",
"error_title": "Something went wrong",
"retry_button": "Try Another URL",
"elapsed_template": "{seconds}s elapsed",
"connection_lost_error": "Connection to server lost. The page may be taking too long to load.",
"step_connect": "Connecting to website...",
"step_fetch": "Downloading page content...",
"step_detect": "Checking for anti-bot challenges...",
"step_fallback_freedium": "Trying Freedium (Medium bypass)...",
"step_fallback_org": "Trying archive.org snapshot...",
"step_fallback_ph": "Trying archive.today / archive.ph snapshot...",
"step_process": "Processing article...",
"step_cleanup": "Cleaning up & preparing view...",
"step_done": "Done!",
"invalid_url": "Invalid URL",
"medium_challenge_error": "Medium served an anti-bot challenge and Freedium / archive.org / archive.today all came back empty. The article may be too new to have been archived yet.",
"challenge_error": "The site served an anti-bot challenge (Cloudflare or similar) and no usable snapshot was found on archive.org or archive.today. This site is actively blocking automated requests.",
"timeout_error": "The website took too long to respond (30s timeout). Try again later.",
"connection_error": "Could not connect to the website. Check the URL and try again.",
"request_error": "Failed to fetch the page.",
"unexpected_error": "Unexpected error while fetching the page."
}
+27
View File
@@ -0,0 +1,27 @@
{
"heading": "Entrez le lien du site web",
"label": "Lien du site web dont vous souhaitez supprimer le paywall :",
"submit": "Soumettre",
"toggle_dark_mode": "Basculer en mode sombre",
"status_heading": "Récupération de l'article",
"error_title": "Une erreur s'est produite",
"retry_button": "Essayer une autre URL",
"elapsed_template": "{seconds}s écoulées",
"connection_lost_error": "Connexion au serveur perdue. Le chargement de la page prend peut-être trop de temps.",
"step_connect": "Connexion au site web...",
"step_fetch": "Téléchargement du contenu de la page...",
"step_detect": "Vérification des protections anti-bot...",
"step_fallback_freedium": "Essai de Freedium (contournement de Medium)...",
"step_fallback_org": "Essai d'un instantané archive.org...",
"step_fallback_ph": "Essai d'un instantané archive.today / archive.ph...",
"step_process": "Traitement de l'article...",
"step_cleanup": "Nettoyage et préparation de l'affichage...",
"step_done": "Terminé !",
"invalid_url": "URL invalide",
"medium_challenge_error": "Medium a présenté une protection anti-bot et Freedium / archive.org / archive.today n'ont rien renvoyé. L'article est peut-être trop récent pour avoir déjà été archivé.",
"challenge_error": "Le site a présenté une protection anti-bot (Cloudflare ou similaire) et aucun instantané exploitable n'a été trouvé sur archive.org ou archive.today. Ce site bloque activement les requêtes automatisées.",
"timeout_error": "Le site web a mis trop de temps à répondre (délai de 30 s). Veuillez réessayer plus tard.",
"connection_error": "Impossible de se connecter au site web. Vérifiez l'URL et réessayez.",
"request_error": "Impossible de récupérer la page.",
"unexpected_error": "Erreur inattendue lors du chargement de la page."
}
+157 -56
View File
@@ -1,3 +1,4 @@
import json
import os import os
import flask import flask
import re import re
@@ -5,11 +6,79 @@ import requests
import uuid import uuid
import threading import threading
import time import time
from functools import lru_cache
from urllib.parse import urljoin, urlparse, quote from urllib.parse import urljoin, urlparse, quote
from flask import request, Response from flask import request, Response
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
app = flask.Flask(__name__) app = flask.Flask(__name__)
_DEFAULT_STRINGS = {
"heading": "Enter Website Link",
"label": "Link of the website you want to remove paywall for:",
"submit": "Submit",
"toggle_dark_mode": "Toggle Dark Mode",
"status_heading": "Fetching Article",
"error_title": "Something went wrong",
"retry_button": "Try Another URL",
"elapsed_template": "{seconds}s elapsed",
"connection_lost_error": "Connection to server lost. The page may be taking too long to load.",
"step_connect": "Connecting to website...",
"step_fetch": "Downloading page content...",
"step_detect": "Checking for anti-bot challenges...",
"step_fallback_freedium": "Trying Freedium (Medium bypass)...",
"step_fallback_org": "Trying archive.org snapshot...",
"step_fallback_ph": "Trying archive.today / archive.ph snapshot...",
"step_process": "Processing article...",
"step_cleanup": "Cleaning up & preparing view...",
"step_done": "Done!",
"invalid_url": "Invalid URL",
"medium_challenge_error": (
"Medium served an anti-bot challenge and Freedium / archive.org / "
"archive.today all came back empty. The article may be too new to "
"have been archived yet."
),
"challenge_error": (
"The site served an anti-bot challenge (Cloudflare or similar) "
"and no usable snapshot was found on archive.org or archive.today. "
"This site is actively blocking automated requests."
),
"timeout_error": "The website took too long to respond (30s timeout). Try again later.",
"connection_error": "Could not connect to the website. Check the URL and try again.",
"request_error": "Failed to fetch the page.",
"unexpected_error": "Unexpected error while fetching the page.",
}
def get_locale():
locale = os.environ.get("LOCALE", "en")
if not re.match(r"^[a-zA-Z0-9_-]+$", locale):
locale = "en"
return locale
@lru_cache(maxsize=None)
def load_strings_for_locale(locale):
strings = dict(_DEFAULT_STRINGS)
locale_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "locales")
locale_file = os.path.join(locale_dir, f"{locale}.json")
if locale != "en":
try:
with open(locale_file, "r", encoding="utf-8") as file:
strings.update(json.load(file))
except (FileNotFoundError, json.JSONDecodeError, OSError):
strings = dict(_DEFAULT_STRINGS)
locale = "en"
strings["lang"] = locale
return strings
def load_strings():
return dict(load_strings_for_locale(get_locale()))
googlebot_headers = { googlebot_headers = {
"User-Agent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.119 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" "User-Agent": "Mozilla/5.0 (Linux; Android 6.0.1; Nexus 5X Build/MMB29P) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.6533.119 Mobile Safari/537.36 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"
} }
@@ -17,9 +86,15 @@ googlebot_headers = {
jobs = {} jobs = {}
jobs_lock = threading.Lock() jobs_lock = threading.Lock()
class UserFacingError(Exception):
def __init__(self, user_message):
super().__init__(user_message)
self.user_message = user_message
html = """ html = """
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="{{ lang }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
@@ -249,21 +324,21 @@ html = """
<body> <body>
<div class="dark-mode-toggle"> <div class="dark-mode-toggle">
<input type="checkbox" id="dark-mode-toggle"> <input type="checkbox" id="dark-mode-toggle">
<label for="dark-mode-toggle" title="Toggle Dark Mode"></label> <label for="dark-mode-toggle" title="{{ toggle_dark_mode }}"></label>
</div> </div>
<div id="form-view"> <div id="form-view">
<form id="url-form"> <form id="url-form">
<h1>Enter Website Link</h1> <h1>{{ heading }}</h1>
<label for="link">Link of the website you want to remove paywall for:</label> <label for="link">{{ label }}</label>
<input type="text" id="link" name="link" required autofocus> <input type="text" id="link" name="link" required autofocus>
<input type="submit" value="Submit"> <input type="submit" value="{{ submit }}">
</form> </form>
</div> </div>
<div id="status-view" style="display:none;"> <div id="status-view" style="display:none;">
<div class="status-container"> <div class="status-container">
<h1>Fetching Article</h1> <h1>{{ status_heading }}</h1>
<div class="url-display" id="status-url"></div> <div class="url-display" id="status-url"></div>
<div class="progress-bar-track"> <div class="progress-bar-track">
<div class="progress-bar-fill" id="progress-fill"></div> <div class="progress-bar-fill" id="progress-fill"></div>
@@ -293,17 +368,8 @@ html = """
} }
}); });
const STEPS = [ const UI_STRINGS = {{ ui_strings | tojson }};
{ id: 'connect', label: 'Connecting to website...' }, const STEPS = {{ steps | tojson }};
{ id: 'fetch', label: 'Downloading page content...' },
{ id: 'detect', label: 'Checking for anti-bot challenges...' },
{ id: 'fallback_freedium', label: 'Trying Freedium (Medium bypass)...' },
{ id: 'fallback_org', label: 'Trying archive.org snapshot...' },
{ id: 'fallback_ph', label: 'Trying archive.today / archive.ph snapshot...' },
{ id: 'process', label: 'Processing article...' },
{ id: 'cleanup', label: 'Cleaning up & preparing view...' },
{ id: 'done', label: 'Done!' }
];
function renderSteps(activeIdx) { function renderSteps(activeIdx) {
const container = document.getElementById('steps-list'); const container = document.getElementById('steps-list');
@@ -343,14 +409,14 @@ html = """
const box = document.createElement('div'); const box = document.createElement('div');
box.className = 'error-box'; box.className = 'error-box';
const strong = document.createElement('strong'); const strong = document.createElement('strong');
strong.textContent = 'Something went wrong'; strong.textContent = UI_STRINGS.error_title;
box.appendChild(strong); box.appendChild(strong);
box.appendChild(document.createTextNode(message)); box.appendChild(document.createTextNode(message));
area.appendChild(box); area.appendChild(box);
const retry = document.createElement('a'); const retry = document.createElement('a');
retry.href = '/'; retry.href = '/';
retry.className = 'retry-btn'; retry.className = 'retry-btn';
retry.textContent = 'Try Another URL'; retry.textContent = UI_STRINGS.retry_button;
area.appendChild(retry); area.appendChild(retry);
} }
@@ -366,7 +432,7 @@ html = """
const timer = setInterval(() => { const timer = setInterval(() => {
const sec = ((Date.now() - startTime) / 1000).toFixed(0); const sec = ((Date.now() - startTime) / 1000).toFixed(0);
elapsedEl.textContent = sec + 's elapsed'; elapsedEl.textContent = UI_STRINGS.elapsed_template.replace('{seconds}', sec);
}, 500); }, 500);
const evtSource = new EventSource('/status/' + encodeURIComponent(link)); const evtSource = new EventSource('/status/' + encodeURIComponent(link));
@@ -404,7 +470,7 @@ html = """
evtSource.onerror = function() { evtSource.onerror = function() {
evtSource.close(); evtSource.close();
clearInterval(timer); clearInterval(timer);
showError('Connection to server lost. The page may be taking too long to load.'); showError(UI_STRINGS.connection_lost_error);
}; };
}); });
</script> </script>
@@ -414,6 +480,36 @@ html = """
""" """
def build_steps(strings):
return [
{"id": "connect", "label": strings["step_connect"]},
{"id": "fetch", "label": strings["step_fetch"]},
{"id": "detect", "label": strings["step_detect"]},
{"id": "fallback_freedium", "label": strings["step_fallback_freedium"]},
{"id": "fallback_org", "label": strings["step_fallback_org"]},
{"id": "fallback_ph", "label": strings["step_fallback_ph"]},
{"id": "process", "label": strings["step_process"]},
{"id": "cleanup", "label": strings["step_cleanup"]},
{"id": "done", "label": strings["step_done"]},
]
def render_main_page():
strings = load_strings()
ui_strings = {
"error_title": strings["error_title"],
"retry_button": strings["retry_button"],
"elapsed_template": strings["elapsed_template"],
"connection_lost_error": strings["connection_lost_error"],
}
return flask.render_template_string(
html,
steps=build_steps(strings),
ui_strings=ui_strings,
**strings,
)
def add_base_tag(html_content, original_url): def add_base_tag(html_content, original_url):
soup = BeautifulSoup(html_content, "html.parser") soup = BeautifulSoup(html_content, "html.parser")
parsed_url = urlparse(original_url) parsed_url = urlparse(original_url)
@@ -568,7 +664,7 @@ def fetch_via_archive_ph(url, job_id=None):
return None, None return None, None
def bypass_paywall(url, job_id=None): def bypass_paywall(url, strings, job_id=None):
set_step(job_id, 'connect') set_step(job_id, 'connect')
if not url.startswith("http"): if not url.startswith("http"):
@@ -611,18 +707,10 @@ def bypass_paywall(url, job_id=None):
if not recovered: if not recovered:
if medium: if medium:
msg = ( msg = strings["medium_challenge_error"]
"Medium served an anti-bot challenge and Freedium / archive.org / "
"archive.today all came back empty. The article may be too new to "
"have been archived yet."
)
else: else:
msg = ( msg = strings["challenge_error"]
"The site served an anti-bot challenge (Cloudflare or similar) " raise UserFacingError(msg)
"and no usable snapshot was found on archive.org or archive.today. "
"This site is actively blocking automated requests."
)
raise RuntimeError(msg)
set_step(job_id, 'process') set_step(job_id, 'process')
result = add_base_tag(html_text, final_url) result = add_base_tag(html_text, final_url)
@@ -631,48 +719,47 @@ def bypass_paywall(url, job_id=None):
return result return result
def fetch_worker(job_id, url): def fetch_worker(job_id, url, strings):
try: try:
result = bypass_paywall(url, job_id) result = bypass_paywall(url, strings, job_id)
with jobs_lock: with jobs_lock:
jobs[job_id]['result'] = result jobs[job_id]['result'] = result
jobs[job_id]['step'] = 'done' jobs[job_id]['step'] = 'done'
except requests.exceptions.Timeout: except requests.exceptions.Timeout:
with jobs_lock: with jobs_lock:
jobs[job_id]['error'] = 'The website took too long to respond (30s timeout). Try again later.' jobs[job_id]['error'] = strings["timeout_error"]
jobs[job_id]['step'] = 'error' jobs[job_id]['step'] = 'error'
except requests.exceptions.ConnectionError: except requests.exceptions.ConnectionError:
with jobs_lock: with jobs_lock:
jobs[job_id]['error'] = 'Could not connect to the website. Check the URL and try again.' jobs[job_id]['error'] = strings["connection_error"]
jobs[job_id]['step'] = 'error' jobs[job_id]['step'] = 'error'
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
with jobs_lock: with jobs_lock:
jobs[job_id]['error'] = f'Failed to fetch the page: {e}' jobs[job_id]['error'] = strings["request_error"]
jobs[job_id]['step'] = 'error' jobs[job_id]['step'] = 'error'
except RuntimeError as e: except UserFacingError as error:
with jobs_lock: with jobs_lock:
jobs[job_id]['error'] = str(e) jobs[job_id]['error'] = error.user_message
jobs[job_id]['step'] = 'error' jobs[job_id]['step'] = 'error'
except Exception as e: except Exception as e:
with jobs_lock: with jobs_lock:
jobs[job_id]['error'] = f'Unexpected error: {e}' jobs[job_id]['error'] = strings["unexpected_error"]
jobs[job_id]['step'] = 'error' jobs[job_id]['step'] = 'error'
@app.route("/") @app.route("/")
def main_page(): def main_page():
return html return render_main_page()
@app.route("/status/<path:url>") @app.route("/status/<path:url>")
def status_stream(url): def status_stream(url):
import json strings = load_strings()
job_id = str(uuid.uuid4()) job_id = str(uuid.uuid4())
with jobs_lock: with jobs_lock:
jobs[job_id] = {'step': 'queued', 'result': None, 'error': None} jobs[job_id] = {'step': 'queued', 'result': None, 'error': None}
thread = threading.Thread(target=fetch_worker, args=(job_id, url)) thread = threading.Thread(target=fetch_worker, args=(job_id, url, strings))
thread.daemon = True thread.daemon = True
thread.start() thread.start()
@@ -710,29 +797,43 @@ def status_stream(url):
@app.route("/article", methods=["POST"]) @app.route("/article", methods=["POST"])
def show_article(): def show_article():
link = flask.request.form["link"] link = flask.request.form["link"]
strings = load_strings()
try: try:
return bypass_paywall(link) return bypass_paywall(link, strings)
except requests.exceptions.RequestException as e: except requests.exceptions.Timeout:
return str(e), 400 return strings["timeout_error"], 400
except Exception as e: except requests.exceptions.ConnectionError:
raise e return strings["connection_error"], 400
except requests.exceptions.RequestException:
return strings["request_error"], 400
except UserFacingError as error:
return error.user_message, 400
except Exception:
return strings["unexpected_error"], 500
@app.route("/", defaults={"path": ""}) @app.route("/", defaults={"path": ""})
@app.route("/<path:path>", methods=["GET"]) @app.route("/<path:path>", methods=["GET"])
def get_article(path): def get_article(path):
strings = load_strings()
full_url = request.url full_url = request.url
parts = full_url.split("/", 4) parts = full_url.split("/", 4)
if len(parts) >= 5: if len(parts) >= 5:
actual_url = "https://" + parts[4].lstrip("/") actual_url = "https://" + parts[4].lstrip("/")
try: try:
return bypass_paywall(actual_url) return bypass_paywall(actual_url, strings)
except requests.exceptions.RequestException as e: except requests.exceptions.Timeout:
return str(e), 400 return strings["timeout_error"], 400
except Exception as e: except requests.exceptions.ConnectionError:
raise e return strings["connection_error"], 400
except requests.exceptions.RequestException:
return strings["request_error"], 400
except UserFacingError as error:
return error.user_message, 400
except Exception:
return strings["unexpected_error"], 500
else: else:
return "Invalid URL", 400 return strings["invalid_url"], 400
if __name__ == "__main__": if __name__ == "__main__":