diff --git a/.gitignore b/.gitignore index 3b33452..6722b82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ignore everything by default * +!*/ # Whitelist specific files and directories !.gitignore !SKILL.md diff --git a/image-generation/SKILL.md b/image-generation/SKILL.md new file mode 100644 index 0000000..21565fd --- /dev/null +++ b/image-generation/SKILL.md @@ -0,0 +1,67 @@ +--- +name: image-generation +description: Generate images using MiniMax API with support for custom prompts, aspect ratios, and multiple image generation. +metadata: {"clawdbot":{"emoji":"🎨","os":["linux","darwin","win32"]}} +--- + +# MiniMax Image Generation SKILL + +## Description + +Generate images using MiniMax API. Supports custom prompts, aspect ratios, generation count, and more. + +## Usage + +```bash +python scripts/run.py --api-key "your-api-key" --prompt "your-prompt" [options] +``` + +## Arguments + +| Argument | Type | Required | Default | Description | +|----------|------|----------|---------|-------------| +| `--api-key` | string | Yes | - | MiniMax API key (can also be set via `MINIMAX_API_KEY` env var) | +| `--prompt` | string | Yes | - | Image generation prompt | +| `--model` | string | No | `image-01` | Image generation model | +| `--aspect-ratio` | string | No | `1:1` | Image aspect ratio (e.g., 16:9, 1:1, 9:16) | +| `--response-format` | string | No | `url` | Response format (`url` or `base64`) | +| `--n` | int | No | `1` | Number of images to generate (1-3) | +| `--prompt-optimizer` | bool | No | `false` | Enable prompt optimizer | +| `--subject-reference` | string | No | - | Reference image URL or local file path for image-to-image generation | +| `--subject-type` | string | No | `character` | Subject reference type (character, product, logo, video_subject, other) | +| `--seed` | int | No | - | Random seed for reproducible generation | +| `--output-dir` | string | No | `./output` | Output directory for images | + +## API Reference + +- **Endpoint**: `POST https://api.minimaxi.com/v1/image_generation` +- **Auth**: Bearer Token + +## Examples + +```bash + +python scripts/run.py --api-key "sk-xxx" --prompt "A sunset over the ocean" + +# Generate multiple images +python scripts/run.py --api-key "sk-xxx" --prompt "A man in a white t-shirt, full-body, standing front view, outdoors" --n 3 --aspect-ratio "16:9" + +# Enable prompt optimizer +python scripts/run.py --api-key "sk-xxx" --prompt "sunset ocean" --prompt-optimizer true + +# Image-to-image generation with reference image URL +python scripts/run.py --api-key "sk-xxx" --prompt "A girl looking into the distance from a library window" --subject-reference "https://example.com/reference.jpg" --subject-type "character" --n 2 + +# Image-to-image with local file +python scripts/run.py --api-key "sk-xxx" --prompt "A girl looking into the distance from a library window" --subject-reference "./my_character.jpg" --subject-type "character" + +# Image-to-image with product reference +python scripts/run.py --api-key "sk-xxx" --prompt "A beautiful product shot" --subject-reference "https://example.com/product.jpg" --subject-type "product" + +# Use seed for reproducible generation +python scripts/run.py --api-key "sk-xxx" --prompt "A sunset over the ocean" --seed 42 +``` + +## Output + +Images are saved to the specified output directory with the naming format: `generated_image_{index}_{timestamp}.{ext}`. \ No newline at end of file diff --git a/image-generation/scripts/run.py b/image-generation/scripts/run.py new file mode 100644 index 0000000..418e54e --- /dev/null +++ b/image-generation/scripts/run.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +# @skill: image-generation + +""" +MiniMax Image Generation Script +Generate images using MiniMax API +""" + +import argparse +import os +import time +import requests +from urllib.parse import urlparse + + +def parse_args(): + """Parse command line arguments""" + parser = argparse.ArgumentParser( + description="Generate images using MiniMax API", + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + parser.add_argument( + "--api-key", + type=str, + required=False, + help="MiniMax API key (can also be set via MINIMAX_API_KEY environment variable)" + ) + + parser.add_argument( + "--prompt", + type=str, + required=True, + help="Image generation prompt" + ) + + parser.add_argument( + "--model", + type=str, + default="image-01", + help="Image generation model (default: image-01)" + ) + + parser.add_argument( + "--aspect-ratio", + type=str, + default="1:1", + help="Image aspect ratio (default: 1:1, options: 16:9, 9:16, etc.)" + ) + + parser.add_argument( + "--response-format", + type=str, + default="url", + choices=["url", "base64"], + help="Response format (default: url)" + ) + + parser.add_argument( + "--n", + type=int, + default=1, + choices=[1, 2, 3], + help="Number of images to generate (default: 1, max: 3)" + ) + + parser.add_argument( + "--prompt-optimizer", + type=lambda x: x.lower() == "true", + default=False, + help="Enable prompt optimizer (default: false)" + ) + + # Image-to-image (subject reference) parameters + parser.add_argument( + "--subject-reference", + type=str, + default=None, + help="Reference image URL or local file path for image-to-image generation" + ) + + parser.add_argument( + "--subject-type", + type=str, + default="character", + choices=["character", "product", "logo", "video_subject", "other"], + help="Subject reference type (default: character)" + ) + + parser.add_argument( + "--seed", + type=int, + default=None, + help="Random seed for reproducible generation (optional)" + ) + + parser.add_argument( + "--output-dir", + type=str, + default="./output", + help="Output directory for images (default: ./output)" + ) + + parser.add_argument( + "--api-base", + type=str, + default="https://api.minimaxi.com", + help="API base URL (default: https://api.minimaxi.com)" + ) + + return parser.parse_args() + + +def download_image(url: str, output_path: str) -> bool: + """Download image to local file""" + try: + response = requests.get(url, timeout=30) + response.raise_for_status() + + with open(output_path, "wb") as f: + f.write(response.content) + + print(f" [OK] Saved: {output_path}") + return True + except Exception as e: + print(f" [FAIL] Download failed: {e}") + return False + + +def read_local_image(file_path: str) -> str: + """ + Read local image file and return as base64 encoded string. + Returns the base64 string or None if failed. + """ + try: + with open(file_path, "rb") as f: + image_data = f.read() + import base64 + return base64.b64encode(image_data).decode("utf-8") + except Exception as e: + print(f" [FAIL] Failed to read local image: {e}") + return None + + +def build_subject_reference(subject_ref: str, subject_type: str) -> dict: + """ + Build subject_reference object from URL or local file path. + + Args: + subject_ref: URL or local file path + subject_type: Type of subject (character, product, logo, etc.) + + Returns: + dict with subject_reference structure + """ + # Check if it's a URL or local file + if subject_ref.startswith(("http://", "https://")): + # It's a URL + return { + "type": subject_type, + "image_file": subject_ref + } + else: + # It's a local file - convert to base64 + print(f" Processing local image: {subject_ref}") + base64_data = read_local_image(subject_ref) + if base64_data: + return { + "type": subject_type, + "image_file": f"data:image/jpeg;base64,{base64_data}" + } + else: + return None + + +def generate_images(args): + """Call MiniMax API to generate images""" + url = f"{args.api_base}/v1/image_generation" + + headers = { + "Authorization": f"Bearer {args.api_key}", + "Content-Type": "application/json" + } + + payload = { + "model": args.model, + "prompt": args.prompt, + "aspect_ratio": args.aspect_ratio, + "response_format": args.response_format, + "n": args.n, + "prompt_optimizer": args.prompt_optimizer + } + + # Add seed if provided + if args.seed is not None: + payload["seed"] = args.seed + + # Add subject_reference for image-to-image generation + if args.subject_reference: + subject_ref = build_subject_reference(args.subject_reference, args.subject_type) + if subject_ref: + payload["subject_reference"] = [subject_ref] + else: + print("Warning: Failed to process subject reference, continuing without it.") + + print(f"\n{'='*60}") + print(f"MiniMax Image Generation") + print(f"{'='*60}") + print(f"Model: {args.model}") + print(f"Prompt: {args.prompt}") + print(f"Aspect Ratio: {args.aspect_ratio}") + print(f"Number: {args.n}") + print(f"Prompt Optimizer: {'Enabled' if args.prompt_optimizer else 'Disabled'}") + if args.seed is not None: + print(f"Seed: {args.seed}") + if args.subject_reference: + print(f"Subject Reference: {args.subject_type} - {args.subject_reference}") + print(f"{'='*60}\n") + + try: + print("Generating images...") + response = requests.post(url, headers=headers, json=payload, timeout=60) + response.raise_for_status() + + result = response.json() + + if result.get("base_resp", {}).get("status_code") != 0: + error_msg = result.get("base_resp", {}).get("status_msg", "Unknown error") + print(f"API Error: {error_msg}") + return False + + # Create output directory + os.makedirs(args.output_dir, exist_ok=True) + + # Process returned images + image_urls = result.get("data", {}).get("image_urls", []) + metadata = result.get("metadata", {}) + + print(f"\nSuccessfully generated {metadata.get('success_count', len(image_urls))} images:") + + timestamp = int(time.time()) + saved_count = 0 + + for i, image_url in enumerate(image_urls, 1): + # Extract file extension from URL + parsed = urlparse(image_url) + path = parsed.path + ext = os.path.splitext(path)[1] if "." in path else ".jpeg" + + filename = f"generated_image_{i}_{timestamp}{ext}" + output_path = os.path.join(args.output_dir, filename) + + if download_image(image_url, output_path): + saved_count += 1 + + print(f"\n{'='*60}") + print(f"Done! Successfully saved {saved_count}/{len(image_urls)} images") + print(f"Output directory: {os.path.abspath(args.output_dir)}") + print(f"{'='*60}\n") + + return saved_count > 0 + + except requests.exceptions.RequestException as e: + print(f"\nRequest Error: {e}") + return False + except Exception as e: + print(f"\nUnexpected Error: {e}") + return False + + +def main(): + """Main function""" + args = parse_args() + + # Get API key from argument or environment variable (optional) + if not args.api_key: + args.api_key = os.environ.get("MINIMAX_API_KEY", "") + + # If still no API key, prompt user to enter it + if not args.api_key: + print("Error: API key not int ENV, and it is required.") + else: + generate_images(args) + + +if __name__ == "__main__": + main() diff --git a/leetcode-daily-card/SKILL.md b/leetcode-daily-card/SKILL.md new file mode 100644 index 0000000..ef86faa --- /dev/null +++ b/leetcode-daily-card/SKILL.md @@ -0,0 +1,87 @@ +--- +name: leetcode-daily-card +description: A Python script that automatically fetches the daily LeetCode challenge and renders it as a PNG card or Markdown document. +metadata: {"clawdbot":{"emoji":"📋","os":["linux","darwin","win32"]}} +--- + +# LeetCode Daily Card Generator + +A Python script that automatically fetches the daily LeetCode challenge and renders it as a PNG card or Markdown document. + +## Features + +- **Automatic Daily Fetch**: Retrieves the current daily LeetCode problem via GraphQL API +- **Two-Step Rendering**: HTML → Markdown → PNG for optimal text rendering +- **CJK Support**: Uses system CJK fonts (Microsoft YaHei, SimHei, etc.) for Chinese characters +- **Multiple Output Formats**: Supports `.png` for image cards and `.md` for Markdown documents +- **Clean Unicode Handling**: Removes zero-width spaces and other invisible Unicode characters + +## Installation + +```bash +pip install Pillow html2text +``` + +## Dependencies + +- **Pillow**: For PNG/JPG image rendering +- **html2text**: For converting HTML content to Markdown + +## Usage + +```bash +# Generate PNG card +python scripts/run.py output.png + +# Generate Markdown document +python scripts/run.py output.md +``` + +## Output Formats + +### PNG Card +- Blue gradient header +- White card body with rounded corners +- Dark gradient background +- Displays: title, question ID, difficulty, acceptance rate, tags, description + +### Markdown Document +- Clean Markdown formatting +- Question metadata in Info section +- Full problem description +- Example test cases +- Link to problem page + +## Supported Platforms + +- **Windows**: Uses `msyh.ttc` (Microsoft YaHei), `simhei.ttf`, etc. +- **macOS**: Uses PingFang.ttc, STHeiti Light.ttc +- **Linux**: Uses NotoSansCJK, DejaVuSans.ttf, wqy-microhei.ttc + +## Workflow + +``` +LeetCode API (HTML) + ↓ +html2text (Markdown) + ↓ +Markdown → Plain Text (cleanup) + ↓ +Pillow (PNG Image) +``` + +## Example + +```bash +python scripts/run.py daily.png +# Output: daily.png with the daily LeetCode challenge card + +python scripts/run.py daily.md +# Output: daily.md with formatted Markdown content +``` + +## Notes + +- The script requires internet access to fetch from LeetCode API +- Formula rendering is not supported (plain text only) +- Chinese fonts must be installed on the system for CJK character display \ No newline at end of file diff --git a/leetcode-daily-card/scripts/run.py b/leetcode-daily-card/scripts/run.py new file mode 100644 index 0000000..2ecfb9e --- /dev/null +++ b/leetcode-daily-card/scripts/run.py @@ -0,0 +1,421 @@ +"""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() diff --git a/markdown-converter/SKILL.md b/markdown-converter/SKILL.md new file mode 100644 index 0000000..f178488 --- /dev/null +++ b/markdown-converter/SKILL.md @@ -0,0 +1,84 @@ +--- +name: markdown-converter +description: A versatile Markdown conversion tool that supports converting Markdown to HTML, PNG images, and plain text formats. +metadata: {"clawdbot":{"emoji":"📝","os":["linux","darwin","win32"]}} +--- + +# Markdown Converter + +A versatile Markdown conversion tool that supports converting Markdown to HTML, PNG images, and plain text formats. + +## Features + +- **HTML Conversion**: High-quality Markdown to HTML conversion +- **PNG Output**: Render Markdown as PNG images +- **CJK Support**: Uses system CJK fonts for Chinese character rendering +- **Code Highlighting**: Syntax highlighting for code blocks +- **Clean Output**: Removes invisible Unicode characters + +## Installation + +```bash +pip install matplotlib html2text pygments markdown +``` + +## Dependencies + +- **matplotlib**: PNG image rendering with excellent CJK support +- **html2text**: HTML and Markdown conversion +- **Pygments**: Code syntax highlighting +- **markdown**: Python Markdown processor + +## Usage + +```bash +# Convert to HTML +python scripts/md_convert.py input.md output.html + +# Convert to PNG +python scripts/md_convert.py input.md output.png + +# Convert to plain text +python scripts/md_convert.py input.md output.txt +``` + +## Output Formats + +### HTML +- Complete HTML document structure +- Inline CSS styling +- Code syntax highlighting +- Responsive design + +### PNG Card +- White card background +- Large title font +- Automatic text wrapping +- CJK character support + +### Plain Text +- Plain text output +- Preserves basic formatting +- Removes invisible characters + +## Supported Platforms + +- **Windows**: Uses system CJK fonts +- **macOS**: Uses PingFang and other system fonts +- **Linux**: Uses NotoSansCJK and other fonts + +## Workflow + +``` +Markdown Input + ↓ +[html2text / markdown library] + ↓ +HTML / PNG / Plain Text +``` + +## Notes + +- PNG rendering requires Chinese fonts to be installed on the system +- Code highlighting requires Pygments support +- Large files may require longer processing time \ No newline at end of file diff --git a/markdown-converter/scripts/md_convert.py b/markdown-converter/scripts/md_convert.py new file mode 100644 index 0000000..b42bfe7 --- /dev/null +++ b/markdown-converter/scripts/md_convert.py @@ -0,0 +1,610 @@ +"""Markdown 转换器 - 支持 HTML / PNG / Plain Text 输出 + +Dependencies: + - HTML/PNG 输出: pip install Pillow html2text pygments markdown +""" + +import html +import re +import sys +import os +from pathlib import Path + + +# ============================================================ +# 字体查找 +# ============================================================ + +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 _clean_invisible_chars(text): + """清理不可见的 Unicode 字符""" + if not text: + return "" + text = re.sub(r'[\u200b-\u200f\u2028-\u202f\ufeff\u00ad]', '', text) + text = re.sub(r'[\xa0\u3000]', ' ', text) + text = re.sub(r'[\uff00-\uffef]', '', text) + return text + + +def _decode_html_entities(text): + """解码 HTML 实体""" + entities = { + '<': '<', + '>': '>', + '&': '&', + '"': '"', + ''': "'", + ''': "'", + } + for k, v in entities.items(): + text = text.replace(k, v) + # 处理特殊引号 + text = text.replace('"', '"').replace('"', '"') + text = text.replace(''', "'").replace(''', "'") + return text + + +# ============================================================ +# Markdown 解析 +# ============================================================ + +def parse_markdown(md_text): + """解析 Markdown 文本,提取标题、代码块、段落等元素""" + if not md_text: + return [] + + md_text = _clean_invisible_chars(md_text) + lines = md_text.split('\n') + elements = [] + current_paragraph = [] + + def flush_paragraph(): + nonlocal current_paragraph + if current_paragraph: + text = ' '.join(current_paragraph) + if text.strip(): + elements.append(('paragraph', text.strip())) + current_paragraph = [] + + for line in lines: + stripped = line.strip() + + # 跳过空行 + if not stripped: + flush_paragraph() + continue + + # 标题 + header_match = re.match(r'^(#{1,6})\s+(.+)$', stripped) + if header_match: + flush_paragraph() + level = len(header_match.group(1)) + elements.append(('header', level, header_match.group(2).strip())) + continue + + # 代码块 + if stripped.startswith('```'): + flush_paragraph() + elements.append(('codeblock', stripped[3:].strip())) + continue + + # 无序列表 + list_match = re.match(r'^[\-\*+]\s+(.+)$', stripped) + if list_match: + flush_paragraph() + elements.append(('list_item', list_match.group(1).strip())) + continue + + # 有序列表 + ordered_match = re.match(r'^\d+\.\s+(.+)$', stripped) + if ordered_match: + flush_paragraph() + elements.append(('ordered_item', ordered_match.group(1).strip())) + continue + + # 引用 + if stripped.startswith('>'): + flush_paragraph() + content = stripped[1:].strip() + elements.append(('quote', content)) + continue + + # 水平线 + if re.match(r'^[\-\*_]{3,}$', stripped): + flush_paragraph() + elements.append(('hr',)) + continue + + # 链接或图片 + link_match = re.match(r'!?\[([^\]]+)\]\([^\)]+\)', stripped) + if link_match: + flush_paragraph() + elements.append(('link', link_match.group(1))) + continue + + # 默认作为段落处理 + current_paragraph.append(stripped) + + flush_paragraph() + return elements + + +# ============================================================ +# 文本换行 +# ============================================================ + +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 + + +# ============================================================ +# HTML 转换 +# ============================================================ + +def markdown_to_html(md_text): + """将 Markdown 转换为 HTML""" + if not md_text: + return "" + + md_text = _clean_invisible_chars(md_text) + elements = parse_markdown(md_text) + + try: + import html + except ImportError: + import urllib.parse as html + + try: + import markdown + from markdown.extensions import codehilite + + md = markdown.Markdown(extensions=['codehilite', 'fenced_code', 'tables']) + html_content = md.convert(md_text) + return _html_template(html_content) + except ImportError: + # 降级处理:使用简单的转换 + return _simple_markdown_to_html(md_text) + + +def _simple_markdown_to_html(md_text): + """简单的 Markdown 到 HTML 转换(无外部依赖)""" + lines = md_text.split('\n') + html_lines = [] + + in_codeblock = False + + for line in lines: + stripped = line.strip() + + # 代码块开始/结束 + if stripped.startswith('```'): + if in_codeblock: + html_lines.append('') + in_codeblock = False + else: + lang = stripped[3:].strip() or '' + lang_attr = f' class="language-{lang}"' if lang else '' + html_lines.append(f'
')
+                in_codeblock = True
+            continue
+
+        if in_codeblock:
+            html_lines.append(html.escape(line))
+            continue
+
+        # 标题
+        header_match = re.match(r'^(#{1,6})\s+(.+)$', stripped)
+        if header_match:
+            level = len(header_match.group(1))
+            content = header_match.group(2)
+            html_lines.append(f'{_decode_html_entities(content)}')
+            continue
+
+        # 水平线
+        if re.match(r'^[\-\*_]{3,}$', stripped):
+            html_lines.append('
') + continue + + # 引用 + if stripped.startswith('>'): + content = stripped[1:].strip() + html_lines.append(f'
{_decode_html_entities(content)}
') + continue + + # 列表项 + list_match = re.match(r'^[\-\*+]\s+(.+)$', stripped) + if list_match: + content = list_match.group(1) + html_lines.append(f'
  • {_decode_html_entities(content)}
  • ') + continue + + # 段落 + if stripped: + # 处理粗体和斜体 + text = _decode_html_entities(stripped) + 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) + html_lines.append(f'

    {text}

    ') + + return _html_template('\n'.join(html_lines)) + + +def _html_template(content): + """生成完整的 HTML 文档""" + return f""" + + + + + Markdown Document + + + +{content} + +""" + + +# ============================================================ +# PNG 渲染 (使用 matplotlib) +# ============================================================ + +def _get_matplotlib_font(): + """获取支持中文的 matplotlib 字体(通过字体文件路径)""" + import matplotlib + import matplotlib.font_manager as fm + import os + import sys + + # Linux 中文字体路径 + linux_font_paths = [ + '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', + '/usr/share/fonts/opentype/noto/NotoSansSC-Regular.otf', + '/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc', + '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', + '/usr/share/fonts/truetype/wqy/wqy-microhei.ttc', + '/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf', + '/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc', + '/usr/share/fonts/truetype/arphic/uming.ttc', + '/usr/share/fonts/truetype/arphic/ukai.ttc', + ] + + # Windows 中文字体路径 + if sys.platform == "win32": + windir = os.environ.get("WINDIR", r"C:\Windows") + windows_font_paths = [ + os.path.join(windir, "Fonts", "msyh.ttc"), + os.path.join(windir, "Fonts", "msyhbd.ttc"), + os.path.join(windir, "Fonts", "simhei.ttf"), + os.path.join(windir, "Fonts", "simsun.ttc"), + os.path.join(windir, "Fonts", "STHeiti Light.ttc"), + ] + linux_font_paths.extend(windows_font_paths) + + # macOS 中文字体路径 + elif sys.platform == "darwin": + mac_font_paths = [ + '/System/Library/Fonts/PingFang.ttc', + '/System/Library/Fonts/STHeiti Light.ttc', + '/Library/Fonts/Arial Unicode.ttf', + '/System/Library/Fonts/Supplemental/Arial Unicode.ttf', + ] + linux_font_paths.extend(mac_font_paths) + + # 查找存在的字体文件 + for font_path in linux_font_paths: + if os.path.exists(font_path): + # 清除字体缓存并加载指定字体 + fm.fontManager.addfont(font_path) + font = fm.FontProperties(fname=font_path) + # 验证字体可以显示中文 + return font + + # 如果没找到,返回 None 让 matplotlib 使用默认 + return None + + +def markdown_to_png(md_text, img_path): + """将 Markdown 渲染为 PNG 图片(使用 matplotlib)""" + try: + import matplotlib.pyplot as plt + import matplotlib.patches as patches + import matplotlib.font_manager as fm + import matplotlib + except ImportError: + raise ImportError("matplotlib not installed. Run: pip install matplotlib") + + # 设置非交互式后端 + matplotlib.use('Agg') + + # 获取中文字体 + font = _get_matplotlib_font() + + elements = parse_markdown(md_text) + + W, PAD = 10, 0.5 # 英寸, 边距 + FIG_H = 2.0 # 初始高度 + LINE_H = 0.35 # 每行高度 + CODE_H = 0.5 # 代码块初始高度 + + # 计算所需高度 + y = 2.5 # 顶部空间 + for elem in elements: + if elem[0] == 'header': + level = elem[1] + text = elem[2] + chars_per_line = 50 if level <= 2 else 60 + lines = max(1, len(text) // chars_per_line + 1) + y += lines * (0.5 if level <= 2 else 0.4) + 0.2 + elif elem[0] == 'paragraph': + chars_per_line = 60 + lines = max(1, len(elem[1]) // chars_per_line + 1) + y += lines * 0.35 + 0.3 + elif elem[0] == 'codeblock': + lines = elem[1].count('\n') + 2 + y += lines * 0.3 + 0.2 + elif elem[0] in ('list_item', 'quote'): + chars_per_line = 55 + lines = max(1, len(elem[1]) // chars_per_line + 1) + y += lines * 0.32 + 0.15 + + FIG_H = max(8, y) + + fig, ax = plt.subplots(figsize=(W, FIG_H)) + fig.patch.set_facecolor('#ffffff') + ax.set_facecolor('#ffffff') + + # 标题栏背景 + header = patches.Rectangle((0, FIG_H - 1.2), W, 1.0, linewidth=0, facecolor='#2d3748') + ax.add_patch(header) + + # 标题 + ax.text(0.5, FIG_H - 0.6, 'Markdown Document', + fontsize=18, fontweight='bold', color='white', + fontproperties=font, ha='left', va='center') + + ax.text(0.5, FIG_H - 1.0, 'Converted from Markdown', + fontsize=10, color='#888888', fontproperties=font, ha='left', va='center') + + ax.set_xlim(0, W) + ax.set_ylim(0, FIG_H) + ax.axis('off') + + cy = FIG_H - 1.5 + + for elem in elements: + if elem[0] == 'header': + level = elem[1] + text = elem[2] + size = 16 if level <= 2 else 14 + weight = 'bold' if level == 1 else 'normal' + color = '#1a1a2e' if level == 1 else '#2d3748' + ax.text(PAD, cy, text, fontsize=size, fontweight=weight, + color=color, fontproperties=font, ha='left', va='top') + cy -= size * 0.04 + 0.15 + + elif elem[0] == 'paragraph': + ax.text(PAD, cy, elem[1], fontsize=11, color='#374151', + fontproperties=font, ha='left', va='top', wrap=True) + lines = max(1, len(elem[1]) // 60 + 1) + cy -= lines * 0.35 + 0.25 + + elif elem[0] == 'codeblock': + code_h = max(0.5, (elem[1].count('\n') + 2) * 0.3) + code_box = patches.Rectangle((PAD, cy - code_h), W - PAD * 2, code_h, + linewidth=1, edgecolor='#e0e0e0', facecolor='#f4f4f4') + ax.add_patch(code_box) + ax.text(PAD + 0.1, cy - 0.15, elem[1][:500], fontsize=9, + color='#333333', fontfamily='monospace', va='top') + cy -= code_h + 0.2 + + elif elem[0] == 'list_item': + ax.text(PAD, cy, f'\u2022 {elem[1]}', fontsize=11, color='#374151', + fontproperties=font, ha='left', va='top') + cy -= 0.35 + + elif elem[0] == 'quote': + ax.plot([PAD, PAD, PAD + 0.05, PAD + 0.05], + [cy, cy - 0.4, cy - 0.4, cy - 0.6], + color='#0066cc', linewidth=2) + ax.text(PAD + 0.15, cy - 0.1, elem[1], fontsize=11, color='#666666', + fontproperties=font, ha='left', va='top') + cy -= 0.5 + + elif elem[0] == 'hr': + ax.axhline(y=cy, color='#e0e0e0', linewidth=1, xmin=0.05, xmax=0.95) + cy -= 0.3 + + plt.tight_layout(pad=0) + + Path(img_path).parent.mkdir(parents=True, exist_ok=True) + plt.savefig(img_path, format='png', dpi=150, bbox_inches='tight', + facecolor='#ffffff', edgecolor='none') + plt.close() + + +# ============================================================ +# 纯文本转换 +# ============================================================ + +def markdown_to_text(md_text): + """将 Markdown 转换为纯文本""" + if not md_text: + return "" + + md_text = _clean_invisible_chars(md_text) + elements = parse_markdown(md_text) + + lines = [] + + for elem in elements: + if elem[0] == 'header': + level = elem[1] + prefix = "#" * level + " " + lines.append(f"{prefix}{elem[2]}") + lines.append("") + + elif elem[0] == 'paragraph': + lines.append(elem[1]) + lines.append("") + + elif elem[0] == 'codeblock': + lines.append("```") + lines.append(elem[1]) + lines.append("```") + lines.append("") + + elif elem[0] == 'list_item': + lines.append(f"• {elem[1]}") + + elif elem[0] == 'ordered_item': + lines.append(f" {elem[1]}") + + elif elem[0] == 'quote': + lines.append(f"> {elem[1]}") + + elif elem[0] == 'hr': + lines.append("─" * 50) + lines.append("") + + return '\n'.join(lines) + + +# ============================================================ +# 主函数 +# ============================================================ + +def main(): + if len(sys.argv) < 3: + print("Usage: python md_convert.py ", file=sys.stderr) + sys.exit(1) + + input_path = sys.argv[1] + output_path = sys.argv[2] + + if not os.path.exists(input_path): + print(f"Error: Input file not found: {input_path}", file=sys.stderr) + sys.exit(1) + + md_text = Path(input_path).read_text(encoding="utf-8") + ext = Path(output_path).suffix.lower() + + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + + if ext == ".html": + html_content = markdown_to_html(md_text) + Path(output_path).write_text(html_content, encoding="utf-8") + elif ext == ".png": + markdown_to_png(md_text, output_path) + elif ext == ".txt": + text_content = markdown_to_text(md_text) + Path(output_path).write_text(text_content, encoding="utf-8") + else: + print(f"Error: Unsupported output format: {ext}", file=sys.stderr) + print("Supported formats: .html, .png, .txt", file=sys.stderr) + sys.exit(1) + + print(f"Converted: {input_path} -> {output_path}") + + +if __name__ == "__main__": + main() \ No newline at end of file