feat: 修改leetcode 实现方式
This commit is contained in:
parent
faba53edfd
commit
52479458d2
|
|
@ -1,49 +1,69 @@
|
|||
---
|
||||
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.
|
||||
description: A Node.js script that fetches the daily LeetCode challenge and renders it as a PNG card, HTML page, 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.
|
||||
A Node.js script that automatically fetches the daily LeetCode challenge and renders it as a PNG card, HTML page, 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
|
||||
- **Browser Rendering**: Uses Puppeteer for high-quality PNG output with CSS styling
|
||||
- **Multiple Output Formats**: `.png` (image), `.html` (styled page), `.md` (markdown)
|
||||
- **Theme Support**: Light and dark themes
|
||||
- **CJK Support**: Noto Sans SC and system fonts for Chinese characters
|
||||
- **Code Highlighting**: Syntax highlighting via highlight.js
|
||||
- **LaTeX Support**: KaTeX rendering for math formulas
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install Pillow html2text
|
||||
cd scripts
|
||||
npm install
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Pillow**: For PNG/JPG image rendering
|
||||
- **html2text**: For converting HTML content to Markdown
|
||||
- **puppeteer-core**: Browser automation (uses local Chrome/Edge)
|
||||
- **marked**: Markdown parsing
|
||||
- **katex**: LaTeX math rendering
|
||||
- **highlight.js**: Code syntax highlighting
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Generate PNG card
|
||||
python scripts/run.py output.png
|
||||
# Generate PNG card (default light theme)
|
||||
node run.js output.png
|
||||
|
||||
# Generate PNG with dark theme
|
||||
node run.js output.png --dark
|
||||
|
||||
# Generate HTML page
|
||||
node run.js output.html
|
||||
|
||||
# Generate Markdown document
|
||||
python scripts/run.py output.md
|
||||
node run.js output.md
|
||||
|
||||
# Custom max width (for long descriptions)
|
||||
node run.js output.png --max-width=800 --dark
|
||||
```
|
||||
|
||||
## 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
|
||||
- Gradient header with date and LeetCode icon
|
||||
- Question title, ID, difficulty badge, acceptance rate
|
||||
- Tags as styled pills
|
||||
- Full problem description with code formatting
|
||||
- Footer with link to problem page
|
||||
|
||||
### HTML Page
|
||||
- Same styling as PNG, exportable as standalone file
|
||||
- Responsive design
|
||||
- Interactive links
|
||||
|
||||
### Markdown Document
|
||||
- Clean Markdown formatting
|
||||
|
|
@ -54,34 +74,37 @@ python scripts/run.py output.md
|
|||
|
||||
## 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
|
||||
- **Windows**: Chrome/Edge from default installation paths
|
||||
- **macOS**: Chrome/Edge from Applications
|
||||
- **Linux**: Chrome/Chromium from system PATH
|
||||
|
||||
## Workflow
|
||||
|
||||
```
|
||||
LeetCode API (HTML)
|
||||
LeetCode GraphQL API
|
||||
↓
|
||||
html2text (Markdown)
|
||||
Fetch Question Data
|
||||
↓
|
||||
Markdown → Plain Text (cleanup)
|
||||
Generate HTML Template
|
||||
↓
|
||||
Pillow (PNG Image)
|
||||
Browser Rendering (PNG) / File Output (HTML/MD)
|
||||
```
|
||||
|
||||
## Example
|
||||
## Browser Configuration
|
||||
|
||||
The script auto-detects installed browsers in this order:
|
||||
1. `CHROME_PATH` environment variable
|
||||
2. Google Chrome
|
||||
3. Microsoft Edge
|
||||
4. Chromium
|
||||
|
||||
```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
|
||||
# Force specific browser
|
||||
CHROME_PATH="C:\Program Files\Google\Chrome\Application\chrome.exe" node run.js output.png
|
||||
```
|
||||
|
||||
## 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
|
||||
- Requires internet access to fetch from LeetCode API
|
||||
- Browser must be installed for PNG/HTML output
|
||||
- Markdown output works without browser
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "leetcode-daily-card",
|
||||
"version": "1.0.0",
|
||||
"description": "LeetCode Daily Challenge Card Renderer",
|
||||
"scripts": {
|
||||
"start": "node run.js",
|
||||
"render": "node run.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"katex": "^0.16.9",
|
||||
"marked": "^12.0.0",
|
||||
"puppeteer-core": "^22.15.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,391 @@
|
|||
/**
|
||||
* LeetCode Daily Card - Pure Node.js Version
|
||||
*
|
||||
* Usage:
|
||||
* node run.js [output.png|output.md|output.html] [--dark|--light] [--width=600]
|
||||
*
|
||||
* Dependencies: puppeteer-core, marked, katex
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const { marked } = require('marked');
|
||||
const katex = require('katex');
|
||||
|
||||
// === CLI Args ===
|
||||
const args = process.argv.slice(2);
|
||||
let outputPath = null;
|
||||
let forcedTheme = null;
|
||||
let maxWidth = 900;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg.startsWith('--max-width=')) maxWidth = parseInt(arg.split('=')[1]);
|
||||
else if (arg === '--dark') forcedTheme = 'dark';
|
||||
else if (arg === '--light') forcedTheme = 'light';
|
||||
else if (!outputPath) outputPath = arg;
|
||||
}
|
||||
|
||||
if (!outputPath) {
|
||||
console.error('Usage: node run.js <output.png|output.md|output.html> [--dark|--light] [--max-width=900]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
outputPath = path.resolve(outputPath);
|
||||
const ext = path.extname(outputPath).toLowerCase();
|
||||
|
||||
// === GraphQL Query ===
|
||||
const GRAPHQL_QUERY = `query questionOfToday {
|
||||
activeDailyCodingChallengeQuestion {
|
||||
date
|
||||
question {
|
||||
questionId
|
||||
title
|
||||
titleSlug
|
||||
difficulty
|
||||
content
|
||||
acRate
|
||||
topicTags { name slug }
|
||||
exampleTestcases
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
// === Fetch Data ===
|
||||
function fetchDailyChallenge() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = JSON.stringify({ query: GRAPHQL_QUERY });
|
||||
const options = {
|
||||
hostname: 'leetcode.com',
|
||||
path: '/graphql/',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
};
|
||||
|
||||
const req = https.request(options, (res) => {
|
||||
let body = '';
|
||||
res.on('data', chunk => body += chunk);
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(body);
|
||||
const challenge = json.data?.activeDailyCodingChallengeQuestion;
|
||||
if (!challenge) return reject(new Error('No daily challenge found'));
|
||||
|
||||
const q = challenge.question;
|
||||
resolve({
|
||||
date: challenge.date,
|
||||
title: q.title,
|
||||
title_slug: q.titleSlug,
|
||||
question_id: q.questionId,
|
||||
difficulty: q.difficulty,
|
||||
content: q.content || '',
|
||||
tags: (q.topicTags || []).map(t => t.name),
|
||||
example_cases: q.exampleTestcases || '',
|
||||
ac_rate: q.acRate || 0
|
||||
});
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
// === HTML to Markdown ===
|
||||
function htmlToMarkdown(html) {
|
||||
// Clean invisible unicode
|
||||
html = html.replace(/[\u200b-\u200f\u2028-\u202f\ufeff\u00ad]/g, '');
|
||||
html = html.replace(/[\xa0\u3000]/g, ' ');
|
||||
html = html.replace(/[\uff00-\uffef]/g, '');
|
||||
|
||||
// Use marked to parse HTML content
|
||||
return marked.parse(html);
|
||||
}
|
||||
|
||||
// === Build HTML Template ===
|
||||
function buildHtml(data, theme = 'light') {
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const diffStyles = {
|
||||
'Easy': { label: 'Easy', bg: '#dcfce7', fg: '#166534' },
|
||||
'Medium': { label: 'Medium', bg: '#fef9c3', fg: '#854d0e' },
|
||||
'Hard': { label: 'Hard', bg: '#fee2e2', fg: '#991b1b' },
|
||||
};
|
||||
const ds = diffStyles[data.difficulty] || { label: data.difficulty, bg: '#e5e7eb', fg: '#374151' };
|
||||
|
||||
// Convert HTML content to markdown then to HTML
|
||||
const descMd = data.content ? htmlToMarkdown(data.content) : '';
|
||||
const descHtml = marked.parse(descMd);
|
||||
|
||||
const tagsHtml = data.tags.length
|
||||
? '<div class="tags">' + data.tags.map(t => `<span class="tag">${t}</span>`).join('') + '</div>'
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/styles/${isDark ? 'github-dark' : 'github'}.min.css">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: ${theme};
|
||||
--bg: ${isDark ? '#1a1a2e' : '#ffffff'};
|
||||
--card-bg: ${isDark ? '#16213e' : '#f8fafc'};
|
||||
--header-bg: ${isDark ? '#0f3460' : '#3b82f6'};
|
||||
--text: ${isDark ? '#e2e8f0' : '#1e293b'};
|
||||
--text-muted: ${isDark ? '#94a3b8' : '#64748b'};
|
||||
--border: ${isDark ? '#334155' : '#e2e8f0'};
|
||||
--tag-bg: ${isDark ? '#1e3a5f' : '#eff6ff'};
|
||||
--tag-text: ${isDark ? '#60a5fa' : '#3b82f6'};
|
||||
--code-bg: ${isDark ? '#1e293b' : '#f1f5f9'};
|
||||
--code-text: ${isDark ? '#e2e8f0' : '#334155'};
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
width: ${maxWidth}px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
padding: 20px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
}
|
||||
.header {
|
||||
background: linear-gradient(135deg, var(--header-bg), ${isDark ? '#1e40af' : '#2563eb'});
|
||||
padding: 24px 28px;
|
||||
}
|
||||
.header-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.date { font-size: 13px; color: rgba(255,255,255,0.85); font-weight: 500; }
|
||||
.lc-icon { font-size: 18px; color: white; }
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.meta { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.question-id { font-size: 13px; color: rgba(255,255,255,0.7); font-family: 'JetBrains Mono', monospace; }
|
||||
.difficulty {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: ${ds.bg};
|
||||
color: ${ds.fg};
|
||||
}
|
||||
.ac-rate { font-size: 12px; color: rgba(255,255,255,0.8); }
|
||||
.body { padding: 24px 28px; }
|
||||
.tags { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 20px; }
|
||||
.tag { padding: 6px 14px; border-radius: 8px; font-size: 12px; font-weight: 500; background: var(--tag-bg); color: var(--tag-text); }
|
||||
.content { padding-top: 16px; }
|
||||
.content h1, .content h2, .content h3 { font-weight: 600; margin: 1em 0 0.5em; }
|
||||
.content p { margin: 0.6em 0; }
|
||||
.content pre { background: var(--code-bg); padding: 16px; border-radius: 8px; overflow-x: auto; font-family: 'JetBrains Mono', monospace; font-size: 13px; margin: 1em 0; }
|
||||
.content code { font-family: 'JetBrains Mono', monospace; font-size: 0.9em; }
|
||||
.content p code, .content li code { padding: 2px 6px; border-radius: 4px; background: var(--code-bg); }
|
||||
.content ul, .content ol { margin: 0.6em 0; padding-left: 1.5em; }
|
||||
.content li { margin: 0.3em 0; }
|
||||
.content table { border-collapse: collapse; margin: 1em 0; width: 100%; }
|
||||
.content th, .content td { padding: 8px 12px; }
|
||||
.content th { background: var(--code-bg); font-weight: 600; }
|
||||
.footer { padding: 16px 28px; text-align: center; }
|
||||
.link { color: var(--tag-text); text-decoration: none; font-size: 13px; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<span class="date">${data.date}</span>
|
||||
<span class="lc-icon">📋</span>
|
||||
</div>
|
||||
<h1 class="title">${data.title}</h1>
|
||||
<div class="meta">
|
||||
<span class="question-id">#${data.question_id}</span>
|
||||
<span class="difficulty">${ds.label}</span>
|
||||
<span class="ac-rate">Accept: ${data.ac_rate.toFixed(1)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="body">
|
||||
${tagsHtml}
|
||||
<div class="content">
|
||||
${descHtml}
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<a class="link" href="https://leetcode.com/problems/${data.title_slug}/">View on LeetCode →</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// === Build Markdown ===
|
||||
function buildMarkdown(data) {
|
||||
const tagsMd = data.tags.length ? data.tags.map(t => `- \`${t}\``).join('\n') : '';
|
||||
const descMd = data.content ? htmlToMarkdown(data.content) : '';
|
||||
|
||||
return `# ${data.title}
|
||||
|
||||
## Info
|
||||
- **#${data.question_id}** | ${data.difficulty} | Accept Rate: ${data.ac_rate.toFixed(1)}%
|
||||
- Date: ${data.date}
|
||||
- Tags:
|
||||
${tagsMd}
|
||||
|
||||
## Description
|
||||
${descMd}
|
||||
|
||||
## Examples
|
||||
\`\`\`
|
||||
${data.example_cases}
|
||||
\`\`\`
|
||||
|
||||
## Link
|
||||
[View on LeetCode](https://leetcode.com/problems/${data.title_slug}/)
|
||||
`;
|
||||
}
|
||||
|
||||
// === Browser Rendering ===
|
||||
function findBrowserPath() {
|
||||
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
|
||||
return process.env.CHROME_PATH;
|
||||
}
|
||||
const candidates = {
|
||||
win32: [
|
||||
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||
],
|
||||
darwin: [
|
||||
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||
],
|
||||
linux: ['/usr/bin/google-chrome', '/usr/bin/chromium-browser', '/usr/bin/chromium'],
|
||||
};
|
||||
for (const p of (candidates[process.platform] || [])) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getBrowser() {
|
||||
let browserModule, usingCore = false;
|
||||
try {
|
||||
browserModule = require('puppeteer-core');
|
||||
usingCore = true;
|
||||
} catch {
|
||||
try {
|
||||
browserModule = require('puppeteer');
|
||||
} catch {
|
||||
console.error('Error: Neither puppeteer-core nor puppeteer is installed.');
|
||||
console.error('Run: cd scripts && npm install puppeteer-core');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const browserPath = findBrowserPath();
|
||||
if (usingCore) {
|
||||
if (!browserPath) {
|
||||
console.error('Error: puppeteer-core requires Chrome/Edge/Chromium.');
|
||||
console.error('Set CHROME_PATH env var or install a browser.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Using: puppeteer-core (${path.basename(browserPath)})`);
|
||||
return browserModule.launch({
|
||||
headless: 'new',
|
||||
executablePath: browserPath,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
}
|
||||
console.log('Using: puppeteer (bundled Chromium)');
|
||||
return browserModule.launch({ headless: 'new', args: ['--no-sandbox'] });
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function renderToPng(html, outputPath) {
|
||||
const browser = await getBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: maxWidth, height: 800, deviceScaleFactor: 2 });
|
||||
|
||||
await page.setContent(html, { waitUntil: 'networkidle0' });
|
||||
await sleep(1500);
|
||||
|
||||
const bodyHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||
|
||||
await page.setViewport({
|
||||
width: maxWidth,
|
||||
height: Math.max(bodyHeight, 200),
|
||||
deviceScaleFactor: 2,
|
||||
});
|
||||
|
||||
await page.screenshot({
|
||||
path: outputPath,
|
||||
fullPage: true,
|
||||
type: 'png',
|
||||
});
|
||||
|
||||
console.log(`Done: ${outputPath}`);
|
||||
const stats = fs.statSync(outputPath);
|
||||
console.log(`Size: ${(stats.size / 1024).toFixed(1)} KB`);
|
||||
console.log(`Dimensions: ${maxWidth} x ${bodyHeight} px (2x scale)`);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
// === Main ===
|
||||
async function main() {
|
||||
console.log('Fetching LeetCode daily challenge...');
|
||||
const data = await fetchDailyChallenge();
|
||||
console.log(`Question: #${data.question_id} - ${data.title} [${data.difficulty}]`);
|
||||
|
||||
const theme = forcedTheme || 'light';
|
||||
console.log(`Theme: ${theme}, Max Width: ${maxWidth}px`);
|
||||
|
||||
if (ext === '.md') {
|
||||
const md = buildMarkdown(data);
|
||||
fs.writeFileSync(outputPath, md, 'utf-8');
|
||||
console.log(`Done: ${outputPath}`);
|
||||
} else if (ext === '.html') {
|
||||
const html = buildHtml(data, theme);
|
||||
fs.writeFileSync(outputPath, html, 'utf-8');
|
||||
console.log(`Done: ${outputPath}`);
|
||||
} else {
|
||||
const html = buildHtml(data, theme);
|
||||
await renderToPng(html, outputPath);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Error:', err.message || err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -1,421 +0,0 @@
|
|||
"""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()
|
||||
Loading…
Reference in New Issue