"""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 ", 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()