Files
2026-05-08 17:59:37 +02:00

172 lines
5.6 KiB
Python

"""
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 <img> 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 <img> 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"<img[^>]+>", 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 <p>...</p>, remove the whole paragraph so we don't
# leave an empty <p></p> behind.
wrapped = re.search(
r"<p>\s*" + re.escape(img_tag) + r"\s*</p>",
html,
re.IGNORECASE,
)
if wrapped:
html = html[: wrapped.start()] + html[wrapped.end():]
prepend = f"<p>{img_tag}</p>\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"),
)