mirror of
https://github.com/immich-app/static-pages.git
synced 2026-09-30 13:23:05 +08:00
195 lines
6.0 KiB
Python
195 lines
6.0 KiB
Python
# /// script
|
|
# requires-python = ">=3.14"
|
|
# dependencies = [
|
|
# "requests == 2.32.5",
|
|
# "python-frontmatter == 1.1.0",
|
|
# "mistletoe == 1.5.0",
|
|
# "pillow == 12.0.0",
|
|
# "boto3 == 1.40.64"
|
|
# ]
|
|
# ///
|
|
import datetime
|
|
import hashlib
|
|
import os
|
|
import re
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import Iterable
|
|
from urllib.parse import urljoin, urlparse, urlunparse
|
|
|
|
import boto3
|
|
import frontmatter
|
|
import mistletoe
|
|
import requests
|
|
from mistletoe import span_token
|
|
from mistletoe.markdown_renderer import MarkdownRenderer, Fragment
|
|
from PIL import Image
|
|
|
|
WEBP_QUALITY = 85
|
|
BLOG_PREFIX = "blog"
|
|
OUTPUT_BASE = Path("apps/root.immich.app/src/routes/blog")
|
|
|
|
|
|
def slugify(text: str) -> str:
|
|
slug = text.lower().strip()
|
|
slug = re.sub(r'[^\w\s-]', '', slug)
|
|
slug = re.sub(r'[-\s]+', '-', slug)
|
|
return slug.strip('-')
|
|
|
|
|
|
def hash_content(data: bytes) -> str:
|
|
return hashlib.md5(data).hexdigest()
|
|
|
|
|
|
def download_image(url: str, auth_header: str) -> bytes:
|
|
response = requests.get(url, headers={"Authorization": auth_header})
|
|
response.raise_for_status()
|
|
return response.content
|
|
|
|
|
|
def convert_to_webp(image_data: bytes, quality: int = WEBP_QUALITY) -> bytes:
|
|
img = Image.open(BytesIO(image_data))
|
|
|
|
if img.mode in ('RGBA', 'LA', 'P'):
|
|
background = Image.new('RGB', img.size, (255, 255, 255))
|
|
if img.mode == 'P':
|
|
img = img.convert('RGBA')
|
|
background.paste(img, mask=img.split()[-1] if img.mode in ('RGBA', 'LA') else None)
|
|
img = background
|
|
elif img.mode != 'RGB':
|
|
img = img.convert('RGB')
|
|
|
|
output = BytesIO()
|
|
img.save(output, format='WEBP', quality=quality)
|
|
return output.getvalue()
|
|
|
|
|
|
class ImageProcessingRenderer(MarkdownRenderer):
|
|
def __init__(self, importer: 'PostImporter', uuid: str):
|
|
super().__init__()
|
|
self.importer = importer
|
|
self.uuid = uuid
|
|
|
|
def render_image(self, token: span_token.Image) -> Iterable[Fragment]:
|
|
original_url = token.src
|
|
if not original_url.startswith(('http://', 'https://')):
|
|
parts = self.importer.outline_url_parts
|
|
original_url = urlunparse(parts._replace(path=original_url))
|
|
|
|
print(f"Processing image: {original_url}")
|
|
|
|
image_data = download_image(original_url, self.importer.outline_auth_header)
|
|
content_hash = hash_content(image_data)
|
|
webp_data = convert_to_webp(image_data)
|
|
|
|
s3_key = f"{BLOG_PREFIX}/{self.uuid}/{content_hash}.webp"
|
|
self.importer.upload_to_s3(webp_data, s3_key)
|
|
|
|
token.src = f"{self.importer.r2_public_url}/{s3_key}"
|
|
print(f"Replaced with: {token.src}")
|
|
|
|
return super().render_image(token)
|
|
|
|
|
|
class PostImporter:
|
|
def __init__(self):
|
|
self.outline_auth_header = f"Bearer {os.environ["OUTLINE_API_KEY"]}"
|
|
|
|
self.outline_url_parts = urlparse(os.environ["OUTLINE_POST_URL"])
|
|
|
|
self.r2_bucket_name = os.environ["R2_BUCKET_NAME"]
|
|
self.r2_public_url = os.environ["R2_PUBLIC_URL"].rstrip('/')
|
|
|
|
self.s3 = boto3.client(
|
|
's3',
|
|
endpoint_url=os.environ["R2_ENDPOINT_URL"],
|
|
aws_access_key_id=os.environ["R2_ACCESS_KEY_ID"],
|
|
aws_secret_access_key=os.environ["R2_SECRET_ACCESS_KEY"]
|
|
)
|
|
|
|
def fetch_post_from_outline(self) -> tuple[dict, frontmatter.Post]:
|
|
post_id = self.outline_url_parts.path.removeprefix('/doc/').rstrip('/')
|
|
api_url = urlunparse(self.outline_url_parts._replace(path='/api/documents.info'))
|
|
|
|
headers = {"Authorization": self.outline_auth_header}
|
|
response = requests.post(api_url, headers=headers, json={"id": post_id})
|
|
response.raise_for_status()
|
|
post = response.json()
|
|
|
|
text = post['data']['text'].replace('\\---', '---', 2)
|
|
post_data = frontmatter.loads(text)
|
|
|
|
return post, post_data
|
|
|
|
def clear_s3_directory(self, prefix: str) -> None:
|
|
print(f"Clearing S3 directory: {prefix}")
|
|
|
|
paginator = self.s3.get_paginator('list_objects_v2')
|
|
pages = paginator.paginate(Bucket=self.r2_bucket_name, Prefix=prefix)
|
|
|
|
objects_to_delete = []
|
|
for page in pages:
|
|
if 'Contents' in page:
|
|
objects_to_delete.extend([{'Key': obj['Key']} for obj in page['Contents']])
|
|
|
|
if objects_to_delete:
|
|
self.s3.delete_objects(
|
|
Bucket=self.r2_bucket_name,
|
|
Delete={'Objects': objects_to_delete}
|
|
)
|
|
print(f"Deleted {len(objects_to_delete)} objects from {prefix}")
|
|
|
|
def upload_to_s3(self, data: bytes, key: str, content_type: str = 'image/webp') -> None:
|
|
response = self.s3.put_object(
|
|
Bucket=self.r2_bucket_name,
|
|
Key=key,
|
|
Body=data,
|
|
ContentType=content_type
|
|
)
|
|
etag = response.get('ETag', '').strip('"')
|
|
print(f"Uploaded to S3: {key} (ETag: {etag})")
|
|
|
|
def process_images(self, post_data: frontmatter.Post, uuid: str) -> str:
|
|
with ImageProcessingRenderer(self, uuid) as renderer:
|
|
doc = mistletoe.Document(post_data.content) # TODO: Verify content has no frontmatter
|
|
return renderer.render(doc)
|
|
|
|
def update_frontmatter(self, post_data: frontmatter.Post, post: dict, slug: str) -> None:
|
|
post_data['id'] = post['data']['id']
|
|
post_data['title'] = post['data']['title']
|
|
post_data['publishedAt'] = datetime.date.today()
|
|
post_data['authors'] = ['Immich Team']
|
|
post_data['slug'] = slug
|
|
|
|
def write_output(self, post_data: frontmatter.Post, slug: str) -> Path:
|
|
output_dir = OUTPUT_BASE / slug
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
output_file = output_dir / "+page.md"
|
|
|
|
output_file.write_text(frontmatter.dumps(post_data))
|
|
return output_file
|
|
|
|
def run(self) -> None:
|
|
post, post_data = self.fetch_post_from_outline()
|
|
uuid = post['data']['id']
|
|
slug = post_data.get('slug') or slugify(post['data']['title'])
|
|
|
|
self.clear_s3_directory(f"{BLOG_PREFIX}/{uuid}/")
|
|
|
|
rendered_markdown = self.process_images(post_data, uuid)
|
|
post_data.content = rendered_markdown
|
|
|
|
self.update_frontmatter(post_data, post, slug)
|
|
output_file = self.write_output(post_data, slug)
|
|
|
|
print(f"\nPost written to: {output_file}")
|
|
print(f"Post slug: {slug}")
|
|
|
|
if output := os.environ.get('GITHUB_OUTPUT'):
|
|
with open(output, 'a') as o:
|
|
o.write(f"slug={slug}\n")
|
|
o.write(f"uuid={uuid}\n")
|
|
|
|
|
|
PostImporter().run()
|