diff --git a/06-assets/src/worker.py b/06-assets/src/worker.py index 5ba8980..fd15f5a 100644 --- a/06-assets/src/worker.py +++ b/06-assets/src/worker.py @@ -27,6 +27,4 @@ async def fetch(self, request): if path in ["/", "/index.html"]: return Response(INDEX_PAGE, headers={"Content-Type": "text/html"}) - # TODO: Once https://github.com/cloudflare/workerd/pull/4926 is released, can do - # self.env.ASSETS.fetch(request) without the .js_object - return await self.env.ASSETS.fetch(request.js_object) + return await self.env.ASSETS.fetch(request) diff --git a/11-opengraph/README.md b/11-opengraph/README.md new file mode 100644 index 0000000..4d64bb4 --- /dev/null +++ b/11-opengraph/README.md @@ -0,0 +1,68 @@ +# OpenGraph Meta Tag Injection Example + +This example demonstrates how to build a Python Worker that dynamically injects OpenGraph meta tags into web pages based on the request path. This is perfect for controlling how your content appears when shared on social media platforms like Facebook, Twitter, LinkedIn, and Slack. + +## What It Does + +The Worker: +1. **Receives a request** for a specific URL path (e.g., `/blog/my-article`) +2. **Generates OpenGraph metadata** dynamically based on the path +3. **Fetches the original HTML** from your target website +4. **Uses Cloudflare's HTMLRewriter** to inject OpenGraph meta tags into the HTML `
` section +5. **Returns the enhanced HTML** with proper social media preview tags + +This example showcases how to use Cloudflare's powerful HTMLRewriter API from Python Workers via the `js` module interop. + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Now, if you run `uv run pywrangler dev` within this directory, it should use the config +in `wrangler.jsonc` to run the example. + +```bash +uv run pywrangler dev +``` + +Then visit: +- `http://localhost:8787/` - Home page with default metadata +- `http://localhost:8787/blog/python-workers-intro` - Blog post example +- `http://localhost:8787/products/awesome-widget` - Product page example +- `http://localhost:8787/about` - About page example + +## Deployment + +Deploy to Cloudflare Workers: + +```bash +uv run pywrangler deploy +``` + +## Customization + +To adapt this example for your own website: + +1. **Update the target URL** in `src/entry.py`: + ```python + target_url = f"https://your-website.com{path}/" + ``` + +2. **Customize metadata patterns** in the `get_opengraph_data()` method: + ```python + if path.startswith("/your-section/"): + og_data.update({ + "title": "Your Custom Title", + "description": "Your custom description", + "image": "https://your-image-url.com/image.jpg" + }) + ``` + +3. **Add more URL patterns** to match your site structure + +## Testing Your OpenGraph Tags + +Use these tools to validate your OpenGraph tags: +- [Facebook Sharing Debugger](https://developers.facebook.com/tools/debug/) +- [X Card Validator](https://cards-dev.x.com/validator) +- [LinkedIn Post Inspector](https://www.linkedin.com/post-inspector/) diff --git a/11-opengraph/package.json b/11-opengraph/package.json new file mode 100644 index 0000000..b77afec --- /dev/null +++ b/11-opengraph/package.json @@ -0,0 +1,13 @@ +{ + "name": "python-opengraph", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.46.0" + } +} diff --git a/11-opengraph/pyproject.toml b/11-opengraph/pyproject.toml new file mode 100644 index 0000000..252b233 --- /dev/null +++ b/11-opengraph/pyproject.toml @@ -0,0 +1,15 @@ +[project] +name = "python-opengraph" +version = "0.1.0" +description = "Python opengraph example" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "webtypy>=0.1.7", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk" +] diff --git a/11-opengraph/src/entry.py b/11-opengraph/src/entry.py new file mode 100644 index 0000000..4b7f3a5 --- /dev/null +++ b/11-opengraph/src/entry.py @@ -0,0 +1,170 @@ +from workers import WorkerEntrypoint, Request, fetch +from js import HTMLRewriter +from urllib.parse import urlparse +from html import escape + +from pyodide.ffi import create_proxy + + +class MetaTagInjector: + """ + Element handler for HTMLRewriter that injects OpenGraph meta tags. + Uses Python's html.escape() for proper HTML escaping. + """ + + def __init__(self, og_data: dict): + self.og_data = og_data + self.injected = False + + def element(self, element): + """Called when the element is encountered.""" + if not self.injected: + # Create and inject meta tags + self._inject_meta_tags(element) + self.injected = True + + def _inject_meta_tags(self, head_element): + """Inject OpenGraph and Twitter Card meta tags.""" + # OpenGraph tags + self._create_meta(head_element, "property", "og:title", self.og_data["title"]) + self._create_meta( + head_element, "property", "og:description", self.og_data["description"] + ) + self._create_meta(head_element, "property", "og:image", self.og_data["image"]) + self._create_meta(head_element, "property", "og:url", self.og_data["url"]) + self._create_meta(head_element, "property", "og:type", self.og_data["type"]) + self._create_meta( + head_element, "property", "og:site_name", self.og_data["site_name"] + ) + + # Twitter Card tags + self._create_meta(head_element, "name", "twitter:card", "summary_large_image") + self._create_meta(head_element, "name", "twitter:title", self.og_data["title"]) + self._create_meta( + head_element, "name", "twitter:description", self.og_data["description"] + ) + self._create_meta(head_element, "name", "twitter:image", self.og_data["image"]) + + def _create_meta(self, head_element, attr_name: str, attr_value: str, content: str): + """ + Create a meta tag and prepend it to the head element. + Uses Python's html.escape() for proper attribute escaping. + """ + # Use Python's built-in html.escape() which handles all necessary escaping + escaped_attr_value = escape(attr_value, quote=True) + escaped_content = escape(content, quote=True) + meta_html = ( + f'' + ) + head_element.prepend(meta_html, html=True) + + +class ExistingMetaRemover: + """ + Element handler that removes existing OpenGraph and Twitter meta tags. + """ + + def element(self, element): + """Remove the element by calling remove().""" + element.remove() + + +class Default(WorkerEntrypoint): + """ + OpenGraph Meta Tag Injection Example + + This Worker fetches a web page and injects OpenGraph meta tags + based on the request path using Cloudflare's HTMLRewriter API. + """ + + async def fetch(self, request: Request): + # Parse the request path to determine which page we're serving + url = urlparse(request.url) + path = url.path + + # Define OpenGraph metadata based on the path + og_data = self.get_opengraph_data(path) + + # Fetch the original HTML from a target website + # In this example, we'll use example.com, but you can replace this + # with your actual website URL + # + # Note that this isn't necessary if your worker will also be serving + # content of your website, in that case you should already have the HTML + # you're returning ready to go here. + target_url = f"https://example.com{path}/" + + # Fetch the original page + response = await fetch(target_url) + + # Use HTMLRewriter to inject OpenGraph meta tags + rewritten_response = self.inject_opengraph_tags(response, og_data) + + return rewritten_response + + def get_opengraph_data(self, path: str) -> dict: + """ + Generate OpenGraph metadata based on the request path. + Customize this function to match your site's structure. + """ + # Default metadata + og_data = { + "title": "My Awesome Website", + "description": "Welcome to my website built with Python Workers!", + "image": "https://images.unsplash.com/photo-1518770660439-4636190af475", + "url": f"https://yoursite.com{path}/", + "type": "website", + "site_name": "Python Workers Demo", + } + + # Customize based on path + if path.startswith("/blog/"): + article_slug = path.replace("/blog/", "").strip("/") + og_data.update( + { + "title": f"Blog Post: {article_slug.replace('-', ' ').title()}", + "description": f"Read our latest article about {article_slug.replace('-', ' ')}", + "image": "https://images.unsplash.com/photo-1499750310107-5fef28a66643", + "type": "article", + } + ) + elif path.startswith("/products/"): + product_slug = path.replace("/products/", "").strip("/") + og_data.update( + { + "title": f"Product: {product_slug.replace('-', ' ').title()}", + "description": f"Check out our amazing {product_slug.replace('-', ' ')} product", + "image": "https://images.unsplash.com/photo-1505740420928-5e560c06d30e", + "type": "product", + } + ) + elif path == "/about": + og_data.update( + { + "title": "About Us - Python Workers", + "description": "Learn more about our team and what we do with Python Workers", + "image": "https://images.unsplash.com/photo-1522071820081-009f0129c71c", + } + ) + + return og_data + + def inject_opengraph_tags(self, response, og_data: dict): + """ + Use HTMLRewriter to inject OpenGraph meta tags into the HTML response. + Removes existing OG tags first to avoid duplicates. + """ + # Create an HTMLRewriter instance + rewriter = HTMLRewriter.new() + + meta_remover = create_proxy(ExistingMetaRemover()) + meta_injector = create_proxy(MetaTagInjector(og_data)) + + rewriter = HTMLRewriter.new() + # Remove existing OpenGraph and Twitter meta tags to avoid duplicates + rewriter.on('meta[property^="og:"]', meta_remover) + rewriter.on('meta[name^="twitter:"]', meta_remover) + # Inject new OpenGraph meta tags into the element + rewriter.on("head", meta_injector) + + return rewriter.transform(response.js_object) diff --git a/11-opengraph/wrangler.jsonc b/11-opengraph/wrangler.jsonc new file mode 100644 index 0000000..42be06b --- /dev/null +++ b/11-opengraph/wrangler.jsonc @@ -0,0 +1,12 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "python-opengraph", + "main": "src/entry.py", + "compatibility_date": "2025-11-02", + "compatibility_flags": [ + "python_workers" + ], + "observability": { + "enabled": true + } +} diff --git a/12-image-gen/README.md b/12-image-gen/README.md new file mode 100644 index 0000000..fa555fa --- /dev/null +++ b/12-image-gen/README.md @@ -0,0 +1,44 @@ +# Image Generation with Pillow Example + +This example demonstrates how to build a Python Worker that dynamically generates images using the Pillow (PIL) library. + +## What It Does + +The Worker provides four different image generation endpoints: +1. **Gradient Generator** (`/gradient`) - Creates gradient images with customizable colors and dimensions +2. **Badge Generator** (`/badge`) - Generates badges or buttons with text +3. **Placeholder Generator** (`/placeholder`) - Creates placeholder images with dimensions displayed +4. **Chart Generator** (`/chart`) - Produces simple bar charts + +## How to Run + +First ensure that `uv` is installed: +https://docs.astral.sh/uv/getting-started/installation/#standalone-installer + +Now, if you run `uv run pywrangler dev` within this directory, it should use the config +in `wrangler.jsonc` to run the example. + +```bash +uv run pywrangler dev +``` + +Then visit: +- `http://localhost:8787/` - Interactive demo page with all examples +- `http://localhost:8787/gradient?width=600&height=300&color1=FF6B6B&color2=4ECDC4` - Gradient image +- `http://localhost:8787/badge?text=Python+Workers&bg_color=2196F3` - Custom badge +- `http://localhost:8787/placeholder?width=500&height=300` - Placeholder image +- `http://localhost:8787/chart?values=15,30,25,40,20&labels=Mon,Tue,Wed,Thu,Fri` - Bar chart + +## Deployment + +Deploy to Cloudflare Workers: + +```bash +uv run pywrangler deploy +``` + +## Learn More + +- [Pillow Documentation](https://pillow.readthedocs.io/) +- [Python Workers Documentation](https://developers.cloudflare.com/workers/languages/python/) +- [ImageDraw Reference](https://pillow.readthedocs.io/en/stable/reference/ImageDraw.html) diff --git a/12-image-gen/package.json b/12-image-gen/package.json new file mode 100644 index 0000000..26070a9 --- /dev/null +++ b/12-image-gen/package.json @@ -0,0 +1,13 @@ +{ + "name": "python-image-gen", + "version": "0.0.0", + "private": true, + "scripts": { + "deploy": "uv run pywrangler deploy", + "dev": "uv run pywrangler dev", + "start": "uv run pywrangler dev" + }, + "devDependencies": { + "wrangler": "^4.46.0" + } +} diff --git a/12-image-gen/pyproject.toml b/12-image-gen/pyproject.toml new file mode 100644 index 0000000..42aeac3 --- /dev/null +++ b/12-image-gen/pyproject.toml @@ -0,0 +1,16 @@ +[project] +name = "python-image-gen" +version = "0.1.0" +description = "Python image generation example using Pillow" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "webtypy>=0.1.7", + "pillow", +] + +[dependency-groups] +dev = [ + "workers-py", + "workers-runtime-sdk" +] diff --git a/12-image-gen/src/entry.py b/12-image-gen/src/entry.py new file mode 100644 index 0000000..1bad378 --- /dev/null +++ b/12-image-gen/src/entry.py @@ -0,0 +1,339 @@ +from workers import WorkerEntrypoint, Response, Request +from PIL import Image, ImageDraw, ImageFont +from io import BytesIO +from urllib.parse import urlparse, parse_qs +import random +from pathlib import Path + +from pyodide.ffi import to_js + + +class Default(WorkerEntrypoint): + """ + Image Generation Example using Pillow (PIL) + + This Worker demonstrates how to use the Pillow library to dynamically + generate images in a Cloudflare Python Worker. It showcases various + image generation techniques including gradients, text rendering, shapes, + and more. + + Available endpoints: + - /gradient - Generate a colorful gradient image + - /badge - Generate a badge with custom text + - /placeholder - Generate a placeholder image with dimensions + - /chart - Generate a simple bar chart + - / - Show available endpoints + """ + + async def fetch(self, request: Request): + # Parse the request URL to determine which image to generate + url = urlparse(request.url) + path = url.path + + # Parse query parameters for customization + query_params = parse_qs(url.query) + + # Route to different image generators based on path + if path == "/gradient": + return self.generate_gradient(query_params) + elif path == "/badge": + return self.generate_badge(query_params) + elif path == "/placeholder": + return self.generate_placeholder(query_params) + elif path == "/chart": + return self.generate_chart(query_params) + else: + # Return a simple HTML page showing available endpoints + return self.show_endpoints() + + def generate_gradient(self, params: dict) -> Response: + """ + Generate a gradient image. + + Query parameters: + - width: Image width (default: 800) + - height: Image height (default: 400) + - color1: Start color in hex (default: random) + - color2: End color in hex (default: random) + """ + # Get dimensions from query params or use defaults + width = int(params.get("width", [800])[0]) + height = int(params.get("height", [400])[0]) + + # Get colors or generate random ones + color1 = params.get("color1", [None])[0] + color2 = params.get("color2", [None])[0] + + if not color1: + color1 = "#{:06x}".format(random.randint(0, 0xFFFFFF)) + if not color2: + color2 = "#{:06x}".format(random.randint(0, 0xFFFFFF)) + + # Convert hex colors to RGB tuples + r1, g1, b1 = self.hex_to_rgb(color1) + r2, g2, b2 = self.hex_to_rgb(color2) + + # Create a new image with RGB mode + image = Image.new("RGB", (width, height)) + draw = ImageDraw.Draw(image) + + # Draw gradient by interpolating between colors + for y in range(height): + # Calculate interpolation factor (0.0 to 1.0) + factor = y / height + + # Interpolate each color channel + r = int(r1 + (r2 - r1) * factor) + g = int(g1 + (g2 - g1) * factor) + b = int(b1 + (b2 - b1) * factor) + + # Draw a horizontal line with the interpolated color + draw.line([(0, y), (width, y)], fill=(r, g, b)) + + # Convert image to bytes and return as PNG + return self.image_to_response(image, "image/png") + + def generate_badge(self, params: dict) -> Response: + """ + Generate a badge/button with custom text. + + Query parameters: + - text: Badge text (default: "Hello World") + - bg_color: Background color in hex (default: #4CAF50) + - text_color: Text color in hex (default: #FFFFFF) + """ + # Get parameters + text = params.get("text", ["Hello World"])[0] + bg_color = params.get("bg_color", ["#4CAF50"])[0] + text_color = params.get("text_color", ["#FFFFFF"])[0] + + # Convert hex colors to RGB + bg_rgb = self.hex_to_rgb(bg_color) + text_rgb = self.hex_to_rgb(text_color) + + # Create image with padding for text + # We'll estimate size based on text length + padding = 20 + + # Create a temporary image to measure text size + temp_img = Image.new("RGB", (1, 1)) + temp_draw = ImageDraw.Draw(temp_img) + + # Use default font (Pillow's built-in font) + # Note: In a production environment, you might want to include custom fonts + font = ImageFont.load_default() + + # Get text bounding box to calculate required image size + bbox = temp_draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + # Create the actual image with proper dimensions + width = text_width + (padding * 2) + height = text_height + (padding * 2) + + image = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(image) + + # Draw rounded rectangle background (simulate with rectangle for simplicity) + # Draw the text centered + text_x = padding + text_y = padding + draw.text((text_x, text_y), text, fill=text_rgb, font=font) + + return self.image_to_response(image, "image/png") + + def generate_placeholder(self, params: dict) -> Response: + """ + Generate a placeholder image with dimensions displayed. + + Query parameters: + - width: Image width (default: 400) + - height: Image height (default: 300) + - bg_color: Background color in hex (default: #CCCCCC) + - text_color: Text color in hex (default: #666666) + """ + # Get dimensions + width = int(params.get("width", [400])[0]) + height = int(params.get("height", [300])[0]) + bg_color = params.get("bg_color", ["#CCCCCC"])[0] + text_color = params.get("text_color", ["#666666"])[0] + + # Convert colors + bg_rgb = self.hex_to_rgb(bg_color) + text_rgb = self.hex_to_rgb(text_color) + + # Create image + image = Image.new("RGB", (width, height), bg_rgb) + draw = ImageDraw.Draw(image) + + # Draw an X across the image + draw.line([(0, 0), (width, height)], fill=text_rgb, width=2) + draw.line([(width, 0), (0, height)], fill=text_rgb, width=2) + + # Draw border + draw.rectangle([(0, 0), (width - 1, height - 1)], outline=text_rgb, width=2) + + # Add dimensions text in the center + text = f"{width} ร {height}" + + font = ImageFont.load_default() + + # Get text size and center it + bbox = draw.textbbox((0, 0), text, font=font) + text_width = bbox[2] - bbox[0] + text_height = bbox[3] - bbox[1] + + text_x = (width - text_width) // 2 + text_y = (height - text_height) // 2 + + # Draw text with a background for better visibility + padding = 10 + draw.rectangle( + [ + (text_x - padding, text_y - padding), + (text_x + text_width + padding, text_y + text_height + padding), + ], + fill=bg_rgb, + ) + draw.text((text_x, text_y), text, fill=text_rgb, font=font) + + return self.image_to_response(image, "image/png") + + def generate_chart(self, params: dict) -> Response: + """ + Generate a simple bar chart. + + Query parameters: + - values: Comma-separated values (default: 10,25,15,30,20) + - labels: Comma-separated labels (default: A,B,C,D,E) + - color: Bar color in hex (default: #2196F3) + """ + # Parse values and labels + values_str = params.get("values", ["10,25,15,30,20"])[0] + values = [int(v.strip()) for v in values_str.split(",")] + + labels_str = params.get("labels", ["A,B,C,D,E"])[0] + labels = [label.strip() for label in labels_str.split(",")] + + bar_color = params.get("color", ["#2196F3"])[0] + bar_rgb = self.hex_to_rgb(bar_color) + + # Chart dimensions + width = 600 + height = 400 + padding = 50 + chart_width = width - (padding * 2) + chart_height = height - (padding * 2) + + # Create image with white background + image = Image.new("RGB", (width, height), (255, 255, 255)) + draw = ImageDraw.Draw(image) + + # Draw axes + draw.line( + [(padding, padding), (padding, height - padding)], fill=(0, 0, 0), width=2 + ) # Y-axis + draw.line( + [(padding, height - padding), (width - padding, height - padding)], + fill=(0, 0, 0), + width=2, + ) # X-axis + + # Calculate bar dimensions + num_bars = len(values) + bar_width = chart_width // (num_bars * 2) + spacing = bar_width + max_value = max(values) if values else 1 + + # Draw bars + font = ImageFont.load_default() + + for i, (value, label) in enumerate(zip(values, labels)): + # Calculate bar position and height + bar_height = int((value / max_value) * chart_height) + x = padding + spacing + (i * (bar_width + spacing)) + y = height - padding - bar_height + + # Draw bar + draw.rectangle( + [(x, y), (x + bar_width, height - padding)], + fill=bar_rgb, + outline=(0, 0, 0), + ) + + # Draw value on top of bar + value_text = str(value) + bbox = draw.textbbox((0, 0), value_text, font=font) + text_width = bbox[2] - bbox[0] + draw.text( + (x + (bar_width - text_width) // 2, y - 20), + value_text, + fill=(0, 0, 0), + font=font, + ) + + # Draw label below bar + bbox = draw.textbbox((0, 0), label, font=font) + text_width = bbox[2] - bbox[0] + draw.text( + (x + (bar_width - text_width) // 2, height - padding + 5), + label, + fill=(0, 0, 0), + font=font, + ) + + return self.image_to_response(image, "image/png") + + def hex_to_rgb(self, hex_color: str) -> tuple: + """ + Convert a hex color string to an RGB tuple. + + Args: + hex_color: Color in format "#RRGGBB" or "RRGGBB" + + Returns: + Tuple of (R, G, B) values + """ + # Remove '#' if present + hex_color = hex_color.lstrip("#") + + # Convert to RGB + return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + def image_to_response(self, image: Image.Image, content_type: str) -> Response: + """ + Convert a PIL Image to a Response object. + + Args: + image: PIL Image object + content_type: MIME type for the response + + Returns: + Response object with image data + """ + # Create a BytesIO buffer to hold the image data + buffer = BytesIO() + + # Save image to buffer in PNG format + image.save(buffer, format="PNG") + + image_bytes = buffer.getvalue() + + # Create and return response with appropriate headers + # + # TODO: This currently performs an unnecessary copy, need to fix. + return Response( + to_js(image_bytes).buffer, + headers={ + "Content-Type": content_type, + "Cache-Control": "public, max-age=3600", + }, + ) + + def show_endpoints(self) -> Response: + """ + Return an HTML page showing available endpoints and examples. + """ + index = Path(__file__).parent / "index.html" + return Response(index.read_text(), headers={"Content-Type": "text/html"}) diff --git a/12-image-gen/src/index.html b/12-image-gen/src/index.html new file mode 100644 index 0000000..317c105 --- /dev/null +++ b/12-image-gen/src/index.html @@ -0,0 +1,125 @@ + + + + + +This Python Worker demonstrates dynamic image generation using the Pillow library.
+ +Generate gradient images with custom colors and dimensions.
+Endpoint: /gradient
Parameters:
+width - Image width (default: 800)height - Image height (default: 400)color1 - Start color in hex (default: random)color2 - End color in hex (default: random)Create custom badges or buttons with text.
+Endpoint: /badge
Parameters:
+text - Badge text (default: "Hello World")bg_color - Background color in hex (default: #4CAF50)text_color - Text color in hex (default: #FFFFFF)Generate placeholder images with dimensions displayed.
+Endpoint: /placeholder
Parameters:
+width - Image width (default: 400)height - Image height (default: 300)bg_color - Background color in hex (default: #CCCCCC)text_color - Text color in hex (default: #666666)Create simple bar charts with custom data.
+Endpoint: /chart
Parameters:
+values - Comma-separated values (default: 10,25,15,30,20)labels - Comma-separated labels (default: A,B,C,D,E)color - Bar color in hex (default: #2196F3)