""" medium-proxy — transparent HTTPS proxy between Postiz and api.medium.com Intercepts POST requests to Medium's post creation endpoints and applies two transformations before forwarding to the real API: 1. Markdown → HTML conversion (fixes table rendering — Medium's API does not render GFM tables when contentFormat is 'markdown') 2. Featured image placement — moves the first to the top of the content so Medium picks it up as the story cover All other requests (GET /v1/me, publications, etc.) are forwarded unchanged. """ import json import logging import re import httpx import markdown from fastapi import FastAPI, Request, Response logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s") logger = logging.getLogger("medium-proxy") MEDIUM_API_BASE = "https://api.medium.com" # HTTP hop-by-hop headers that must not be forwarded HOP_BY_HOP = { "host", "content-length", "transfer-encoding", "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "upgrade", } app = FastAPI(title="medium-proxy", docs_url=None, redoc_url=None) # ── Content transformations ─────────────────────────────────────────────────── def markdown_to_html(content: str) -> str: """ Convert Markdown to HTML with GFM table and fenced code block support. Medium's Markdown renderer ignores pipe-table syntax, producing raw '|' characters in the published story. Sending HTML with contentFormat='html' is the only reliable way to get proper table rendering. """ return markdown.markdown( content, extensions=["tables", "fenced_code"], ) def move_first_image_to_top(html: str) -> str: """ Ensure the first element appears at the very beginning of the HTML. Medium treats the first image in a story as the cover/featured image shown in listings and social previews. Without this, stories published via the API show a generated pattern instead of the article's real thumbnail. If no image is found, the HTML is returned unchanged. """ img_match = re.search(r"]+>", html, re.IGNORECASE) if not img_match: return html img_tag = img_match.group(0) # Already at the start — nothing to move if img_match.start() == 0: return html # Remove the image from its current position. # If it sits inside a

...

, remove the whole paragraph so we don't # leave an empty

behind. wrapped = re.search( r"

\s*" + re.escape(img_tag) + r"\s*

", html, re.IGNORECASE, ) if wrapped: html = html[: wrapped.start()] + html[wrapped.end():] prepend = f"

{img_tag}

\n" else: pos = html.find(img_tag) html = html[:pos] + html[pos + len(img_tag):] prepend = img_tag + "\n" return prepend + html def process_post_body(body: dict) -> dict: """ Transform a Medium post creation request body. Only modifies requests where contentFormat is 'markdown'. Returns a new dict; the original is never mutated. """ if body.get("contentFormat") != "markdown": return body content = body.get("content", "") html = markdown_to_html(content) html = move_first_image_to_top(html) logger.info("Converted markdown → html (tables + featured image)") return {**body, "content": html, "contentFormat": "html"} # ── Proxy route ─────────────────────────────────────────────────────────────── @app.api_route( "/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"], ) async def proxy(request: Request, path: str): """ Forward every request to api.medium.com. POST requests to endpoints containing 'posts' are processed before forwarding. """ body_bytes = await request.body() body_json = None is_post_creation = request.method == "POST" and "posts" in path if request.method in ("POST", "PUT", "PATCH") and body_bytes: try: body_json = json.loads(body_bytes) if is_post_creation: original_fmt = body_json.get("contentFormat", "?") body_json = process_post_body(body_json) logger.info( "POST /%s | contentFormat: %s → %s", path, original_fmt, body_json.get("contentFormat"), ) except (json.JSONDecodeError, ValueError): # Not JSON — will forward raw bytes pass forward_headers = { k: v for k, v in request.headers.items() if k.lower() not in HOP_BY_HOP } async with httpx.AsyncClient(timeout=30.0) as client: kwargs: dict = dict( method=request.method, url=f"{MEDIUM_API_BASE}/{path}", headers=forward_headers, params=dict(request.query_params), ) if body_json is not None: kwargs["json"] = body_json elif body_bytes: kwargs["content"] = body_bytes upstream = await client.request(**kwargs) # Strip hop-by-hop + content-encoding from upstream response resp_headers = { k: v for k, v in upstream.headers.items() if k.lower() not in HOP_BY_HOP | {"content-encoding"} } return Response( content=upstream.content, status_code=upstream.status_code, headers=resp_headers, media_type=upstream.headers.get("content-type"), )