feat: initial import
This commit is contained in:
commit
0f9c0d93d9
22 changed files with 3563 additions and 0 deletions
249
src/snapshots/client.py
Normal file
249
src/snapshots/client.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import base64
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse, urlunparse, urljoin
|
||||
|
||||
import requests
|
||||
from babel.dates import format_date
|
||||
from babel.support import Translations
|
||||
from bs4 import BeautifulSoup
|
||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||
|
||||
from src.snapshots.config import SnapshotsConfig, config_for_url
|
||||
from src.snapshots.schemas import SnapshotContext
|
||||
|
||||
|
||||
class SnapshotParseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
ALLOWED_ASSET_TYPES = {
|
||||
"image/jpeg",
|
||||
"image/webp",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/svg+xml",
|
||||
"image/x-icon",
|
||||
}
|
||||
|
||||
|
||||
def encode_data_uri(content: bytes, content_type: str) -> str | None:
|
||||
content_type = content_type.split(";")[0].strip().lower()
|
||||
if content_type not in ALLOWED_ASSET_TYPES:
|
||||
return None
|
||||
encoded = base64.b64encode(content).decode("utf-8")
|
||||
return f"data:{content_type};base64,{encoded}"
|
||||
|
||||
|
||||
def fetch_file(filename: str) -> str | None:
|
||||
content_type = mimetypes.guess_type(filename)[0]
|
||||
if content_type not in ALLOWED_ASSET_TYPES:
|
||||
return None
|
||||
try:
|
||||
with open(filename, "rb") as f:
|
||||
return encode_data_uri(f.read(), content_type)
|
||||
except IOError:
|
||||
return None
|
||||
|
||||
|
||||
def fetch_url(base: str, url: str) -> str | None:
|
||||
if url.startswith("data:"):
|
||||
return url
|
||||
url = urljoin(base, url)
|
||||
try:
|
||||
response = requests.get(url, stream=True, timeout=0.5)
|
||||
response.raise_for_status()
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length is not None:
|
||||
try:
|
||||
if int(content_length) > 500_000:
|
||||
return None
|
||||
except ValueError:
|
||||
pass # Invalid Content-Length format, proceed to stream
|
||||
content = b""
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
content += chunk
|
||||
if len(content) > 500_000:
|
||||
return None
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
return encode_data_uri(content, content_type)
|
||||
except requests.exceptions.RequestException:
|
||||
return None
|
||||
|
||||
|
||||
class Snapshot:
|
||||
config: SnapshotsConfig | None = None
|
||||
context: SnapshotContext | None = None
|
||||
raw: bytes | None = None
|
||||
soup: BeautifulSoup | None = None
|
||||
|
||||
def __init__(self, url: str) -> None:
|
||||
self.url = url
|
||||
self.config = config_for_url(url)
|
||||
|
||||
def get_content(self) -> None:
|
||||
self.raw = requests.get(self.url, timeout=1).content
|
||||
|
||||
def _get_attribute_value(self, selector: str, attribute: str) -> str | None:
|
||||
element = self.soup.select_one(selector)
|
||||
if not element:
|
||||
return None
|
||||
try:
|
||||
return element[attribute]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def get_attribute_value(
|
||||
self, selector: str | list[str] | None, attribute: str, optional: bool = False
|
||||
) -> str | None:
|
||||
if not selector:
|
||||
if optional:
|
||||
return None
|
||||
raise SnapshotParseError("No selector specified for non-optional attribute")
|
||||
if isinstance(selector, str):
|
||||
selector = [selector]
|
||||
for s in selector:
|
||||
if result := self._get_attribute_value(s, attribute):
|
||||
return result
|
||||
if optional:
|
||||
return None
|
||||
raise SnapshotParseError("No element matched for non-optional attribute")
|
||||
|
||||
def get_element_content(
|
||||
self, selector: str | None, optional: bool = False
|
||||
) -> str | None:
|
||||
if not selector:
|
||||
if optional:
|
||||
return None
|
||||
raise SnapshotParseError("No selector specified for non-optional element")
|
||||
element = self.soup.select_one(selector)
|
||||
if not element:
|
||||
if not optional:
|
||||
raise SnapshotParseError(f"Missing element for selector: {selector}")
|
||||
return None
|
||||
return element.text
|
||||
|
||||
def _get_opengraph_value(self, prop: str) -> str | None:
|
||||
element = self.soup.select_one(f'meta[name="{prop}"]')
|
||||
if not element:
|
||||
return None
|
||||
try:
|
||||
return element["content"]
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def get_opengraph_value(
|
||||
self, prop: str | list[str] | None, optional: bool = False
|
||||
) -> str | None:
|
||||
if not prop:
|
||||
if optional:
|
||||
return None
|
||||
raise SnapshotParseError("No property specified for non-optional property")
|
||||
if isinstance(prop, str):
|
||||
prop = [prop]
|
||||
for p in prop:
|
||||
if result := self._get_opengraph_value(p):
|
||||
return result
|
||||
if optional:
|
||||
return None
|
||||
raise SnapshotParseError("No property matched for non-optional property")
|
||||
|
||||
def get_body(self):
|
||||
body = copy.copy(self.soup.select_one(self.config.article_body_selector))
|
||||
if self.config.article_body_remove_selector:
|
||||
for element in body.select(", ".join(self.config.article_body_remove_selector)):
|
||||
element.decompose()
|
||||
for image in body.select("img"):
|
||||
image.attrs = {
|
||||
"src": fetch_url(self.url, image["src"]),
|
||||
"alt": image["alt"],
|
||||
}
|
||||
return str(body)
|
||||
|
||||
def preprocess(self) -> None:
|
||||
compound = ", ".join(
|
||||
self.config.pre_remove_selectors + ["form", "script", "style", "iframe"]
|
||||
)
|
||||
for element in self.soup.select(compound):
|
||||
element.decompose()
|
||||
for element in self.soup.select("[style]"):
|
||||
element.attrs.pop("style")
|
||||
|
||||
def favicon(self):
|
||||
icon = fetch_url(
|
||||
self.url, self.get_attribute_value('link[rel="icon"]', "href", optional=True)
|
||||
)
|
||||
if icon:
|
||||
return icon
|
||||
parsed = urlparse(self.url)
|
||||
icon_url = urlunparse((parsed.scheme, parsed.netloc, "/favicon.ico", "", "", ""))
|
||||
return fetch_url(self.url, icon_url)
|
||||
|
||||
def published_time(self, locale: str = "en") -> str:
|
||||
if self.config.article_published_selector:
|
||||
if published := self.get_element_content(
|
||||
self.config.article_published_selector, optional=True
|
||||
):
|
||||
return published
|
||||
ts = datetime.datetime.fromisoformat(
|
||||
self.get_opengraph_value("article:published_time")
|
||||
)
|
||||
return format_date(ts, locale=locale)
|
||||
|
||||
def parse(self) -> None:
|
||||
self.soup = BeautifulSoup(self.raw, "lxml")
|
||||
self.preprocess()
|
||||
article_image_source = self.get_attribute_value(
|
||||
self.config.article_image_selector, "src"
|
||||
)
|
||||
page_language = self.get_attribute_value(["html", "body"], "lang", optional=True)
|
||||
self.context = SnapshotContext(
|
||||
article_author=self.get_element_content(
|
||||
self.config.article_author_selector, optional=True
|
||||
),
|
||||
article_body=self.get_body(),
|
||||
article_description=self.get_attribute_value(
|
||||
'meta[name="description"]', "content", optional=True
|
||||
),
|
||||
article_image=fetch_url(self.url, article_image_source),
|
||||
article_image_caption=self.get_element_content(
|
||||
self.config.article_image_caption_selector, optional=True
|
||||
),
|
||||
article_image_source=article_image_source,
|
||||
article_published=self.published_time(page_language),
|
||||
article_title=self.get_element_content(self.config.article_title_selector),
|
||||
article_url=self.url,
|
||||
page_direction=self.get_attribute_value(["html", "body"], "dir", optional=True),
|
||||
page_language=page_language,
|
||||
site_favicon=self.favicon(),
|
||||
site_logo=fetch_file(self.config.site_logo),
|
||||
site_title=self.config.site_title,
|
||||
)
|
||||
|
||||
def get_context(self) -> dict[str, Any]:
|
||||
logging.info("Get content")
|
||||
self.get_content()
|
||||
logging.info("Parse")
|
||||
self.parse()
|
||||
logging.info("Dump")
|
||||
return self.context.model_dump()
|
||||
|
||||
def render(self) -> str:
|
||||
context = self.get_context()
|
||||
jinja_env = Environment(
|
||||
loader=PackageLoader(
|
||||
package_name="src.snapshots",
|
||||
package_path="templates",
|
||||
),
|
||||
extensions=["jinja2.ext.i18n"],
|
||||
autoescape=select_autoescape(),
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
translations = Translations.load("i18n", [context["page_language"], "en"])
|
||||
jinja_env.install_gettext_translations(translations)
|
||||
template = jinja_env.get_template("article-template.html.j2")
|
||||
return template.render(**context)
|
||||
37
src/snapshots/config.py
Normal file
37
src/snapshots/config.py
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import fnmatch
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from src.config import CustomBaseSettings
|
||||
|
||||
|
||||
class SnapshotsConfig(BaseModel):
|
||||
article_author_selector: str | None = None
|
||||
article_image_selector: str | None = None
|
||||
article_image_caption_selector: str | None = None
|
||||
article_body_selector: str
|
||||
article_body_remove_selector: list[str] = []
|
||||
article_published_selector: str | None = Field(
|
||||
None,
|
||||
description="CSS selector for an element containing a localised publication datetime. By default, the publication date will be determined from the OpenGraph metadata.",
|
||||
)
|
||||
article_title_selector: str = "h1"
|
||||
match_urls: list[str]
|
||||
pre_remove_selectors: list[str] = "aside"
|
||||
site_logo: str
|
||||
site_title: str
|
||||
|
||||
|
||||
class Config(CustomBaseSettings):
|
||||
PARSER_CONFIGS: list[SnapshotsConfig]
|
||||
|
||||
|
||||
settings = Config()
|
||||
|
||||
|
||||
def config_for_url(url: str) -> SnapshotsConfig | None:
|
||||
for cfg in settings.PARSER_CONFIGS:
|
||||
for pattern in cfg.match_urls:
|
||||
if fnmatch.fnmatch(url, pattern):
|
||||
return cfg
|
||||
return None
|
||||
49
src/snapshots/router.py
Normal file
49
src/snapshots/router.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from starlette import status
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from src.config import settings
|
||||
from src.google.config import settings as google_settings
|
||||
from src.snapshots.client import Snapshot
|
||||
from src.snapshots.schemas import SnapshotContext
|
||||
from src.snapshots.tasks import upload_snapshot
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/debug/context",
|
||||
summary="Generate the context used by the snapshot template for debugging purposes. Endpoint disabled on production deployments.",
|
||||
response_model=SnapshotContext,
|
||||
)
|
||||
def context(url: str = "https://www.bbc.com/russian/articles/ckgeey4dqgxo"):
|
||||
if settings.ENVIRONMENT.is_debug:
|
||||
return Snapshot(url).get_context()
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/debug/demo",
|
||||
summary="Generate a rendered snapshot template for debugging purposes. Endpoint disabled on production deployments.",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
def parse(url: str = "https://www.bbc.com/russian/articles/ckgeey4dqgxo"):
|
||||
if settings.ENVIRONMENT.is_debug:
|
||||
return Snapshot(url).render()
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/debug/upload",
|
||||
summary="Generate a rendered snapshot template for debugging purposes and upload to Google Cloud Storage. Endpoint disabled on production deployments.",
|
||||
response_class=HTMLResponse,
|
||||
)
|
||||
def upload(
|
||||
background_tasks: BackgroundTasks,
|
||||
url: str = "https://www.bbc.com/russian/articles/ckgeey4dqgxo",
|
||||
):
|
||||
if settings.ENVIRONMENT.is_debug:
|
||||
rendered = Snapshot(url).render()
|
||||
background_tasks.add_task(upload_snapshot, "debug2.html", rendered)
|
||||
return f'<a href="https://storage.googleapis.com/{google_settings.BUCKET_NAME}/debug.html">Google Cloud Storage</a>'
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND)
|
||||
18
src/snapshots/schemas.py
Normal file
18
src/snapshots/schemas.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class SnapshotContext(BaseModel):
|
||||
article_author: str | None = None
|
||||
article_body: str
|
||||
article_description: str | None = None
|
||||
article_image: str | None = None
|
||||
article_image_caption: str | None = None
|
||||
article_image_source: str | None = None
|
||||
article_published: str
|
||||
article_title: str
|
||||
article_url: str
|
||||
page_direction: str | None = None
|
||||
page_language: str | None = None
|
||||
site_favicon: str | None = None
|
||||
site_logo: str = None
|
||||
site_title: str
|
||||
5
src/snapshots/tasks.py
Normal file
5
src/snapshots/tasks.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from src.google.client import upload_blob
|
||||
|
||||
|
||||
def upload_snapshot(filename: str, content: str) -> None:
|
||||
upload_blob(filename, content.encode("utf-8"), "text/html")
|
||||
165
src/snapshots/templates/article-template.html.j2
Normal file
165
src/snapshots/templates/article-template.html.j2
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
{% from "article.css.j2" import article_css %}<!DOCTYPE html>
|
||||
<html {% if page_direction %} dir="{{ page_direction }}"{% endif %}{% if page_language %} lang={{ page_language }}{% endif %} prefix="og: https://ogp.me/ns#">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{{ article_title }}</title>
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
|
||||
<meta name="format-detection" content="telephone=no"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta name="MobileOptimized" content="176"/>
|
||||
<meta name="HandheldFriendly" content="True"/>
|
||||
|
||||
<meta property="og:type" content="article"/>
|
||||
<meta property="og:title" content="{{ article_title }}"/>
|
||||
<meta property="og:site_name" content="{{ site_title }}"/>
|
||||
<meta property="og:url" content="{{ article_url }}"/>
|
||||
{% if article_image_source %}
|
||||
<meta property="og:image" content="{{ article_image_source }}"/>
|
||||
{% endif %}
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
{% if article_author %}<meta property="article:author" content="{{ article_author }}"/>{% endif %}
|
||||
|
||||
{% if noindex %}<meta name="robots" content="noindex" />{% endif %}
|
||||
|
||||
{% if site_favicon %}
|
||||
<link rel="icon" href="{{ site_favicon }}" />
|
||||
{% endif %}
|
||||
<style>
|
||||
{{ article_css() }}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
const cancelCurrentLink = function () {
|
||||
delete document.body.dataset.currentLink;
|
||||
};
|
||||
|
||||
const goToCurrentLink = function () {
|
||||
const currentLinkHref = document.body.dataset.currentLink;
|
||||
if (currentLinkHref) {
|
||||
delete document.body.dataset.currentLink;
|
||||
document.location.href = currentLinkHref;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
let target = e.target.closest("a");
|
||||
if (target) {
|
||||
// if the click was on or within an <a>
|
||||
if (!target.href.includes("cloudfront.net") &&
|
||||
!target.href.includes("azureedge.net") &&
|
||||
!target.href.includes("global.ssl.fastly.net")) {
|
||||
e.preventDefault();
|
||||
document.body.dataset.currentLink = target.href;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="snap-wrapper">
|
||||
<a href="#snap-main" class="snap-skip-link">Skip to main content</a>
|
||||
|
||||
<details class="snap-trust-header">
|
||||
<summary class="snap-trust-header__header">
|
||||
<div class="snap-trust-header__header-text">
|
||||
{{ gettext("How do I know that I can trust this page?") }}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" class="snap-trust-header__expand-icon">
|
||||
<path d="M12.0132 15.5154C12.282 15.5133 12.5392 15.4067 12.7307 15.2181L17.6953 10.2535C17.8899 10.0598 17.9996 9.79673 18.0001 9.52216C18.0008 9.24758 17.8922 8.98401 17.6986 8.7894C17.5048 8.59479 17.2417 8.4851 16.9671 8.48455C16.6925 8.48391 16.429 8.59243 16.2343 8.78608L12.0001 13.0201L7.76585 8.78608H7.76596C7.50378 8.52519 7.12238 8.42384 6.76536 8.52038C6.40823 8.61693 6.12979 8.89664 6.03484 9.2541C5.93989 9.61155 6.04286 9.99261 6.30504 10.2535L11.2696 15.2181C11.4674 15.413 11.7354 15.5201 12.0129 15.5154H12.0132Z" fill="#333333"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</summary>
|
||||
<div class="snap-trust-header__content">
|
||||
{{ gettext('This story is a copy of an article from <a href="{site_url}" class="snap-trust-header__sitelink">{site_title}</a>. It is delivered to you from a trusted archive to assure its availability over time.').format(site_url=site_url, site_title=site_title) }} <p>
|
||||
<a href="{{ article_url }}" class="snap-footer-link">
|
||||
{{ gettext("View the article source") }}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19.7212 13.0822C19.3072 13.0822 18.9712 13.4189 18.9712 13.8322V18.9712H5.02881V5.02881H10.167C10.5818 5.02881 10.917 4.69279 10.917 4.27881C10.917 3.86483 10.5818 3.52881 10.167 3.52881H4.27881C3.86405 3.52881 3.52881 3.86483 3.52881 4.27881V19.7212C3.52881 20.136 3.86405 20.4712 4.27881 20.4712H19.7212C20.136 20.4712 20.4712 20.136 20.4712 19.7212V13.8322C20.4712 13.4197 20.136 13.0822 19.7212 13.0822Z"
|
||||
fill="#222F3A"></path>
|
||||
<path
|
||||
d="M19.7212 3.52881H14.1622C13.7474 3.52881 13.4122 3.86483 13.4122 4.27881C13.4122 4.69279 13.7474 5.02881 14.1622 5.02881H17.9108L11.9978 10.9418C11.7045 11.2351 11.7045 11.7091 11.9978 12.0023C12.144 12.1485 12.3361 12.222 12.528 12.222C12.7201 12.222 12.912 12.1485 13.0583 12.0023L18.9713 6.08927V9.83927C18.9713 10.2532 19.3073 10.5893 19.7213 10.5893C20.136 10.5893 20.4713 10.2532 20.4713 9.83927V4.27887C20.4713 3.86411 20.136 3.52887 19.7213 3.52887L19.7212 3.52881Z"
|
||||
fill="#222F3A"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<header class="snap-page-header">
|
||||
<nav class="snap-page-header-nav">
|
||||
{% if article_mirror_url %}<a href="{{ article_mirror_url }}">{% endif %}
|
||||
<img src="{{ site_logo }}" alt="{{ site_title }}" class="snap-page-header-logo">
|
||||
{% if article_mirror_url %}</a>{% endif %}
|
||||
</nav>
|
||||
</header>
|
||||
<main id="snap-main">
|
||||
<header class="snap-article-header">
|
||||
<h1>{{ article_title }}</h1>
|
||||
<div class="snap-byline">
|
||||
{{ article_published }} - {{ site_title }}
|
||||
</div>
|
||||
</header>
|
||||
{% if article_image %}
|
||||
<figure>
|
||||
<img src="{{ article_image }}">
|
||||
{% if article_image_caption %}
|
||||
<figcaption>{{ article_image_caption }}</figcaption>
|
||||
{% endif %}
|
||||
</figure>
|
||||
{% endif %}
|
||||
<div class="snap-content">
|
||||
{{ article_body }}
|
||||
{% if article_mirror_url %}
|
||||
<p>
|
||||
<a href="{{ article_mirror_url }}" class="snap-footer-link">
|
||||
View the original article
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19.7212 13.0822C19.3072 13.0822 18.9712 13.4189 18.9712 13.8322V18.9712H5.02881V5.02881H10.167C10.5818 5.02881 10.917 4.69279 10.917 4.27881C10.917 3.86483 10.5818 3.52881 10.167 3.52881H4.27881C3.86405 3.52881 3.52881 3.86483 3.52881 4.27881V19.7212C3.52881 20.136 3.86405 20.4712 4.27881 20.4712H19.7212C20.136 20.4712 20.4712 20.136 20.4712 19.7212V13.8322C20.4712 13.4197 20.136 13.0822 19.7212 13.0822Z"
|
||||
fill="#222F3A"></path>
|
||||
<path d="M19.7212 3.52881H14.1622C13.7474 3.52881 13.4122 3.86483 13.4122 4.27881C13.4122 4.69279 13.7474 5.02881 14.1622 5.02881H17.9108L11.9978 10.9418C11.7045 11.2351 11.7045 11.7091 11.9978 12.0023C12.144 12.1485 12.3361 12.222 12.528 12.222C12.7201 12.222 12.912 12.1485 13.0583 12.0023L18.9713 6.08927V9.83927C18.9713 10.2532 19.3073 10.5893 19.7213 10.5893C20.136 10.5893 20.4713 10.2532 20.4713 9.83927V4.27887C20.4713 3.86411 20.136 3.52887 19.7213 3.52887L19.7212 3.52881Z"
|
||||
fill="#222F3A"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
<footer class="snap-footer">
|
||||
<div>
|
||||
{% if site_mirror_url %}<a href="https://d7qg4uz16a7xs.cloudfront.net/">{% endif %}
|
||||
<img src="{{ site_logo }}" alt="{{ site_title }} logo">
|
||||
{% if site_mirror_url %}</a>{% endif %}
|
||||
</div>
|
||||
<p>© {{ site_title }}</p>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="snap-link-warning-popup">
|
||||
<div class="snap-link-warning-popup__wrapper">
|
||||
<div class="snap-link-warning-popup__header">
|
||||
<div class="snap-link-warning-popup__title">{{ gettext("You are leaving this page") }}</div>
|
||||
<div class="snap-link-warning-popup__icon" onclick="cancelCurrentLink()">
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_235_140)">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M2.94975 3.05025C3.34027 2.65973 3.97344 2.65973 4.36396 3.05025L7.89949 6.58579L11.435 3.05025C11.8256 2.65973 12.4587 2.65973 12.8492 3.05025C13.2398 3.44078 13.2398 4.07394 12.8492 4.46447L9.31371 8L12.8492 11.5355C13.2398 11.9261 13.2398 12.5592 12.8492 12.9497C12.4587 13.3403 11.8256 13.3403 11.435 12.9497L7.89949 9.41421L4.36396 12.9497C3.97344 13.3403 3.34027 13.3403 2.94975 12.9497C2.55922 12.5592 2.55922 11.9261 2.94975 11.5355L6.48528 8L2.94975 4.46447C2.55922 4.07394 2.55922 3.44078 2.94975 3.05025Z"
|
||||
fill="#303E4F"></path>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_235_140">
|
||||
<rect width="16" height="16" fill="white"></rect>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snap-link-warning-popup__content">
|
||||
{{ gettext("This link will redirect you to an external website. If it’s not available in your region, you may not be able to access it.") }}
|
||||
</div>
|
||||
<div class="snap-link-warning-popup__button" onclick="goToCurrentLink()">{{ gettext("Continue") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
311
src/snapshots/templates/article.css.j2
Normal file
311
src/snapshots/templates/article.css.j2
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
{% macro article_css() %}
|
||||
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap');
|
||||
|
||||
html, body {
|
||||
font-size: 18px;
|
||||
font-family: "Noto Sans", Arial, sans-serif;
|
||||
font-weight: 400;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
a:focus {
|
||||
background-color: #ffc800;
|
||||
}
|
||||
|
||||
figure {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
figure img {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
font-size: 12px;
|
||||
color: #4f4f4f;
|
||||
padding-top: 8px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
#snap-main {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snap-page-header {
|
||||
background: none !important;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.snap-page-header img {
|
||||
width: 396px;
|
||||
}
|
||||
|
||||
.snap-skip-link {
|
||||
display: block;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.snap-skip-link:focus {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
color: #000;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.snap-trust-header {
|
||||
background: rgba(249, 248, 246, 1);
|
||||
overflow: hidden;
|
||||
padding: 0 16px 0 16px;
|
||||
}
|
||||
|
||||
.snap-trust-header__header {
|
||||
box-sizing: border-box;
|
||||
height: 48px;
|
||||
padding: 0px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.snap-trust-header__header-text {
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
max-width: 600px;
|
||||
font-weight: 400;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.snap-trust-header__header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.snap-trust-header__header:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.snap-trust-header__content {
|
||||
font-weight: 400;
|
||||
font-size: 18px;
|
||||
line-height: 27px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.snap-trust-header[open] .snap-trust-header__expand-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.snap-trust-header__sitelink {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.snap-article-header h1 {
|
||||
font-weight: 500;
|
||||
font-size: 40px;
|
||||
line-height: 48px;
|
||||
padding-bottom: 24px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.snap-byline {
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
padding-bottom: 20px;
|
||||
color: #4f4f4f;
|
||||
}
|
||||
|
||||
#snap-main p, #snap-main ul, #snap-main ol {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.snap-content a {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.snap-footer-link {
|
||||
box-sizing: border-box;
|
||||
color: #333;
|
||||
display: block;
|
||||
max-width: 335px;
|
||||
width: 100%;
|
||||
border: 1px solid #e0dfdd;
|
||||
border-radius: 4px;
|
||||
padding: 16px 24px;
|
||||
text-decoration: none;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.snap-footer-link:hover {
|
||||
background-color: #F9F8F6;
|
||||
}
|
||||
|
||||
.snap-footer-link:focus {
|
||||
background-color: #fff;
|
||||
border: 2px solid #222F3A;
|
||||
}
|
||||
|
||||
.snap-footer-link:active {
|
||||
background-color: rgba(34, 47, 58, 0.24);
|
||||
border-color: #e0dfdd;
|
||||
}
|
||||
|
||||
.snap-footer-link svg {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.snap-footer-link--disabled {
|
||||
color: rgba(51, 51, 51, 0.5);
|
||||
border-color: rgba(51, 51, 51, 0.5);
|
||||
background-color: transparent; /* Ensure no background on hover/focus */
|
||||
cursor: not-allowed;
|
||||
pointer-events: none; /* Disable link interaction */
|
||||
}
|
||||
|
||||
.snap-footer-link--disabled svg path {
|
||||
fill: rgba(51, 51, 51, 0.5);
|
||||
}
|
||||
|
||||
.snap-footer {
|
||||
text-align: center;
|
||||
display: block;
|
||||
color: #4f4f4f;
|
||||
background-color: #f8f9f6;
|
||||
border-bottom: 8px solid #222f3a;
|
||||
margin-top: 36px;
|
||||
padding: 83px 10px 24px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.snap-footer img {
|
||||
max-width: 237px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
#snap-main {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
.snap-page-header img {
|
||||
width: 198px;
|
||||
}
|
||||
|
||||
.snap-article-header h1 {
|
||||
font-size: 32px;
|
||||
line-height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.snap-link-warning-popup {
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
background-color: rgba(0, 0, 0, 0.24);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s linear;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body[data-current-link] .snap-link-warning-popup {
|
||||
pointer-events: initial;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.snap-link-warning-popup__wrapper {
|
||||
width: 375px;
|
||||
background-color: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px 12px 12px 12px;
|
||||
}
|
||||
|
||||
.snap-link-warning-popup__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.snap-link-warning-popup__title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 20px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.snap-link-warning-popup__icon:hover,
|
||||
.snap-link-warning-popup__button:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.snap-link-warning-popup__content {
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
text-align: left;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.snap-link-warning-popup__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 15px 16px 15px 16px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
font-size: 16px;
|
||||
font-weight: 400;
|
||||
line-height: 24px;
|
||||
text-align: center;
|
||||
color: rgba(51, 51, 51, 1);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.snap-link-warning-popup {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.snap-link-warning-popup__wrapper {
|
||||
width: unset;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
|
||||
.snap-link-warning-popup .snap-link-warning-popup__wrapper {
|
||||
transform: translateY(50%);
|
||||
transition: transform 0.3s linear;
|
||||
}
|
||||
|
||||
body[data-current-link] .snap-link-warning-popup .snap-link-warning-popup__wrapper {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
{% endmacro %}
|
||||
Loading…
Add table
Add a link
Reference in a new issue