feat: make UI strings localisable via LOCALE environment variable

Agent-Logs-Url: https://github.com/wasi-master/13ft/sessions/3b6e6061-c4d2-4aa1-879b-8526ccd3b1db

Co-authored-by: wasi-master <63045920+wasi-master@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-05-11 18:41:32 +00:00
committed by GitHub
co-authored by wasi-master
parent b6b831f156
commit e7669b7e08
5 changed files with 221 additions and 47 deletions
+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": "Seite konnte nicht geladen werden: {error}",
"unexpected_error": "Unerwarteter Fehler: {error}"
}
+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: {error}",
"unexpected_error": "Unexpected error: {error}"
}
+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": "Échec de récupération de la page : {error}",
"unexpected_error": "Erreur inattendue : {error}"
}
+118 -47
View File
@@ -1,3 +1,4 @@
import json
import os
import flask
import re
@@ -10,6 +11,62 @@ from flask import request, Response
from bs4 import BeautifulSoup
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: {error}",
"unexpected_error": "Unexpected error: {error}",
}
def load_strings():
locale = os.environ.get("LOCALE", "en")
if not re.match(r"^[a-zA-Z0-9_-]+$", locale):
locale = "en"
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):
locale = "en"
strings["lang"] = locale
return strings
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)"
}
@@ -19,7 +76,7 @@ jobs_lock = threading.Lock()
html = """
<!DOCTYPE html>
<html lang="en">
<html lang="{{ lang }}">
<head>
<meta charset="UTF-8">
@@ -249,21 +306,21 @@ html = """
<body>
<div class="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 id="form-view">
<form id="url-form">
<h1>Enter Website Link</h1>
<label for="link">Link of the website you want to remove paywall for:</label>
<h1>{{ heading }}</h1>
<label for="link">{{ label }}</label>
<input type="text" id="link" name="link" required autofocus>
<input type="submit" value="Submit">
<input type="submit" value="{{ submit }}">
</form>
</div>
<div id="status-view" style="display:none;">
<div class="status-container">
<h1>Fetching Article</h1>
<h1>{{ status_heading }}</h1>
<div class="url-display" id="status-url"></div>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progress-fill"></div>
@@ -293,17 +350,8 @@ html = """
}
});
const STEPS = [
{ id: 'connect', label: 'Connecting to website...' },
{ 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!' }
];
const UI_STRINGS = {{ ui_strings | tojson }};
const STEPS = {{ steps | tojson }};
function renderSteps(activeIdx) {
const container = document.getElementById('steps-list');
@@ -343,14 +391,14 @@ html = """
const box = document.createElement('div');
box.className = 'error-box';
const strong = document.createElement('strong');
strong.textContent = 'Something went wrong';
strong.textContent = UI_STRINGS.error_title;
box.appendChild(strong);
box.appendChild(document.createTextNode(message));
area.appendChild(box);
const retry = document.createElement('a');
retry.href = '/';
retry.className = 'retry-btn';
retry.textContent = 'Try Another URL';
retry.textContent = UI_STRINGS.retry_button;
area.appendChild(retry);
}
@@ -366,7 +414,7 @@ html = """
const timer = setInterval(() => {
const sec = ((Date.now() - startTime) / 1000).toFixed(0);
elapsedEl.textContent = sec + 's elapsed';
elapsedEl.textContent = UI_STRINGS.elapsed_template.replace('{seconds}', sec);
}, 500);
const evtSource = new EventSource('/status/' + encodeURIComponent(link));
@@ -404,7 +452,7 @@ html = """
evtSource.onerror = function() {
evtSource.close();
clearInterval(timer);
showError('Connection to server lost. The page may be taking too long to load.');
showError(UI_STRINGS.connection_lost_error);
};
});
</script>
@@ -414,6 +462,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):
soup = BeautifulSoup(html_content, "html.parser")
parsed_url = urlparse(original_url)
@@ -568,7 +646,7 @@ def fetch_via_archive_ph(url, job_id=None):
return None, None
def bypass_paywall(url, job_id=None):
def bypass_paywall(url, strings, job_id=None):
set_step(job_id, 'connect')
if not url.startswith("http"):
@@ -611,17 +689,9 @@ def bypass_paywall(url, job_id=None):
if not recovered:
if medium:
msg = (
"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."
)
msg = strings["medium_challenge_error"]
else:
msg = (
"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."
)
msg = strings["challenge_error"]
raise RuntimeError(msg)
set_step(job_id, 'process')
@@ -631,23 +701,23 @@ def bypass_paywall(url, job_id=None):
return result
def fetch_worker(job_id, url):
def fetch_worker(job_id, url, strings):
try:
result = bypass_paywall(url, job_id)
result = bypass_paywall(url, strings, job_id)
with jobs_lock:
jobs[job_id]['result'] = result
jobs[job_id]['step'] = 'done'
except requests.exceptions.Timeout:
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'
except requests.exceptions.ConnectionError:
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'
except requests.exceptions.RequestException as e:
with jobs_lock:
jobs[job_id]['error'] = f'Failed to fetch the page: {e}'
jobs[job_id]['error'] = strings["request_error"].format(error=e)
jobs[job_id]['step'] = 'error'
except RuntimeError as e:
with jobs_lock:
@@ -655,24 +725,23 @@ def fetch_worker(job_id, url):
jobs[job_id]['step'] = 'error'
except Exception as e:
with jobs_lock:
jobs[job_id]['error'] = f'Unexpected error: {e}'
jobs[job_id]['error'] = strings["unexpected_error"].format(error=e)
jobs[job_id]['step'] = 'error'
@app.route("/")
def main_page():
return html
return render_main_page()
@app.route("/status/<path:url>")
def status_stream(url):
import json
strings = load_strings()
job_id = str(uuid.uuid4())
with jobs_lock:
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.start()
@@ -710,10 +779,11 @@ def status_stream(url):
@app.route("/article", methods=["POST"])
def show_article():
link = flask.request.form["link"]
strings = load_strings()
try:
return bypass_paywall(link)
return bypass_paywall(link, strings)
except requests.exceptions.RequestException as e:
return str(e), 400
return strings["request_error"].format(error=e), 400
except Exception as e:
raise e
@@ -721,18 +791,19 @@ def show_article():
@app.route("/", defaults={"path": ""})
@app.route("/<path:path>", methods=["GET"])
def get_article(path):
strings = load_strings()
full_url = request.url
parts = full_url.split("/", 4)
if len(parts) >= 5:
actual_url = "https://" + parts[4].lstrip("/")
try:
return bypass_paywall(actual_url)
return bypass_paywall(actual_url, strings)
except requests.exceptions.RequestException as e:
return str(e), 400
return strings["request_error"].format(error=e), 400
except Exception as e:
raise e
else:
return "Invalid URL", 400
return strings["invalid_url"], 400
if __name__ == "__main__":