172 lines
5.6 KiB
Python
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"),
|
|
)
|