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:
co-authored by
wasi-master
parent
b6b831f156
commit
e7669b7e08
@@ -166,6 +166,28 @@ WantedBy=multi-user.target
|
||||
</VirtualHost>
|
||||
```
|
||||
|
||||
## Localisation
|
||||
|
||||
The UI strings can be localised by setting the `LOCALE` environment variable before starting the app. Locale files live in `/home/runner/work/13ft/13ft/app/locales/<locale>.json`.
|
||||
|
||||
Built-in locales:
|
||||
|
||||
- `en` (default)
|
||||
- `de`
|
||||
- `fr`
|
||||
|
||||
Examples:
|
||||
|
||||
```sh
|
||||
# from /home/runner/work/13ft/13ft/app
|
||||
LOCALE=de python portable.py
|
||||
|
||||
# or with gunicorn
|
||||
LOCALE=fr python -m gunicorn 'portable:app'
|
||||
```
|
||||
|
||||
To add a new locale, copy `/home/runner/work/13ft/13ft/app/locales/en.json` to a new file such as `/home/runner/work/13ft/13ft/app/locales/es.json` and translate the values.
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Step 1
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user