feat: 添加RSS订阅功能及页脚支持
- 新增RSS订阅功能,支持获取最近7天的日报内容 - 添加页脚插入功能,包含播客平台链接和图片 - 实现GitHub文件内容获取接口 - 优化日期处理工具函数,增加RSS日期格式支持 - 使用marked.js替换原有markdown解析器 - 在提交到GitHub时同时存储报告数据到KV
This commit is contained in:
@@ -64,8 +64,11 @@
|
||||
|
||||
**内容成果展示:**
|
||||
|
||||
* 🎙️ **小宇宙**:[来生小酒馆](https://www.xiaoyuzhoufm.com/podcast/683c62b7c1ca9cf575a5030e)
|
||||
* 📹 **抖音**:[来生情报站](https://www.douyin.com/user/MS4wLjABAAAAwpwqPQlu38sO38VyWgw9ZjDEnN4bMR5j8x111UxpseHR9DpB6-CveI5KRXOWuFwG)
|
||||
| 🎙️ **小宇宙** | 📹 **抖音** |
|
||||
| --- | --- |
|
||||
| [来生小酒馆](https://www.xiaoyuzhoufm.com/podcast/683c62b7c1ca9cf575a5030e) | [来生情报站](https://www.douyin.com/user/MS4wLjABAAAAwpwqPQlu38sO38VyWgw9ZjDEnN4bMR5j8x111UxpseHR9DpB6-CveI5KRXOWuFwG)|
|
||||
|  |  |
|
||||
|
||||
|
||||
**项目截图:**
|
||||
|
||||
|
||||
15
src/foot.js
Normal file
15
src/foot.js
Normal file
@@ -0,0 +1,15 @@
|
||||
export function insertFoot() {
|
||||
return `
|
||||
|
||||
---
|
||||
|
||||
**收听语音版**
|
||||
|
||||
| 🎙️ **小宇宙** | 📹 **抖音** |
|
||||
| --- | --- |
|
||||
| [来生小酒馆](https://www.xiaoyuzhoufm.com/podcast/683c62b7c1ca9cf575a5030e) | [来生情报站](https://www.douyin.com/user/MS4wLjABAAAAwpwqPQlu38sO38VyWgw9ZjDEnN4bMR5j8x111UxpseHR9DpB6-CveI5KRXOWuFwG)|
|
||||
|  |  |
|
||||
|
||||
|
||||
`;
|
||||
}
|
||||
@@ -87,4 +87,35 @@ export async function createOrUpdateGitHubFile(env, filePath, content, commitMes
|
||||
payload.sha = existingSha;
|
||||
}
|
||||
return callGitHubApi(env, `/contents/${filePath}`, 'PUT', payload);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content of a file from GitHub.
|
||||
*/
|
||||
export async function getDailyReportContent(env, filePath) {
|
||||
const GITHUB_BRANCH = env.GITHUB_BRANCH || 'main';
|
||||
const GITHUB_REPO_OWNER = env.GITHUB_REPO_OWNER;
|
||||
const GITHUB_REPO_NAME = env.GITHUB_REPO_NAME;
|
||||
|
||||
if (!GITHUB_REPO_OWNER || !GITHUB_REPO_NAME) {
|
||||
console.error("GitHub environment variables (GITHUB_REPO_OWNER, GITHUB_REPO_NAME) are not configured.");
|
||||
throw new Error("GitHub API configuration is missing in environment variables.");
|
||||
}
|
||||
|
||||
const rawUrl = `https://raw.githubusercontent.com/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}/${GITHUB_BRANCH}/${filePath}`;
|
||||
console.log(rawUrl)
|
||||
try {
|
||||
const response = await fetch(rawUrl);
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
console.log(`File not found: ${filePath} on branch ${GITHUB_BRANCH}`);
|
||||
return null;
|
||||
}
|
||||
throw new Error(`Failed to fetch file from GitHub: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return await response.text();
|
||||
} catch (error) {
|
||||
console.error(`Error fetching daily report content from ${rawUrl}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// src/handlers/commitToGitHub.js
|
||||
import { getISODate, formatMarkdownText } from '../helpers.js';
|
||||
import { getISODate, formatMarkdownText, replaceImageProxy,formatDateToChineseWithTime } from '../helpers.js';
|
||||
import { getGitHubFileSha, createOrUpdateGitHubFile } from '../github.js';
|
||||
import { storeInKV } from '../kv.js';
|
||||
import { marked } from '../marked.esm.js';
|
||||
|
||||
export async function handleCommitToGitHub(request, env) {
|
||||
if (request.method !== 'POST') {
|
||||
return new Response(JSON.stringify({ status: 'error', message: 'Method Not Allowed' }), { status: 405, headers: { 'Content-Type': 'application/json' } });
|
||||
@@ -10,11 +13,21 @@ export async function handleCommitToGitHub(request, env) {
|
||||
const dateStr = formData.get('date') || getISODate();
|
||||
const dailyMd = formData.get('daily_summary_markdown');
|
||||
const podcastMd = formData.get('podcast_script_markdown');
|
||||
const report = {
|
||||
report_date: dateStr,
|
||||
title: dateStr+'日刊',
|
||||
link: '/daily/'+dateStr+'.html',
|
||||
content_html: null,
|
||||
// 可以添加其他相關欄位,例如作者、來源等
|
||||
published_date: formatDateToChineseWithTime(new Date()) // 記錄保存時間
|
||||
}
|
||||
|
||||
const filesToCommit = [];
|
||||
|
||||
if (dailyMd) {
|
||||
filesToCommit.push({ path: `daily/${dateStr}.md`, content: formatMarkdownText(dailyMd), description: "Daily Summary File" });
|
||||
report.content_html = marked.parse(formatMarkdownText(replaceImageProxy(env.IMG_PROXY, dailyMd)));
|
||||
storeInKV(env.DATA_KV, `${dateStr}-report`, report);
|
||||
}
|
||||
if (podcastMd) {
|
||||
filesToCommit.push({ path: `podcast/${dateStr}.md`, content: podcastMd, description: "Podcast Script File" });
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getSystemPromptSummarizationStepOne } from '../prompt/summarizationProm
|
||||
import { getSystemPromptSummarizationStepTwo } from '../prompt/summarizationPromptStepTwo.js';
|
||||
import { getSystemPromptPodcastFormatting } from '../prompt/podcastFormattingPrompt.js';
|
||||
import { getSystemPromptDailyAnalysis } from '../prompt/dailyAnalysisPrompt.js'; // Import new prompt
|
||||
import { insertFoot } from '../foot.js';
|
||||
|
||||
export async function handleGenAIPodcastScript(request, env) {
|
||||
let dateStr;
|
||||
@@ -228,6 +229,7 @@ export async function handleGenAIContent(request, env) {
|
||||
if (fullPromptForCall2_User) promptsMarkdownContent += `### User Input (Output of Call 1)\n\`\`\`\n${fullPromptForCall2_User}\n\`\`\`\n\n`;
|
||||
|
||||
let dailySummaryMarkdownContent = `# ${env.DAILY_TITLE} ${formatDateToChinese(dateStr)}\n\n${removeMarkdownCodeBlock(outputOfCall2)}`;
|
||||
if (env.INSERT_FOOT) dailySummaryMarkdownContent += insertFoot() +`\n\n`;
|
||||
|
||||
const successHtml = generateGenAiPageHtml(
|
||||
env,
|
||||
|
||||
99
src/handlers/getRss.js
Normal file
99
src/handlers/getRss.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { stripHtml, getShanghaiTime, formatRssDate } from '../helpers.js';
|
||||
import { getFromKV } from '../kv.js';
|
||||
|
||||
function minifyHTML(htmlString) {
|
||||
if (typeof htmlString !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return htmlString
|
||||
.replace(/>\s+</g, '><') // 移除标签之间的空白
|
||||
.trim(); // 移除字符串两端的空白
|
||||
}
|
||||
|
||||
/**
|
||||
* 處理 Supabase RSS 請求
|
||||
* @param {Request} request - 傳入的請求物件
|
||||
* @param {object} env - Cloudflare Workers 環境變數
|
||||
* @returns {Response} RSS Feed 的回應
|
||||
*/
|
||||
export async function handleRss(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const days = parseInt(url.searchParams.get('days')) || 7; // 預設查詢 7 天內的資料
|
||||
|
||||
const allData = [];
|
||||
const today = getShanghaiTime(); // 加上東八時區的偏移量
|
||||
console.log(today);
|
||||
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = new Date(today);
|
||||
date.setDate(today.getDate() - i);
|
||||
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const key = `${dateStr}-report`;
|
||||
const data = await getFromKV(env.DATA_KV, key);
|
||||
if (data) {
|
||||
allData.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
// 扁平化數據,因為每個 report 可能包含多個項目
|
||||
const data = allData.flat();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return new Response('沒有找到相關資料', { status: 200 });
|
||||
}
|
||||
|
||||
// 建立 RSS Feed
|
||||
let rssItems = '';
|
||||
if (data && data.length > 0) {
|
||||
const filteredData = {};
|
||||
data.forEach(item => {
|
||||
const reportDate = item.report_date;
|
||||
const publishedDate = new Date(item.published_date);
|
||||
|
||||
if (!filteredData[reportDate] || publishedDate > new Date(filteredData[reportDate].published_date)) {
|
||||
filteredData[reportDate] = item;
|
||||
}
|
||||
});
|
||||
const finalData = Object.values(filteredData);
|
||||
|
||||
finalData.forEach(item => {
|
||||
const pubDate = item.published_date ? formatRssDate(new Date(item.published_date)) : formatRssDate(new Date());
|
||||
const content = minifyHTML(item.content_html);
|
||||
const title = item.title || '无标题';
|
||||
const link = env.BOOK_LINK+item.link || '#';
|
||||
const description = stripHtml(item.content_html).substring(0, 200); // 移除 HTML 標籤並截取 200 字元
|
||||
|
||||
rssItems += `
|
||||
<item>
|
||||
<title><![CDATA[${title}]]></title>
|
||||
<link>${link}</link>
|
||||
<guid>${item.id || link}</guid>
|
||||
<pubDate>${pubDate}</pubDate>
|
||||
<content:encoded><![CDATA[${content}]]></content:encoded>
|
||||
<description><![CDATA[${description}]]></description>
|
||||
</item>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
const rssFeed = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
|
||||
<channel>
|
||||
<title>AI洞察日报 RSS Feed</title>
|
||||
<link>${env.BOOK_LINK}</link>
|
||||
<description> 近 ${days} 天的AI日报</description>
|
||||
<language>zh-cn</language>
|
||||
<lastBuildDate>${formatRssDate(new Date())}</lastBuildDate>
|
||||
<atom:link href="${url.origin}/rss" rel="self" type="application/rss+xml" />
|
||||
${rssItems}
|
||||
</channel>
|
||||
</rss>`;
|
||||
|
||||
return new Response(rssFeed, {
|
||||
headers: {
|
||||
'Content-Type': 'application/xml; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600' // 快取一小時
|
||||
}
|
||||
});
|
||||
}
|
||||
39
src/handlers/writeRssData.js
Normal file
39
src/handlers/writeRssData.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { replaceImageProxy, formatDateToChineseWithTime } from '../helpers.js';
|
||||
import { getDailyReportContent } from '../github.js';
|
||||
import { storeInKV } from '../kv.js';
|
||||
import { marked } from '../marked.esm.js';
|
||||
|
||||
export async function handleWriteRssData(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const dateStr = url.searchParams.get('date');
|
||||
|
||||
if (!dateStr) {
|
||||
return new Response('Missing date parameter', { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const path = `daily/${dateStr}.md`;
|
||||
const content = await getDailyReportContent(env, path);
|
||||
if (!content) {
|
||||
return new Response(`No content found for ${path}`, { status: 404 });
|
||||
}
|
||||
|
||||
const report = {
|
||||
report_date: dateStr,
|
||||
title: dateStr+'日刊',
|
||||
link: '/daily/'+dateStr+'.html',
|
||||
content_html: null,
|
||||
// 可以添加其他相關欄位,例如作者、來源等
|
||||
published_date: formatDateToChineseWithTime(new Date()) // 記錄保存時間
|
||||
}
|
||||
report.content_html = marked.parse(replaceImageProxy(env.IMG_PROXY, content));
|
||||
storeInKV(env.DATA_KV, `${dateStr}-report`, report);
|
||||
|
||||
return new Response(JSON.stringify({ message: `Successfully fetched and stored daily report for ${dateStr}`}), {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error handling daily report:', error);
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,7 @@ export function stripHtml(html) {
|
||||
* @param {string} dateString - The date string to convert.
|
||||
* @returns {Date} A Date object set to the specified date in Asia/Shanghai timezone.
|
||||
*/
|
||||
function convertToShanghaiTime(dateString) {
|
||||
export function convertToShanghaiTime(dateString) {
|
||||
// Create a Date object from the ISO string.
|
||||
const date = new Date(dateString);
|
||||
|
||||
@@ -143,6 +143,28 @@ function convertToShanghaiTime(dateString) {
|
||||
return new Date(shanghaiDateString);
|
||||
}
|
||||
|
||||
export function getShanghaiTime() {
|
||||
// Create a Date object from the ISO string.
|
||||
const date = new Date();
|
||||
|
||||
// Get the date components in Asia/Shanghai timezone
|
||||
const options = {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
second: 'numeric',
|
||||
hour12: false,
|
||||
timeZone: 'Asia/Shanghai'
|
||||
};
|
||||
|
||||
// Format the date to a string in Shanghai timezone, then parse it back to a Date object.
|
||||
// This is a common workaround to get a Date object representing a specific timezone.
|
||||
const shanghaiDateString = new Intl.DateTimeFormat('en-US', options).format(date);
|
||||
return new Date(shanghaiDateString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given date string is within the last specified number of days (inclusive of today).
|
||||
* @param {string} dateString - The date string to check (YYYY-MM-DD or ISO format).
|
||||
@@ -202,6 +224,27 @@ export function formatDateToChineseWithTime(isoDateString) {
|
||||
return new Intl.DateTimeFormat('zh-CN', options).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* 將日期物件格式化為 RSS 2.0 規範的日期字串 (RFC 822)
|
||||
* 例如: "Thu, 01 Jan 1970 00:00:00 GMT"
|
||||
* @param {Date} date - 日期物件
|
||||
* @returns {string} 格式化後的日期字串
|
||||
*/
|
||||
export function formatRssDate(date) {
|
||||
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
|
||||
const dayOfWeek = days[date.getUTCDay()];
|
||||
const dayOfMonth = date.getUTCDate();
|
||||
const month = months[date.getUTCMonth()];
|
||||
const year = date.getUTCFullYear();
|
||||
const hours = String(date.getUTCHours()).padStart(2, '0');
|
||||
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
|
||||
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
|
||||
|
||||
return `${dayOfWeek}, ${dayOfMonth} ${month} ${year} ${hours}:${minutes}:${seconds} GMT`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts English double quotes (") to Chinese double quotes (“”).
|
||||
* @param {string} text - The input string.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/htmlGenerators.js
|
||||
import { escapeHtml, formatDateToChinese, convertEnglishQuotesToChinese, replaceImageProxy} from './helpers.js';
|
||||
import { dataSources } from './dataFetchers.js'; // Import dataSources
|
||||
import { marked } from './marked.esm.js';
|
||||
|
||||
function generateHtmlListForContentPage(items, dateStr) {
|
||||
let listHtml = '';
|
||||
@@ -395,7 +396,7 @@ export function generateGenAiPageHtml(env, title, bodyContent, pageDate, isError
|
||||
</div>
|
||||
<p>所选内容日期: <strong>${formatDateToChinese(escapeHtml(pageDate))}</strong></p>
|
||||
<div class="content-box" id="mainContentBox">${bodyContent}</div>
|
||||
<div class="content-box" id="outContentBox">${markdownToHtml(replaceImageProxy(env.IMG_PROXY, bodyContent))}</div>
|
||||
<div class="content-box" id="outContentBox">${marked.parse(replaceImageProxy(env.IMG_PROXY, bodyContent))}</div>
|
||||
${promptDisplayHtml}
|
||||
<div class="navigation-links">
|
||||
<a href="/getContentHtml?date=${encodeURIComponent(pageDate)}" class="button-link">返回内容选择</a>
|
||||
@@ -447,27 +448,36 @@ export function generateGenAiPageHtml(env, title, bodyContent, pageDate, isError
|
||||
formData.append('podcast_script_markdown', document.getElementById('podcastMd').value);
|
||||
}
|
||||
|
||||
let githubSuccess = false;
|
||||
let supabaseSuccess = false;
|
||||
|
||||
try {
|
||||
const response = await fetch('/commitToGitHub', {
|
||||
// Commit to GitHub
|
||||
const githubResponse = await fetch('/commitToGitHub', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
const githubResult = await githubResponse.json();
|
||||
if (githubResponse.ok) {
|
||||
alert('GitHub 提交成功!');
|
||||
console.log('GitHub Commit Success:', result);
|
||||
console.log('GitHub Commit Success:', githubResult);
|
||||
githubSuccess = true;
|
||||
} else {
|
||||
alert('GitHub 提交失败: ' + result.message);
|
||||
console.error('GitHub Commit Failed:', result);
|
||||
alert('GitHub 提交失败: ' + githubResult.message);
|
||||
console.error('GitHub Commit Failed:', githubResult);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error committing to GitHub:', error);
|
||||
alert('请求失败,请检查网络或服务器。');
|
||||
} finally {
|
||||
button.textContent = originalText;
|
||||
button.disabled = false;
|
||||
alert('GitHub 请求失败,请检查网络或服务器。');
|
||||
}
|
||||
|
||||
if (githubSuccess || supabaseSuccess) {
|
||||
// Optionally reload or update UI if both or one succeeded
|
||||
}
|
||||
|
||||
button.textContent = originalText;
|
||||
button.disabled = false;
|
||||
}
|
||||
|
||||
async function generateAIDailyAnalysis(date) {
|
||||
@@ -514,286 +524,3 @@ export function generateGenAiPageHtml(env, title, bodyContent, pageDate, isError
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 一个功能完善的 Markdown 到 HTML 的转换器。
|
||||
*
|
||||
* 设计思路:
|
||||
* 1. **tokenize(markdown)**: 词法分析器,将 Markdown 字符串转换为一个令牌数组。
|
||||
* - 它按块(block-level elements)处理输入,如段落、标题、列表等。
|
||||
* - 每个令牌是一个对象,如 { type: 'heading', depth: 1, text: '标题文字' }。
|
||||
* - 包含内联 Markdown 的文本(如段落内容)会先保持原样,等待下一步处理。
|
||||
*
|
||||
* 2. **render(tokens)**: 渲染器,接收令牌数组并输出 HTML。
|
||||
* - 它遍历令牌,根据令牌的 `type` 生成相应的 HTML 标签。
|
||||
* - 当遇到需要处理内联元素的令牌时(如 heading, paragraph),它会调用 `parseInline`。
|
||||
*
|
||||
* 3. **parseInline(text)**: 内联解析器。
|
||||
* - 它负责处理行内的 Markdown 语法,如加粗、斜体、链接、图片、行内代码等。
|
||||
* - 它使用一系列的正则表达式按优先级顺序进行替换。
|
||||
*
|
||||
* 4. **辅助函数**: 如 `escapeHtml` 用于防止 XSS 攻击。
|
||||
*/
|
||||
export function markdownToHtml(markdown) {
|
||||
if (typeof markdown !== 'string') {
|
||||
console.error("Input must be a string.");
|
||||
return '';
|
||||
}
|
||||
|
||||
// 预处理:规范化换行符,确保尾部有换行符以便于正则匹配
|
||||
const preprocessedMarkdown = markdown.replace(/\r\n?/g, '\n').replace(/^(#+\s*[^#\s].*)\s*#+\s*$/gm, '$1') + '\n\n';
|
||||
|
||||
// --- 1. 辅助函数 ---
|
||||
const escapeHtml = (str) => {
|
||||
const map = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '\''
|
||||
};
|
||||
return str.replace(/[&<>"']/g, m => map[m]);
|
||||
};
|
||||
|
||||
|
||||
// --- 2. 内联解析器 (Inline Parser) ---
|
||||
// 按优先级顺序处理内联元素
|
||||
const parseInline = (text) => {
|
||||
let html = text;
|
||||
|
||||
// 图片: 
|
||||
html = html.replace(/!\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, alt, src, title) => {
|
||||
let titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<img src="${escapeHtml(src)}" alt="${escapeHtml(alt)}"${titleAttr}>`;
|
||||
});
|
||||
|
||||
// 链接: [text](href "title")
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]+)")?\)/g, (_, text, href, title) => {
|
||||
let titleAttr = title ? ` title="${escapeHtml(title)}"` : '';
|
||||
return `<a href="${escapeHtml(href)}"${titleAttr}>${parseInline(text)}</a>`; // 递归处理链接文本中的内联格式
|
||||
});
|
||||
|
||||
// 行内代码: `code`
|
||||
html = html.replace(/`([^`]+)`/g, (_, code) => `<code>${escapeHtml(code)}</code>`);
|
||||
|
||||
// 加粗+斜体: ***text*** 或 ___text___
|
||||
html = html.replace(/(\*\*\*|___)(.+?)\1/g, '<strong><em>$2</em></strong>');
|
||||
|
||||
// 加粗: **text** 或 __text__
|
||||
html = html.replace(/(\*\*|__)(.+?)\1/g, '<strong>$2</strong>');
|
||||
|
||||
// 斜体: *text* 或 _text_
|
||||
// html = html.replace(/(\*|_)(.+?)\1/g, '<em>$2</em>');
|
||||
|
||||
// 删除线: ~~text~~
|
||||
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
|
||||
|
||||
// 换行: 行尾两个空格
|
||||
html = html.replace(/ {2,}\n/g, '<br>\n');
|
||||
|
||||
return html;
|
||||
};
|
||||
|
||||
|
||||
// --- 3. 词法分析器 (Tokenizer) ---
|
||||
const tokenize = (md) => {
|
||||
const tokens = [];
|
||||
let src = md;
|
||||
|
||||
// 定义块级元素的正则表达式 (按优先级)
|
||||
const rules = {
|
||||
newline: /^\n+/,
|
||||
code: /^```(\w*)\n([\s\S]+?)\n```\n*/,
|
||||
fences: /^ {0,3}(`{3,}|~{3,})([^`~\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,
|
||||
heading: /^ {0,3}(#{1,6}) (.*)(?:\n+|$)/,
|
||||
hr: /^ {0,3}((?:- *){3,}|(?:_ *){3,}|(?:\* *){3,})(?:\n+|$)/,
|
||||
blockquote: /^( {0,3}> ?(.*)(?:\n|$))+/,
|
||||
list: /^( {0,3}(?:[*+-]|\d+\.) [^\n]*(?:\n(?!(?:[*+-]|\d+\. |>|#|`{3,}|-{3,}))[^\n]*)*)+/i,
|
||||
html: /^ {0,3}(?:<(script|pre|style|textarea)[\s>][\s\S]*?(?:<\/\1>[^\n]*\n+|$)|<!--[\s\S]*?(?:-->|$)|<\?[\s\S]*?(?:\?>\n*|$)|<![A-Z][\s\S]*?(?:>\n*|$)|<!\[CDATA\[[\s\S]*?(?:\]\]>\n*|$)|<\/?(address|article|aside|base|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|nav|ol|p|param|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul)(?: +|\n|\/?>)[\s\S]*?(?:\n\n+|$)|<(?!script|pre|style|textarea)[a-z][\w-]*\s*\/?>(?=[ \t]*(?:\n|$))[\s\S]*?(?:\n\n+|$))/,
|
||||
setextHeading: /^([^\n]+)\n {0,3}(=+|-+) *(?:\n+|$)/,
|
||||
paragraph: /^([^\n]+(?:\n(?! {0,3}(?:[*+-]|\d+\.) |>|#|`{3,}|-{3,}|={3,})[^\n]+)*)\n*/
|
||||
};
|
||||
|
||||
while (src) {
|
||||
// 1. 空行
|
||||
let cap = rules.newline.exec(src);
|
||||
if (cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
tokens.push({ type: 'space' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Fenced Code Block (```)
|
||||
cap = rules.fences.exec(src);
|
||||
if (cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
tokens.push({
|
||||
type: 'code',
|
||||
lang: cap[2] ? cap[2].trim() : '',
|
||||
text: cap[3] || ''
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. ATX Heading (# h1)
|
||||
cap = rules.heading.exec(src);
|
||||
if (cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
tokens.push({
|
||||
type: 'heading',
|
||||
depth: cap[1].length,
|
||||
text: cap[2].trim()
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Setext Heading (underline)
|
||||
cap = rules.setextHeading.exec(src);
|
||||
if(cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
tokens.push({
|
||||
type: 'heading',
|
||||
depth: cap[2].charAt(0) === '=' ? 1 : 2,
|
||||
text: cap[1]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 5. Horizontal Rule
|
||||
cap = rules.hr.exec(src);
|
||||
if (cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
tokens.push({ type: 'hr' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// 6. Blockquote
|
||||
cap = rules.blockquote.exec(src);
|
||||
if (cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
// 移除每行开头的 '>' 和一个可选的空格
|
||||
const bqContent = cap[0].replace(/^ *> ?/gm, '');
|
||||
tokens.push({
|
||||
type: 'blockquote',
|
||||
// 递归地对块引用内容进行词法分析
|
||||
tokens: tokenize(bqContent)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 7. List
|
||||
cap = rules.list.exec(src);
|
||||
if (cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
const listStr = cap[0];
|
||||
const ordered = /^\d+\./.test(listStr);
|
||||
const itemRegex = /^( *)([*+-]|\d+\.) +([^\n]*(?:\n(?! {0,3}(?:[*+-]|\d+\.) )[^\n]*)*)/gm;
|
||||
const items = [];
|
||||
let match;
|
||||
while ((match = itemRegex.exec(listStr)) !== null) {
|
||||
const [, indent, , itemContent] = match;
|
||||
// 处理嵌套内容,移除当前项的缩进
|
||||
const nestedContent = itemContent.replace(new RegExp('^' + ' '.repeat(indent.length), 'gm'), '');
|
||||
items.push({
|
||||
type: 'list_item',
|
||||
// 递归地对列表项内容进行词法分析
|
||||
tokens: tokenize(nestedContent)
|
||||
});
|
||||
}
|
||||
|
||||
tokens.push({
|
||||
type: 'list',
|
||||
ordered: ordered,
|
||||
items: items
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 8. Raw HTML
|
||||
cap = rules.html.exec(src);
|
||||
if (cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
tokens.push({
|
||||
type: 'html',
|
||||
text: cap[0]
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 9. Paragraph (作为最后的 fallback)
|
||||
cap = rules.paragraph.exec(src);
|
||||
if (cap) {
|
||||
src = src.substring(cap[0].length);
|
||||
tokens.push({
|
||||
type: 'paragraph',
|
||||
text: cap[1].trim()
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 如果没有规则匹配,说明有未知语法,跳过一个字符防止死循环
|
||||
if (src) {
|
||||
console.error(`Infinite loop on: ${src.slice(0, 20)}`);
|
||||
src = src.substring(1);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
};
|
||||
|
||||
|
||||
// --- 4. 渲染器 (Renderer) ---
|
||||
const render = (tokens) => {
|
||||
let html = '';
|
||||
|
||||
for (const token of tokens) {
|
||||
switch (token.type) {
|
||||
case 'space':
|
||||
break;
|
||||
case 'hr':
|
||||
html += '<hr>\n';
|
||||
break;
|
||||
case 'heading':
|
||||
html += `<h${token.depth}>${parseInline(token.text)}</h${token.depth}>\n`;
|
||||
break;
|
||||
|
||||
case 'code':
|
||||
const langClass = token.lang ? ` class="language-${escapeHtml(token.lang)}"` : '';
|
||||
html += `<pre><code${langClass}>${escapeHtml(token.text)}</code></pre>\n`;
|
||||
break;
|
||||
|
||||
case 'blockquote':
|
||||
// 递归渲染块引用内的令牌
|
||||
html += `<blockquote>\n${render(token.tokens)}</blockquote>\n`;
|
||||
break;
|
||||
|
||||
case 'list':
|
||||
const tag = 'ul';
|
||||
let listContent = '';
|
||||
for (const item of token.items) {
|
||||
// 递归渲染列表项内的令牌
|
||||
listContent += `<li>${render(item.tokens).trim()}</li>\n`;
|
||||
}
|
||||
html += `<${tag}>\n${listContent}</${tag}>\n`;
|
||||
break;
|
||||
|
||||
case 'paragraph':
|
||||
html += `<p>${parseInline(token.text)}</p>\n`;
|
||||
break;
|
||||
|
||||
case 'html':
|
||||
html += token.text;
|
||||
break;
|
||||
|
||||
default:
|
||||
console.error(`Unknown token type: ${token.type}`);
|
||||
}
|
||||
}
|
||||
return html;
|
||||
};
|
||||
|
||||
// --- 执行流程 ---
|
||||
const tokens = tokenize(preprocessedMarkdown);
|
||||
const result = render(tokens);
|
||||
return result.trim();
|
||||
}
|
||||
|
||||
12
src/index.js
12
src/index.js
@@ -4,8 +4,10 @@ import { handleGetContent } from './handlers/getContent.js';
|
||||
import { handleGetContentHtml } from './handlers/getContentHtml.js';
|
||||
import { handleGenAIContent, handleGenAIPodcastScript, handleGenAIDailyAnalysis } from './handlers/genAIContent.js'; // Import handleGenAIPodcastScript and handleGenAIDailyAnalysis
|
||||
import { handleCommitToGitHub } from './handlers/commitToGitHub.js';
|
||||
import { dataSources } from './dataFetchers.js'; // Import dataSources
|
||||
import { handleLogin, isAuthenticated, handleLogout } from './auth.js'; // Import auth functions
|
||||
import { handleRss } from './handlers/getRss.js';
|
||||
import { handleWriteRssData } from './handlers/writeRssData.js';
|
||||
import { dataSources } from './dataFetchers.js';
|
||||
import { handleLogin, isAuthenticated, handleLogout } from './auth.js';
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
@@ -44,6 +46,10 @@ export default {
|
||||
return await handleLogout(request, env);
|
||||
} else if (path === '/getContent' && request.method === 'GET') {
|
||||
return await handleGetContent(request, env);
|
||||
} else if (path.startsWith('/rss') && request.method === 'GET') {
|
||||
return await handleRss(request, env);
|
||||
} else if (path === '/writeRssData' && request.method === 'GET') {
|
||||
return await handleWriteRssData(request, env);
|
||||
}
|
||||
|
||||
// Authentication check for all other paths
|
||||
@@ -75,7 +81,7 @@ export default {
|
||||
response = await handleGenAIDailyAnalysis(request, env);
|
||||
} else if (path === '/commitToGitHub' && request.method === 'POST') {
|
||||
response = await handleCommitToGitHub(request, env);
|
||||
} else {
|
||||
} else {
|
||||
return new Response(null, { status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'} });
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
2189
src/marked.esm.js
Normal file
2189
src/marked.esm.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -39,4 +39,6 @@ LOGIN_PASSWORD = "toor"
|
||||
DAILY_TITLE = "AI洞察日报"
|
||||
PODCAST_TITLE = "来生小酒馆"
|
||||
PODCAST_BEGIN = "嘿,亲爱的V,欢迎收听新一期的来生情报站,我是你们的老朋友,何夕2077"
|
||||
PODCAST_END = "今天的情报就到这里,注意隐蔽,赶紧撤离"
|
||||
PODCAST_END = "今天的情报就到这里,注意隐蔽,赶紧撤离"
|
||||
BOOK_LINK = ""
|
||||
INSERT_FOOT = "false"
|
||||
Reference in New Issue
Block a user