422 lines
12 KiB
Python
422 lines
12 KiB
Python
"""LeetCode Daily Challenge - Fetch & Render as PNG / MD
|
|
|
|
Dependencies:
|
|
- PNG output: pip install Pillow
|
|
- Markdown output: pip install html2text
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import os
|
|
import urllib.request
|
|
import re
|
|
from pathlib import Path
|
|
import html2text
|
|
|
|
|
|
# === GraphQL Query ===
|
|
GRAPHQL_QUERY = """
|
|
query questionOfToday {
|
|
activeDailyCodingChallengeQuestion {
|
|
date
|
|
question {
|
|
questionId
|
|
title
|
|
titleSlug
|
|
difficulty
|
|
content
|
|
acRate
|
|
topicTags {
|
|
name
|
|
slug
|
|
}
|
|
exampleTestcases
|
|
}
|
|
}
|
|
}
|
|
"""
|
|
|
|
# === Fetch Data ===
|
|
def fetch_daily_challenge():
|
|
url = "https://leetcode.com/graphql/"
|
|
payload = json.dumps({"query": GRAPHQL_QUERY}).encode()
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=payload,
|
|
headers={
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
|
},
|
|
)
|
|
with urllib.request.urlopen(req) as resp:
|
|
data = json.loads(resp.read().decode())
|
|
|
|
challenge = data.get("data", {}).get("activeDailyCodingChallengeQuestion")
|
|
if not challenge:
|
|
raise RuntimeError("No daily challenge found")
|
|
|
|
q = challenge["question"]
|
|
return {
|
|
"date": challenge["date"],
|
|
"title": q["title"],
|
|
"title_slug": q["titleSlug"],
|
|
"question_id": q["questionId"],
|
|
"difficulty": q["difficulty"],
|
|
"content": q.get("content", ""),
|
|
"tags": [t["name"] for t in (q.get("topicTags") or [])],
|
|
"example_cases": q.get("exampleTestcases", ""),
|
|
"ac_rate": q.get("acRate", 0),
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Pillow card renderer — zero browser dependency
|
|
# ============================================================
|
|
|
|
def _find_font():
|
|
"""Find a suitable TrueType font across platforms."""
|
|
candidates = []
|
|
if sys.platform == "win32":
|
|
pf = os.environ.get("WINDIR", r"C:\Windows")
|
|
candidates = [
|
|
os.path.join(pf, "Fonts", "msyh.ttc"),
|
|
os.path.join(pf, "Fonts", "msyhbd.ttc"),
|
|
os.path.join(pf, "Fonts", "simhei.ttf"),
|
|
os.path.join(pf, "Fonts", "simsun.ttc"),
|
|
]
|
|
elif sys.platform == "darwin":
|
|
candidates = [
|
|
"/System/Library/Fonts/PingFang.ttc",
|
|
"/System/Library/Fonts/STHeiti Light.ttc",
|
|
"/Library/Fonts/Arial Unicode.ttf",
|
|
]
|
|
else:
|
|
candidates = [
|
|
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
|
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
|
|
]
|
|
for p in candidates:
|
|
if os.path.exists(p):
|
|
return p
|
|
return None
|
|
|
|
|
|
def _html_to_markdown(html):
|
|
"""Convert LeetCode content HTML to Markdown using html2text."""
|
|
# 清理所有 Unicode 不可见字符
|
|
html = re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff\u00ad]', '', html)
|
|
html = re.sub(r'[\xa0\u3000]', ' ', html) # non-breaking space, ideographic space
|
|
html = re.sub(r'[\uff00-\uffef]', '', html) # halfwidth/fullwidth forms
|
|
|
|
h = html2text.HTML2Text()
|
|
h.ignore_links = False
|
|
h.ignore_images = True
|
|
h.body_width = 0
|
|
|
|
md = h.handle(html)
|
|
# 清理 markdown 输出中的不可见字符
|
|
md = re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff]', '', md)
|
|
md = re.sub(r'\n{3,}', '\n\n', md)
|
|
return md.strip()
|
|
|
|
|
|
def _markdown_to_text(md):
|
|
"""Convert Markdown to plain text for Pillow rendering."""
|
|
if not md:
|
|
return ""
|
|
|
|
text = md
|
|
|
|
# 清理不可见 Unicode 字符
|
|
text = re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff]', '', text) # zero-width spaces
|
|
text = re.sub(r'[\xa0]', ' ', text) # non-breaking space
|
|
|
|
# 转换 HTML 实体
|
|
text = text.replace('<', '<')
|
|
text = text.replace('>', '>')
|
|
text = text.replace('&', '&')
|
|
text = text.replace('"', '"')
|
|
text = text.replace(''', "'")
|
|
text = text.replace(''', "'")
|
|
|
|
# 处理特殊引号
|
|
text = text.replace('"', '"').replace('"', '"')
|
|
text = text.replace(''', "'").replace(''', "'")
|
|
text = text.replace('《', '<').replace('》', '>')
|
|
|
|
# 清理 Markdown 格式
|
|
text = re.sub(r'\*\*(.*?)\*\*', r'\1', text)
|
|
text = re.sub(r'\*(.*?)*', r'\1', text)
|
|
text = re.sub(r'__(.*?)__', r'\1', text)
|
|
text = re.sub(r'_(.*?)_', r'\1', text)
|
|
text = re.sub(r'`(.*?)`', r'\1', text)
|
|
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
|
|
text = re.sub(r'^[\-\*+]\s+', '- ', text, flags=re.MULTILINE)
|
|
text = re.sub(r'^\d+\.\s+', '- ', text, flags=re.MULTILINE)
|
|
text = re.sub(r'\[(.*?)\]\(.*?\)', r'\1', text)
|
|
text = re.sub(r'!\[(.*?)\]\(.*?\)', r'\1', text)
|
|
text = re.sub(r'^```.*$', '', text, flags=re.MULTILINE)
|
|
text = re.sub(r'\n{3,}', '\n\n', text)
|
|
|
|
return text.strip()
|
|
|
|
|
|
def _wrap_text(text, font, max_width, draw):
|
|
"""Wrap text to fit within max_width pixels."""
|
|
lines = []
|
|
for paragraph in text.split("\n"):
|
|
if not paragraph.strip():
|
|
lines.append("")
|
|
continue
|
|
current = ""
|
|
for ch in paragraph:
|
|
test = current + ch
|
|
bbox = draw.textbbox((0, 0), test, font=font)
|
|
if bbox[2] - bbox[0] > max_width and current:
|
|
lines.append(current)
|
|
current = ch
|
|
else:
|
|
current = test
|
|
if current:
|
|
lines.append(current)
|
|
return lines
|
|
|
|
|
|
def render_image(data, desc_md, img_path):
|
|
"""Render card as PNG using only Pillow.
|
|
|
|
Args:
|
|
data: LeetCode question data dict
|
|
desc_md: Pre-converted markdown from HTML
|
|
img_path: Output image path
|
|
"""
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
except ImportError:
|
|
raise ImportError("Pillow not installed. Run: pip install Pillow")
|
|
|
|
font_path = _find_font()
|
|
if not font_path:
|
|
raise RuntimeError("No CJK font found. Install a Chinese font or set WINDIR/font path.")
|
|
|
|
try:
|
|
font_title = ImageFont.truetype(font_path, 26)
|
|
font_body = ImageFont.truetype(font_path, 16)
|
|
font_small = ImageFont.truetype(font_path, 14)
|
|
font_tag = ImageFont.truetype(font_path, 13)
|
|
font_header = ImageFont.truetype(font_path, 20)
|
|
font_icon = ImageFont.truetype(font_path, 17)
|
|
except Exception:
|
|
font_title = ImageFont.load_default()
|
|
font_body = font_small = font_tag = font_header = font_icon = font_title
|
|
|
|
W = 540
|
|
PAD = 28
|
|
RADIUS = 20
|
|
HEADER_H = 76
|
|
|
|
# 将 Markdown 转换为纯文本
|
|
desc_text = _markdown_to_text(desc_md) if desc_md else ""
|
|
|
|
draw_dummy = ImageDraw.Draw(Image.new("RGB", (W, 100)))
|
|
y = PAD
|
|
|
|
title_lines = _wrap_text(data["title"], font_title, W - PAD * 2, draw_dummy)
|
|
y += len(title_lines) * 34 + 8
|
|
|
|
y += 28 + 16
|
|
|
|
if data["tags"]:
|
|
tag_x = PAD
|
|
tag_row_h = 32
|
|
for t in data["tags"]:
|
|
tw = draw_dummy.textlength(t + " ", font=font_tag) + 24
|
|
if tag_x + tw > W - PAD:
|
|
y += tag_row_h + 8
|
|
tag_x = PAD
|
|
tag_x += tw + 8
|
|
y += tag_row_h + 22
|
|
|
|
if desc_text:
|
|
desc_lines = _wrap_text(desc_text, font_body, W - PAD * 2, draw_dummy)
|
|
y += 20
|
|
y += len(desc_lines) * 24
|
|
y += 8
|
|
|
|
TOTAL_H = y + PAD
|
|
TOTAL_H = max(TOTAL_H, 400)
|
|
|
|
img = Image.new("RGB", (W, TOTAL_H), "#1a1a2e")
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
for row in range(TOTAL_H):
|
|
r = int(15 + (48 - 15) * row / TOTAL_H)
|
|
g = int(12 + (43 - 12) * row / TOTAL_H)
|
|
b = int(41 + (62 - 41) * row / TOTAL_H)
|
|
draw.line([(0, row), (W, row)], fill=(r, g, b))
|
|
|
|
card_top = HEADER_H
|
|
draw.rounded_rectangle(
|
|
[0, card_top, W - 1, TOTAL_H - 1],
|
|
radius=RADIUS,
|
|
fill="#ffffff",
|
|
)
|
|
draw.rectangle([0, card_top, W - 1, card_top + RADIUS], fill="#ffffff")
|
|
# 蓝色渐变 header
|
|
for row in range(HEADER_H):
|
|
ratio = row / HEADER_H
|
|
r = int(61 - (61 - 42) * ratio) # 61 -> 42
|
|
g = int(133 - (133 - 98) * ratio) # 133 -> 98
|
|
b = int(255 - (255 - 239) * ratio) # 255 -> 239
|
|
draw.line([(0, row), (W, row)], fill=(r, g, b))
|
|
|
|
# LC 图标 - 已删除方框
|
|
|
|
date_text = data["date"]
|
|
date_bbox = draw.textbbox((0, 0), date_text, font=font_small)
|
|
date_w = date_bbox[2] - date_bbox[0]
|
|
draw.text(
|
|
(W - PAD - date_w, (HEADER_H - 18) // 2),
|
|
date_text, fill=(255, 255, 255), font=font_small,
|
|
)
|
|
|
|
cy = card_top + PAD
|
|
|
|
diff_styles = {
|
|
"Easy": {"label": "Easy", "bg": (220, 252, 231), "fg": (22, 101, 52)},
|
|
"Medium": {"label": "Medium", "bg": (254, 249, 195), "fg": (133, 77, 14)},
|
|
"Hard": {"label": "Hard", "bg": (254, 202, 202), "fg": (153, 27, 27)},
|
|
}
|
|
ds = diff_styles.get(data["difficulty"], {"label": data["difficulty"], "bg": (229, 231, 235), "fg": (55, 65, 81)})
|
|
|
|
for line in title_lines:
|
|
draw.text((PAD, cy), line, fill="#1a1a2e", font=font_title)
|
|
cy += 34
|
|
cy += 8
|
|
|
|
meta_y = cy
|
|
id_text = f"#{data['question_id']}"
|
|
draw.text((PAD, meta_y + 4), id_text, fill="#9ca3af", font=font_small)
|
|
id_w = draw.textlength(id_text, font=font_small) + 16
|
|
|
|
badge_text = ds["label"]
|
|
badge_bbox = draw.textbbox((0, 0), badge_text, font=font_small)
|
|
badge_w = badge_bbox[2] - badge_bbox[0] + 24
|
|
badge_h = 28
|
|
badge_x = PAD + id_w
|
|
draw.rounded_rectangle(
|
|
[badge_x, meta_y, badge_x + badge_w, meta_y + badge_h],
|
|
radius=14,
|
|
fill=ds["bg"],
|
|
)
|
|
draw.text((badge_x + 12, meta_y + 4), badge_text, fill=ds["fg"], font=font_small)
|
|
|
|
rate_text = f"Accept: {data['ac_rate']:.1f}%"
|
|
rate_x = badge_x + badge_w + 16
|
|
draw.text((rate_x, meta_y + 4), rate_text, fill="#059669", font=font_small)
|
|
|
|
cy = meta_y + badge_h + 20
|
|
|
|
if data["tags"]:
|
|
tag_x = PAD
|
|
tag_y = cy
|
|
for t in data["tags"]:
|
|
tw = draw.textlength(t, font=font_tag) + 24
|
|
if tag_x + tw > W - PAD:
|
|
tag_x = PAD
|
|
tag_y += 32 + 8
|
|
draw.rounded_rectangle(
|
|
[tag_x, tag_y, tag_x + tw, tag_y + 30],
|
|
radius=8,
|
|
fill=(238, 242, 255),
|
|
)
|
|
draw.text((tag_x + 12, tag_y + 6), t, fill=(67, 56, 202), font=font_tag)
|
|
tag_x += tw + 8
|
|
cy = tag_y + 38
|
|
|
|
if desc_text:
|
|
draw.text((PAD, cy), "Description", fill="#64748b", font=font_small)
|
|
cy += 24
|
|
|
|
desc_lines = _wrap_text(desc_text, font_body, W - PAD * 2, draw)
|
|
for line in desc_lines:
|
|
if line.strip() == "":
|
|
cy += 8
|
|
continue
|
|
draw.text((PAD, cy), line, fill="#374151", font=font_body)
|
|
cy += 24
|
|
|
|
Path(img_path).parent.mkdir(parents=True, exist_ok=True)
|
|
img.save(img_path, "PNG")
|
|
|
|
|
|
def render_markdown(data, desc_md=""):
|
|
"""Convert LeetCode content to Markdown format.
|
|
|
|
Args:
|
|
data: LeetCode question data dict
|
|
desc_md: Pre-converted markdown (optional). If not provided, will be generated from data["content"]
|
|
"""
|
|
if not desc_md and data["content"]:
|
|
desc_md = _html_to_markdown(data["content"])
|
|
|
|
diff_map = {
|
|
"Easy": "Easy",
|
|
"Medium": "Medium",
|
|
"Hard": "Hard",
|
|
}
|
|
difficulty = diff_map.get(data["difficulty"], data["difficulty"])
|
|
|
|
tags_md = ""
|
|
if data["tags"]:
|
|
tags_md = "\n".join([f"- `{tag}`" for tag in data["tags"]])
|
|
|
|
md = f"""# {data['title']}
|
|
|
|
## Info
|
|
- **#{data['question_id']}** | {difficulty} | Accept Rate: {data['ac_rate']:.1f}%
|
|
- Date: {data['date']}
|
|
- Tags:
|
|
{tags_md}
|
|
|
|
## Description
|
|
{desc_md}
|
|
|
|
## Examples
|
|
```
|
|
{data['example_cases']}
|
|
```
|
|
|
|
## Link
|
|
[data](https://leetcode.com/problems/{data['title_slug']}/)
|
|
"""
|
|
return md
|
|
|
|
|
|
# === Main ===
|
|
def main():
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python leetcode_card.py <output.png|output.md>", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
output = sys.argv[1]
|
|
ext = Path(output).suffix.lower()
|
|
|
|
data = fetch_daily_challenge()
|
|
desc_md = _html_to_markdown(data["content"]) if data["content"] else ""
|
|
|
|
if ext == ".md":
|
|
md = render_markdown(data, desc_md)
|
|
Path(output).parent.mkdir(parents=True, exist_ok=True)
|
|
Path(output).write_text(md, encoding="utf-8")
|
|
else:
|
|
render_image(data, desc_md, output)
|
|
|
|
print(json.dumps({"desc": desc_md, "output_file": output}))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|