392 lines
13 KiB
JavaScript
392 lines
13 KiB
JavaScript
/**
|
|
* 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.staticfile.org/KaTeX/0.16.9/katex.min.css">
|
|
<link rel="stylesheet" href="https://cdn.staticfile.org/highlight.js/11.9.0/styles/${isDark ? 'github-dark' : 'github'}.min.css">
|
|
<link href="https://fonts.googleapis.cn/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);
|
|
});
|