opensource

This commit is contained in:
justlovemaki
2025-06-11 17:56:40 +08:00
parent f6387fbe55
commit 67254542d1
44 changed files with 4920 additions and 1 deletions

177
src/auth.js Normal file
View File

@@ -0,0 +1,177 @@
// src/auth.js
const SESSION_COOKIE_NAME = 'session_id_89757';
const SESSION_EXPIRATION_SECONDS = 60 * 60; // 1 hour
// Function to generate the login page HTML
function generateLoginPage(redirectUrl) {
return `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<style>
body { font-family: sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; background-color: #f4f4f4; margin: 0; }
.login-container { background-color: #fff; padding: 30px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); width: 100%; max-width: 400px; text-align: center; }
h2 { color: #333; margin-bottom: 20px; }
.form-group { margin-bottom: 15px; text-align: left; }
label { display: block; margin-bottom: 5px; color: #555; }
input[type="text"], input[type="password"] { width: calc(100% - 20px); padding: 10px; border: 1px solid #ddd; border-radius: 4px; box-sizing: border-box; }
button { background-color: #007bff; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; width: 100%; }
button:hover { background-color: #0056b3; }
.error-message { color: red; margin-top: 10px; }
</style>
</head>
<body>
<div class="login-container">
<h2>Login</h2>
<form id="loginForm" method="POST" action="/login">
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<input type="hidden" name="redirect" value="${redirectUrl}">
<button type="submit">Login</button>
<p id="errorMessage" class="error-message"></p>
</form>
<script>
const form = document.getElementById('loginForm');
const errorMessage = document.getElementById('errorMessage');
form.addEventListener('submit', async (event) => {
event.preventDefault();
const formData = new FormData(form);
const response = await fetch('/login', {
method: 'POST',
body: new URLSearchParams(formData).toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
if (response.ok) {
const redirectUrl = response.headers.get('X-Redirect-Url');
if (redirectUrl && redirectUrl !== '/') {
window.location.href = redirectUrl;
} else {
window.location.href = '/getContentHtml'; // Fallback to home
}
} else {
const errorText = await response.text();
errorMessage.textContent = errorText || 'Login failed. Please try again.';
}
});
</script>
</div>
</body>
</html>
`;
}
// Function to set or renew the session cookie
function setSessionCookie(sessionId) {
const expirationDate = new Date(Date.now() + SESSION_EXPIRATION_SECONDS * 1000);
return `${SESSION_COOKIE_NAME}=${sessionId}; Path=/; Expires=${expirationDate.toUTCString()}; HttpOnly; Secure; SameSite=Lax`;
}
// Function to handle login requests
async function handleLogin(request, env) {
if (request.method === 'GET') {
const url = new URL(request.url);
const redirectUrl = url.searchParams.get('redirect') || '/getContentHtml';
return new Response(generateLoginPage(redirectUrl), {
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
} else if (request.method === 'POST') {
const formData = await request.formData();
const username = formData.get('username');
const password = formData.get('password');
const redirect = formData.get('redirect') || '/';
if (username === env.LOGIN_USERNAME && password === env.LOGIN_PASSWORD) {
const sessionId = crypto.randomUUID(); // Generate a simple session ID
// Store sessionId in KV store for persistent sessions
// await env.DATA_KV.put(`session:${sessionId}`, 'valid', { expirationTtl: SESSION_EXPIRATION_SECONDS });
const cookie = setSessionCookie(sessionId);
return new Response('Login successful', {
status: 200,
headers: {
'Set-Cookie': cookie,
'X-Redirect-Url': redirect, // Custom header for client-side redirect
},
});
} else {
return new Response('Invalid username or password', { status: 401 });
}
}
return new Response('Method Not Allowed', { status: 405 });
}
// Function to check and renew session cookie
async function isAuthenticated(request, env) {
const cookieHeader = request.headers.get('Cookie');
if (!cookieHeader) {
return { authenticated: false, cookie: null };
}
const cookies = cookieHeader.split(';').map(c => c.trim());
const sessionCookie = cookies.find(cookie => cookie.startsWith(`${SESSION_COOKIE_NAME}=`));
if (!sessionCookie) {
return { authenticated: false, cookie: null };
}
const sessionId = sessionCookie.split('=')[1];
// Validate sessionId against KV store
// const storedSession = await env.DATA_KV.get(`session:${sessionId}`);
// if (storedSession !== 'valid') {
// return { authenticated: false, cookie: null };
// }
// Renew the session cookie
const newCookie = setSessionCookie(sessionId);
return { authenticated: true, cookie: newCookie };
}
// Function to handle logout requests
async function handleLogout(request, env) {
const cookieHeader = request.headers.get('Cookie');
if (cookieHeader) {
const cookies = cookieHeader.split(';').map(c => c.trim());
const sessionCookie = cookies.find(cookie => cookie.startsWith(`${SESSION_COOKIE_NAME}=`));
if (sessionCookie) {
const sessionId = sessionCookie.split('=')[1];
// Delete session from KV store
// await env.DATA_KV.delete(`session:${sessionId}`);
}
}
const expiredDate = new Date(0); // Set expiration to a past date
const cookie = `${SESSION_COOKIE_NAME}=; Path=/; Expires=${expiredDate.toUTCString()}; HttpOnly; Secure; SameSite=Lax`;
const url = new URL(request.url);
const redirectUrl = url.searchParams.get('redirect') || '/login'; // Redirect to login page by default
return new Response('Logged out', {
status: 302,
headers: {
'Set-Cookie': cookie,
'Location': redirectUrl,
},
});
}
export {
handleLogin,
isAuthenticated,
handleLogout,
SESSION_COOKIE_NAME,
SESSION_EXPIRATION_SECONDS,
};

567
src/chatapi.js Normal file
View File

@@ -0,0 +1,567 @@
// src/chatapi.js
/**
* Calls the Gemini Chat API (non-streaming).
*
* @param {object} env - Environment object containing GEMINI_API_URL.
* @param {string} promptText - The user's prompt.
* @param {string | null} [systemPromptText=null] - Optional system prompt text.
* @returns {Promise<string>} The generated text content.
* @throws {Error} If GEMINI_API_URL is not set, or if API call fails or returns blocked/empty content.
*/
async function callGeminiChatAPI(env, promptText, systemPromptText = null) {
if (!env.GEMINI_API_URL) {
throw new Error("GEMINI_API_URL environment variable is not set.");
}
if (!env.GEMINI_API_KEY) {
throw new Error("GEMINI_API_KEY environment variable is not set for Gemini models.");
}
const modelName = env.DEFAULT_GEMINI_MODEL;
const url = `${env.GEMINI_API_URL}/v1beta/models/${modelName}:generateContent?key=${env.GEMINI_API_KEY}`;
const payload = {
contents: [{
parts: [{ text: promptText }]
}],
};
if (systemPromptText && typeof systemPromptText === 'string' && systemPromptText.trim() !== '') {
payload.systemInstruction = {
parts: [{ text: systemPromptText }]
};
console.log("System instruction included in Chat API call.");
}
try {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorBodyText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorBodyText);
} catch (e) {
errorData = errorBodyText;
}
console.error("Gemini Chat API Error Response Body:", typeof errorData === 'object' ? JSON.stringify(errorData, null, 2) : errorData);
const message = typeof errorData === 'object' && errorData.error?.message
? errorData.error.message
: (typeof errorData === 'string' ? errorData : 'Unknown Gemini Chat API error');
throw new Error(`Gemini Chat API error (${response.status}): ${message}`);
}
const data = await response.json();
// 1. Check for prompt-level blocking first
if (data.promptFeedback && data.promptFeedback.blockReason) {
const blockReason = data.promptFeedback.blockReason;
const safetyRatings = data.promptFeedback.safetyRatings ? JSON.stringify(data.promptFeedback.safetyRatings) : 'N/A';
console.error(`Gemini Chat prompt blocked: ${blockReason}. Safety ratings: ${safetyRatings}`, JSON.stringify(data, null, 2));
throw new Error(`Gemini Chat prompt blocked: ${blockReason}. Safety ratings: ${safetyRatings}`);
}
// 2. Check candidates and their content
if (data.candidates && data.candidates.length > 0) {
const candidate = data.candidates[0];
// Check finishReason for issues other than STOP
// Common finishReasons: STOP, MAX_TOKENS, SAFETY, RECITATION, OTHER
if (candidate.finishReason && candidate.finishReason !== "STOP") {
const reason = candidate.finishReason;
const safetyRatings = candidate.safetyRatings ? JSON.stringify(candidate.safetyRatings) : 'N/A';
console.error(`Gemini Chat content generation finished with reason: ${reason}. Safety ratings: ${safetyRatings}`, JSON.stringify(data, null, 2));
if (reason === "SAFETY") {
throw new Error(`Gemini Chat content generation blocked due to safety (${reason}). Safety ratings: ${safetyRatings}`);
}
throw new Error(`Gemini Chat content generation finished due to: ${reason}. Safety ratings: ${safetyRatings}`);
}
// If finishReason is STOP, try to extract text
if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0 && candidate.content.parts[0].text) {
return candidate.content.parts[0].text;
} else {
// finishReason was STOP (or not present, implying success), but no text.
console.warn("Gemini Chat API response has candidate with 'STOP' finishReason but no text content, or content structure is unexpected.", JSON.stringify(data, null, 2));
throw new Error("Gemini Chat API returned a candidate with 'STOP' finishReason but no text content.");
}
} else {
// No candidates, and no promptFeedback block reason either (handled above).
// This means the response is empty or malformed in an unexpected way.
console.warn("Gemini Chat API response format unexpected: No candidates found and no prompt block reason.", JSON.stringify(data, null, 2));
throw new Error("Gemini Chat API returned an empty or malformed response with no candidates.");
}
} catch (error) {
// Log the full error object if it's not one we constructed, or just re-throw
if (!(error instanceof Error && error.message.startsWith("Gemini Chat"))) {
console.error("Error calling Gemini Chat API (Non-streaming):", error);
}
throw error;
}
}
/**
* Calls the Gemini Chat API with streaming.
*
* @param {object} env - Environment object containing GEMINI_API_URL.
* @param {string} promptText - The user's prompt.
* @param {string | null} [systemPromptText=null] - Optional system prompt text.
* @returns {AsyncGenerator<string, void, undefined>} An async generator yielding text chunks.
* @throws {Error} If GEMINI_API_URL is not set, or if API call fails or returns blocked/empty content.
*/
async function* callGeminiChatAPIStream(env, promptText, systemPromptText = null) {
if (!env.GEMINI_API_URL) {
throw new Error("GEMINI_API_URL environment variable is not set.");
}
if (!env.GEMINI_API_KEY) {
throw new Error("GEMINI_API_KEY environment variable is not set for Gemini models.");
}
const modelName = env.DEFAULT_GEMINI_MODEL;
const url = `${env.GEMINI_API_URL}/v1beta/models/${modelName}:streamGenerateContent?key=${env.GEMINI_API_KEY}&alt=sse`;
const payload = {
contents: [{
parts: [{ text: promptText }]
}],
};
if (systemPromptText && typeof systemPromptText === 'string' && systemPromptText.trim() !== '') {
payload.systemInstruction = {
parts: [{ text: systemPromptText }]
};
console.log("System instruction included in Chat API call.");
}
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorBodyText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorBodyBody);
} catch (e) {
errorData = errorBodyText;
}
console.error("Gemini Chat API Error (Stream Initial) Response Body:", typeof errorData === 'object' ? JSON.stringify(errorData, null, 2) : errorData);
const message = typeof errorData === 'object' && errorData.error?.message
? errorData.error.message
: (typeof errorData === 'string' ? errorData : 'Unknown Gemini Chat API error');
throw new Error(`Gemini Chat API error (${response.status}): ${message}`);
}
if (!response.body) {
throw new Error("Response body is null, cannot stream.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let hasYieldedContent = false;
let overallFinishReason = null; // To track the final finish reason if available
let finalSafetyRatings = null;
const processJsonChunk = (jsonString) => {
if (jsonString.trim() === "") return null;
try {
return JSON.parse(jsonString);
} catch (e) {
console.warn("Failed to parse JSON chunk from stream:", jsonString, e.message);
return null; // Or throw, depending on how strictly you want to handle malformed JSON
}
};
const handleChunkLogic = (chunk) => {
if (!chunk) return false; // Not a valid chunk to process
// 1. Check for prompt-level blocking (might appear in first chunk)
if (chunk.promptFeedback && chunk.promptFeedback.blockReason) {
const blockReason = chunk.promptFeedback.blockReason;
const safetyRatings = chunk.promptFeedback.safetyRatings ? JSON.stringify(chunk.promptFeedback.safetyRatings) : 'N/A';
console.error(`Gemini Chat prompt blocked during stream: ${blockReason}. Safety ratings: ${safetyRatings}`, JSON.stringify(chunk, null, 2));
throw new Error(`Gemini Chat prompt blocked: ${blockReason}. Safety ratings: ${safetyRatings}`);
}
// 2. Check candidates
if (chunk.candidates && chunk.candidates.length > 0) {
const candidate = chunk.candidates[0];
if (candidate.finishReason) {
overallFinishReason = candidate.finishReason; // Store the latest finish reason
finalSafetyRatings = candidate.safetyRatings;
if (candidate.finishReason !== "STOP") {
const reason = candidate.finishReason;
const sr = candidate.safetyRatings ? JSON.stringify(candidate.safetyRatings) : 'N/A';
console.error(`Gemini Chat stream candidate finished with reason: ${reason}. Safety ratings: ${sr}`, JSON.stringify(chunk, null, 2));
if (reason === "SAFETY") {
throw new Error(`Gemini Chat content generation blocked due to safety (${reason}). Safety ratings: ${sr}`);
}
throw new Error(`Gemini Chat stream finished due to: ${reason}. Safety ratings: ${sr}`);
}
}
if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0) {
const textPart = candidate.content.parts[0].text;
if (textPart && typeof textPart === 'string') {
hasYieldedContent = true;
return textPart; // This is the text to yield
}
}
} else if (chunk.error) { // Check for explicit error object in stream
console.error("Gemini Chat API Stream Error Chunk:", JSON.stringify(chunk.error, null, 2));
throw new Error(`Gemini Chat API stream error: ${chunk.error.message || 'Unknown error in stream'}`);
}
return null; // No text to yield from this chunk
};
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
let eventBoundary;
while ((eventBoundary = buffer.indexOf('\n\n')) !== -1 || (eventBoundary = buffer.indexOf('\n')) !== -1) {
const separatorLength = (buffer.indexOf('\n\n') === eventBoundary) ? 2 : 1;
let message = buffer.substring(0, eventBoundary);
buffer = buffer.substring(eventBoundary + separatorLength);
if (message.startsWith("data: ")) {
message = message.substring(5).trim();
} else {
message = message.trim();
}
if (message === "" || message === "[DONE]") {
continue;
}
const parsedChunk = processJsonChunk(message);
if (parsedChunk) {
const textToYield = handleChunkLogic(parsedChunk);
if (textToYield !== null) {
yield textToYield;
}
}
}
}
// Process any remaining data in the buffer (if not ending with newline(s))
if (buffer.trim()) {
let finalMessage = buffer.trim();
if (finalMessage.startsWith("data: ")) {
finalMessage = finalMessage.substring(5).trim();
}
if (finalMessage !== "" && finalMessage !== "[DONE]") {
const parsedChunk = processJsonChunk(finalMessage);
if (parsedChunk) {
const textToYield = handleChunkLogic(parsedChunk);
if (textToYield !== null) {
yield textToYield;
}
}
}
}
// After the stream has finished, check if any content was yielded and the overall outcome
if (!hasYieldedContent) {
if (overallFinishReason && overallFinishReason !== "STOP") {
const sr = finalSafetyRatings ? JSON.stringify(finalSafetyRatings) : 'N/A';
console.warn(`Gemini Chat stream ended with reason '${overallFinishReason}' and no content was yielded. Safety: ${sr}`);
throw new Error(`Gemini Chat stream completed due to ${overallFinishReason} without yielding content. Safety ratings: ${sr}`);
} else if (overallFinishReason === "STOP") {
console.warn("Gemini Chat stream finished with 'STOP' but no content was yielded.", JSON.stringify({overallFinishReason, finalSafetyRatings}, null, 2));
throw new Error("Gemini Chat stream completed with 'STOP' but yielded no content.");
} else if (!overallFinishReason) {
console.warn("Gemini Chat stream ended without yielding any content or a clear finish reason.");
throw new Error("Gemini Chat stream completed without yielding any content.");
}
}
} catch (error) {
if (!(error instanceof Error && error.message.startsWith("Gemini Chat"))) {
console.error("Error calling or streaming from Gemini Chat API:", error);
}
throw error;
}
}
/**
* Calls the OpenAI Chat API (non-streaming).
*
* @param {object} env - Environment object containing OPENAI_API_URL and OPENAI_API_KEY.
* @param {string} promptText - The user's prompt.
* @param {string | null} [systemPromptText=null] - Optional system prompt text.
* @returns {Promise<string>} The generated text content.
* @throws {Error} If OPENAI_API_URL or OPENAI_API_KEY is not set, or if API call fails.
*/
async function callOpenAIChatAPI(env, promptText, systemPromptText = null) {
if (!env.OPENAI_API_URL) {
throw new Error("OPENAI_API_URL environment variable is not set.");
}
if (!env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY environment variable is not set for OpenAI models.");
}
const url = `${env.OPENAI_API_URL}/v1/chat/completions`;
const messages = [];
if (systemPromptText && typeof systemPromptText === 'string' && systemPromptText.trim() !== '') {
messages.push({ role: "system", content: systemPromptText });
console.log("System instruction included in OpenAI Chat API call.");
}
messages.push({ role: "user", content: promptText });
const modelName = env.DEFAULT_OPEN_MODEL;
const payload = {
model: modelName,
messages: messages,
temperature: 1,
max_tokens: 2048,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
};
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorBodyText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorBodyText);
} catch (e) {
errorData = errorBodyText;
}
console.error("OpenAI Chat API Error Response Body:", typeof errorData === 'object' ? JSON.stringify(errorData, null, 2) : errorData);
const message = typeof errorData === 'object' && errorData.error?.message
? errorData.error.message
: (typeof errorData === 'string' ? errorData : 'Unknown OpenAI Chat API error');
throw new Error(`OpenAI Chat API error (${response.status}): ${message}`);
}
const data = await response.json();
if (data.choices && data.choices.length > 0 && data.choices[0].message && data.choices[0].message.content) {
return data.choices[0].message.content;
} else {
console.warn("OpenAI Chat API response format unexpected: No choices or content found.", JSON.stringify(data, null, 2));
throw new Error("OpenAI Chat API returned an empty or malformed response.");
}
} catch (error) {
if (!(error instanceof Error && error.message.startsWith("OpenAI Chat"))) {
console.error("Error calling OpenAI Chat API (Non-streaming):", error);
}
throw error;
}
}
/**
* Calls the OpenAI Chat API with streaming.
*
* @param {object} env - Environment object containing OPENAI_API_URL and OPENAI_API_KEY.
* @param {string} promptText - The user's prompt.
* @param {string | null} [systemPromptText=null] - Optional system prompt text.
* @returns {AsyncGenerator<string, void, undefined>} An async generator yielding text chunks.
* @throws {Error} If OPENAI_API_URL or OPENAI_API_KEY is not set, or if API call fails.
*/
async function* callOpenAIChatAPIStream(env, promptText, systemPromptText = null) {
if (!env.OPENAI_API_URL) {
throw new Error("OPENAI_API_URL environment variable is not set.");
}
if (!env.OPENAI_API_KEY) {
throw new Error("OPENAI_API_KEY environment variable is not set for OpenAI models.");
}
const url = `${env.OPENAI_API_URL}/v1/chat/completions`;
const messages = [];
if (systemPromptText && typeof systemPromptText === 'string' && systemPromptText.trim() !== '') {
messages.push({ role: "system", content: systemPromptText });
console.log("System instruction included in OpenAI Chat API call.");
}
messages.push({ role: "user", content: promptText });
const modelName = env.DEFAULT_OPEN_MODEL;
const payload = {
model: modelName,
messages: messages,
temperature: 1,
max_tokens: 2048,
top_p: 1,
frequency_penalty: 0,
presence_penalty: 0,
stream: true,
};
let response;
try {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${env.OPENAI_API_KEY}`
},
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorBodyText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorBodyText);
} catch (e) {
errorData = errorBodyText;
}
console.error("OpenAI Chat API Error (Stream Initial) Response Body:", typeof errorData === 'object' ? JSON.stringify(errorData, null, 2) : errorData);
const message = typeof errorData === 'object' && errorData.error?.message
? errorData.error.message
: (typeof errorData === 'string' ? errorData : 'Unknown OpenAI Chat API error');
throw new Error(`OpenAI Chat API error (${response.status}): ${message}`);
}
if (!response.body) {
throw new Error("Response body is null, cannot stream.");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let hasYieldedContent = false;
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// OpenAI streaming uses data: {JSON}\n\n
let eventBoundary;
while ((eventBoundary = buffer.indexOf('\n\n')) !== -1) {
let message = buffer.substring(0, eventBoundary);
buffer = buffer.substring(eventBoundary + 2); // +2 for '\n\n'
if (message.startsWith("data: ")) {
message = message.substring(5).trim();
} else {
message = message.trim();
}
if (message === "" || message === "[DONE]") {
continue;
}
try {
const parsedChunk = JSON.parse(message);
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
const delta = parsedChunk.choices[0].delta;
if (delta && delta.content) {
hasYieldedContent = true;
yield delta.content;
}
} else if (parsedChunk.error) {
console.error("OpenAI Chat API Stream Error Chunk:", JSON.stringify(parsedChunk.error, null, 2));
throw new Error(`OpenAI Chat API stream error: ${parsedChunk.error.message || 'Unknown error in stream'}`);
}
} catch (e) {
console.warn("Failed to parse JSON chunk from OpenAI stream:", message, e.message);
// Continue processing, might be an incomplete chunk
}
}
}
// Process any remaining data in the buffer
if (buffer.trim()) {
let finalMessage = buffer.trim();
if (finalMessage.startsWith("data: ")) {
finalMessage = finalMessage.substring(5).trim();
}
if (finalMessage !== "" && finalMessage !== "[DONE]") {
try {
const parsedChunk = JSON.parse(finalMessage);
if (parsedChunk.choices && parsedChunk.choices.length > 0) {
const delta = parsedChunk.choices[0].delta;
if (delta && delta.content) {
hasYieldedContent = true;
yield delta.content;
}
} else if (parsedChunk.error) {
console.error("OpenAI Chat API Stream Error Chunk:", JSON.stringify(parsedChunk.error, null, 2));
throw new Error(`OpenAI Chat API stream error: ${parsedChunk.error.message || 'Unknown error in stream'}`);
}
} catch (e) {
console.warn("Failed to parse final JSON chunk from OpenAI stream:", finalMessage, e.message);
}
}
}
if (!hasYieldedContent) {
console.warn("OpenAI Chat stream finished but no content was yielded.");
throw new Error("OpenAI Chat stream completed but yielded no content.");
}
} catch (error) {
if (!(error instanceof Error && error.message.startsWith("OpenAI Chat"))) {
console.error("Error calling or streaming from OpenAI Chat API:", error);
}
throw error;
}
}
/**
* Main function to call the appropriate chat API (Gemini or OpenAI) based on model name.
* Defaults to Gemini if no specific API is indicated in the model name.
*
* @param {object} env - Environment object.
* @param {string} promptText - The user's prompt.
* @param {string | null} [systemPromptText=null] - Optional system prompt text.
* @returns {Promise<string>} The generated text content.
* @throws {Error} If API keys/URLs are not set, or if API call fails.
*/
export async function callChatAPI(env, promptText, systemPromptText = null) {
const platform = env.USE_MODEL_PLATFORM;
if (platform.startsWith("OPEN")) {
return callOpenAIChatAPI(env, promptText, systemPromptText);
} else { // Default to Gemini
return callGeminiChatAPI(env, promptText, systemPromptText);
}
}
/**
* Main function to call the appropriate chat API (Gemini or OpenAI) with streaming.
* Defaults to Gemini if no specific API is indicated in the model name.
*
* @param {object} env - Environment object.
* @param {string} promptText - The user's prompt.
* @param {string | null} [systemPromptText=null] - Optional system prompt text.
* @returns {AsyncGenerator<string, void, undefined>} An async generator yielding text chunks.
* @throws {Error} If API keys/URLs are not set, or if API call fails.
*/
export async function* callChatAPIStream(env, promptText, systemPromptText = null) {
const platform = env.USE_MODEL_PLATFORM;
if (platform.startsWith("OPEN")) {
yield* callOpenAIChatAPIStream(env, promptText, systemPromptText);
} else { // Default to Gemini
yield* callGeminiChatAPIStream(env, promptText, systemPromptText);
}
}

91
src/dataFetchers.js Normal file
View File

@@ -0,0 +1,91 @@
// src/dataFetchers.js
import AibaseDataSource from './dataSources/aibase.js';
import GithubTrendingDataSource from './dataSources/github-trending.js';
import HuggingfacePapersDataSource from './dataSources/huggingface-papers.js';
import XiaohuDataSource from './dataSources/xiaohu.js';
import TwitterDataSource from './dataSources/twitter.js';
// Register data sources as arrays to support multiple sources per type
export const dataSources = {
news: { name: '新闻', sources: [AibaseDataSource, XiaohuDataSource] },
project: { name: '项目', sources: [GithubTrendingDataSource] },
paper: { name: '论文', sources: [HuggingfacePapersDataSource] },
socialMedia: { name: '社交平台', sources: [TwitterDataSource] },
// Add new data sources here as arrays, e.g.,
// newType: { name: '新类型', sources: [NewTypeDataSource1, NewTypeDataSource2] },
};
/**
* Fetches and transforms data from all data sources for a specified type.
* @param {string} sourceType - The type of data source (e.g., 'news', 'projects', 'papers').
* @param {object} env - The environment variables.
* @param {string} [foloCookie] - The Folo authentication cookie.
* @returns {Promise<Array<object>>} A promise that resolves to an array of unified data objects from all sources of that type.
*/
export async function fetchAndTransformDataForType(sourceType, env, foloCookie) {
const sources = dataSources[sourceType].sources;
if (!sources || !Array.isArray(sources)) {
console.error(`No data sources registered for type: ${sourceType}`);
return [];
}
let allUnifiedDataForType = [];
for (const dataSource of sources) {
try {
// Pass foloCookie to the fetch method of the data source
const rawData = await dataSource.fetch(env, foloCookie);
const unifiedData = dataSource.transform(rawData, sourceType);
allUnifiedDataForType = allUnifiedDataForType.concat(unifiedData);
} catch (error) {
console.error(`Error fetching or transforming data from source ${dataSource.type} for type ${sourceType}:`, error.message);
// Continue to next data source even if one fails
}
}
// Sort by published_date in descending order for each type
allUnifiedDataForType.sort((a, b) => {
const dateA = new Date(a.published_date);
const dateB = new Date(b.published_date);
return dateB.getTime() - dateA.getTime();
});
return allUnifiedDataForType;
}
/**
* Fetches and transforms data from all registered data sources across all types.
* @param {object} env - The environment variables.
* @param {string} [foloCookie] - The Folo authentication cookie.
* @returns {Promise<object>} A promise that resolves to an object containing unified data for each source type.
*/
export async function fetchAllData(env, foloCookie) {
const allUnifiedData = {};
const fetchPromises = [];
for (const sourceType in dataSources) {
if (Object.hasOwnProperty.call(dataSources, sourceType)) {
fetchPromises.push(
fetchAndTransformDataForType(sourceType, env, foloCookie).then(data => {
allUnifiedData[sourceType] = data;
})
);
}
}
await Promise.allSettled(fetchPromises); // Use allSettled to ensure all promises complete
return allUnifiedData;
}
/**
* Fetches and transforms data from all data sources for a specific category.
* @param {object} env - The environment variables.
* @param {string} category - The category to fetch data for (e.g., 'news', 'project', 'paper', 'twitter').
* @param {string} [foloCookie] - The Folo authentication cookie.
* @returns {Promise<Array<object>>} A promise that resolves to an array of unified data objects for the specified category.
*/
export async function fetchDataByCategory(env, category, foloCookie) {
if (!dataSources[category]) {
console.warn(`Attempted to fetch data for unknown category: ${category}`);
return [];
}
return await fetchAndTransformDataForType(category, env, foloCookie);
}

139
src/dataSources/aibase.js Normal file
View File

@@ -0,0 +1,139 @@
// src/dataSources/aibase.js
import { getRandomUserAgent, sleep, isDateWithinLastDays, stripHtml, formatDateToChineseWithTime, escapeHtml} from '../helpers.js';
const NewsDataSource = {
fetch: async (env, foloCookie) => { // Add sourceType
const feedId = env.AIBASE_FEED_ID;
const fetchPages = parseInt(env.AIBASE_FETCH_PAGES || '3', 10);
const allAibaseItems = [];
const filterDays = parseInt(env.FOLO_FILTER_DAYS || '3', 10);
if (!feedId) {
console.error('AIBASE_FEED_ID is not set in environment variables.');
return {
version: "https://jsonfeed.org/version/1.1",
title: "AI Base Feeds",
home_page_url: "https://www.aibase.com/",
description: "Aggregated AI Base feeds",
language: "zh-cn",
items: []
};
}
let publishedAfter = null;
for (let i = 0; i < fetchPages; i++) {
const userAgent = getRandomUserAgent();
const headers = {
'User-Agent': userAgent,
'Content-Type': 'application/json',
'accept': 'application/json',
'accept-language': 'zh-CN,zh;q=0.9',
'baggage': 'sentry-environment=stable,sentry-release=5251fa921ef6cbb6df0ac4271c41c2b4a0ce7c50,sentry-public_key=e5bccf7428aa4e881ed5cb713fdff181,sentry-trace_id=2da50ca5ad944cb794670097d876ada8,sentry-sampled=true,sentry-sample_rand=0.06211835167903246,sentry-sample_rate=1',
'origin': 'https://app.follow.is',
'priority': 'u=1, i',
'sec-ch-ua': '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
'sec-ch-ua-mobile': '?1',
'sec-ch-ua-platform': '"Android"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'x-app-name': 'Folo Web',
'x-app-version': '0.4.9',
};
// 直接使用传入的 foloCookie
if (foloCookie) {
headers['Cookie'] = foloCookie;
}
const body = {
feedId: feedId,
view: 1,
withContent: true,
};
if (publishedAfter) {
body.publishedAfter = publishedAfter;
}
try {
console.log(`Fetching AI Base data, page ${i + 1}...`);
const response = await fetch(env.FOLO_DATA_API, {
method: 'POST',
headers: headers,
body: JSON.stringify(body),
});
if (!response.ok) {
console.error(`Failed to fetch AI Base data, page ${i + 1}: ${response.statusText}`);
break;
}
const data = await response.json();
if (data && data.data && data.data.length > 0) {
const filteredItems = data.data.filter(entry => isDateWithinLastDays(entry.entries.publishedAt, filterDays));
allAibaseItems.push(...filteredItems.map(entry => ({
id: entry.entries.id,
url: entry.entries.url,
title: entry.entries.title,
content_html: entry.entries.content,
date_published: entry.entries.publishedAt,
authors: [{ name: entry.entries.author }],
source: `aibase`,
})));
publishedAfter = data.data[data.data.length - 1].entries.publishedAt;
} else {
console.log(`No more data for AI Base, page ${i + 1}.`);
break;
}
} catch (error) {
console.error(`Error fetching AI Base data, page ${i + 1}:`, error);
break;
}
// Random wait time between 0 and 5 seconds to avoid rate limiting
await sleep(Math.random() * 5000);
}
return {
version: "https://jsonfeed.org/version/1.1",
title: "AI Base Feeds",
home_page_url: "https://www.aibase.com/",
description: "Aggregated AI Base feeds",
language: "zh-cn",
items: allAibaseItems
};
},
transform: (rawData, sourceType) => { // Add sourceType
const unifiedNews = [];
if (rawData && Array.isArray(rawData.items)) {
rawData.items.forEach((item) => {
unifiedNews.push({
id: item.id,
type: sourceType, // Use sourceType here
url: item.url,
title: item.title,
description: stripHtml(item.content_html || ""),
published_date: item.date_published,
authors: item.authors ? item.authors.map(a => a.name).join(', ') : 'Unknown',
source: item.source || 'AI Base',
details: {
content_html: item.content_html || ""
}
});
});
}
return unifiedNews;
},
generateHtml: (item) => {
return `
<strong>${escapeHtml(item.title)}</strong><br>
<small>来源: ${escapeHtml(item.source || '未知')} | 发布日期: ${formatDateToChineseWithTime(item.published_date)}</small>
<div class="content-html">${item.details.content_html || '无内容。'}</div>
<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">阅读更多</a>
`;
}
};
export default NewsDataSource;

View File

@@ -0,0 +1,113 @@
// src/dataSources/projects.js
import { fetchData, getISODate, removeMarkdownCodeBlock, formatDateToChineseWithTime, escapeHtml} from '../helpers.js';
import { callChatAPI } from '../chatapi.js';
const ProjectsDataSource = {
fetch: async (env) => {
console.log(`Fetching projects from: ${env.PROJECTS_API_URL}`);
let projects;
try {
projects = await fetchData(env.PROJECTS_API_URL);
} catch (error) {
console.error("Error fetching projects data:", error.message);
return { error: "Failed to fetch projects data", details: error.message, items: [] };
}
if (!Array.isArray(projects)) {
console.error("Projects data is not an array:", projects);
return { error: "Invalid projects data format", received: projects, items: [] };
}
if (projects.length === 0) {
console.log("No projects fetched from API.");
return { items: [] };
}
if (!env.OPEN_TRANSLATE === "true") {
console.warn("Skipping paper translations.");
return projects.map(p => ({ ...p, description_zh: p.description || "" }));
}
const descriptionsToTranslate = projects
.map(p => p.description || "")
.filter(desc => typeof desc === 'string');
const nonEmptyDescriptions = descriptionsToTranslate.filter(d => d.trim() !== "");
if (nonEmptyDescriptions.length === 0) {
console.log("No non-empty project descriptions to translate.");
return projects.map(p => ({ ...p, description_zh: p.description || "" }));
}
const promptText = `Translate the following English project descriptions to Chinese.
Provide the translations as a JSON array of strings, in the exact same order as the input.
Each string in the output array must correspond to the string at the same index in the input array.
If an input description is an empty string, the corresponding translated string in the output array should also be an empty string.
Input Descriptions (JSON array of strings):
${JSON.stringify(descriptionsToTranslate)}
Respond ONLY with the JSON array of Chinese translations. Do not include any other text or explanations.
JSON Array of Chinese Translations:`;
let translatedTexts = [];
try {
console.log(`Requesting translation for ${descriptionsToTranslate.length} project descriptions.`);
const chatResponse = await callChatAPI(env, promptText);
const parsedTranslations = JSON.parse(removeMarkdownCodeBlock(chatResponse)); // Assuming direct JSON array response
if (parsedTranslations && Array.isArray(parsedTranslations) && parsedTranslations.length === descriptionsToTranslate.length) {
translatedTexts = parsedTranslations;
} else {
console.warn(`Translation count mismatch or parsing error for project descriptions. Expected ${descriptionsToTranslate.length}, received ${parsedTranslations ? parsedTranslations.length : 'null'}. Falling back.`);
translatedTexts = descriptionsToTranslate.map(() => null);
}
} catch (translationError) {
console.error("Failed to translate project descriptions in batch:", translationError.message);
translatedTexts = descriptionsToTranslate.map(() => null);
}
return projects.map((project, index) => {
const translated = translatedTexts[index];
return {
...project,
description_zh: (typeof translated === 'string') ? translated : (project.description || "")
};
});
},
transform: (projectsData, sourceType) => {
const unifiedProjects = [];
const now = getISODate();
if (Array.isArray(projectsData)) {
projectsData.forEach((project, index) => {
unifiedProjects.push({
id: index + 1, // Use project.url as ID if available
type: sourceType,
url: project.url,
title: project.name,
description: project.description_zh || project.description || "",
published_date: now, // Projects don't have a published date, use current date
authors: project.owner ? [project.owner] : [],
source: "GitHub Trending",
details: {
owner: project.owner,
name: project.name,
language: project.language,
languageColor: project.languageColor,
totalStars: project.totalStars,
forks: project.forks,
starsToday: project.starsToday,
builtBy: project.builtBy || []
}
});
});
}
return unifiedProjects;
},
generateHtml: (item) => {
return `
<strong>${escapeHtml(item.title)}</strong> (所有者: ${escapeHtml(item.details.owner)})<br>
<small>星标: ${escapeHtml(item.details.totalStars)} (今日: ${escapeHtml(item.details.starsToday)}) | 语言: ${escapeHtml(item.details.language || 'N/A')}</small>
描述: ${escapeHtml(item.description) || 'N/A'}<br>
<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">在 GitHub 上查看</a>
`;
}
};
export default ProjectsDataSource;

View File

@@ -0,0 +1,204 @@
// src/dataSources/huggingface-papers.js
import { getRandomUserAgent, sleep, isDateWithinLastDays, stripHtml, removeMarkdownCodeBlock, formatDateToChineseWithTime, escapeHtml} from '../helpers.js';
import { callChatAPI } from '../chatapi.js';
const PapersDataSource = {
fetch: async (env, foloCookie) => {
const feedId = env.HGPAPERS_FEED_ID;
const fetchPages = parseInt(env.HGPAPERS_FETCH_PAGES || '3', 10);
const allPapersItems = [];
const filterDays = parseInt(env.FOLO_FILTER_DAYS || '3', 10);
if (!feedId) {
console.error('HGPAPERS_FEED_ID is not set in environment variables.');
return {
version: "https://jsonfeed.org/version/1.1",
title: "Huggingface Daily Papers Feeds",
home_page_url: "https://huggingface.co/papers",
description: "Aggregated Huggingface Daily Papers feeds",
language: "zh-cn",
items: []
};
}
let publishedAfter = null;
for (let i = 0; i < fetchPages; i++) {
const userAgent = getRandomUserAgent();
const headers = {
'User-Agent': userAgent,
'Content-Type': 'application/json',
'accept': 'application/json',
'accept-language': 'zh-CN,zh;q=0.9',
'baggage': 'sentry-environment=stable,sentry-release=5251fa921ef6cbb6df0ac4271c41c2b4a0ce7c50,sentry-public_key=e5bccf7428aa4e881ed5cb713fdff181,sentry-trace_id=2da50ca5ad944cb794670097d876ada8,sentry-sampled=true,sentry-sample_rand=0.06211835167903246,sentry-sample_rate=1',
'origin': 'https://app.follow.is',
'priority': 'u=1, i',
'sec-ch-ua': '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
'sec-ch-ua-mobile': '?1',
'sec-ch-ua-platform': '"Android"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'x-app-name': 'Folo Web',
'x-app-version': '0.4.9',
};
// 直接使用传入的 foloCookie
if (foloCookie) {
headers['Cookie'] = foloCookie;
}
const body = {
feedId: feedId,
view: 1,
withContent: true,
};
if (publishedAfter) {
body.publishedAfter = publishedAfter;
}
try {
console.log(`Fetching Huggingface Papers data, page ${i + 1}...`);
const response = await fetch(env.FOLO_DATA_API, {
method: 'POST',
headers: headers,
body: JSON.stringify(body),
});
if (!response.ok) {
console.error(`Failed to fetch Huggingface Papers data, page ${i + 1}: ${response.statusText}`);
break;
}
const data = await response.json();
if (data && data.data && data.data.length > 0) {
const filteredItems = data.data.filter(entry => isDateWithinLastDays(entry.entries.publishedAt, filterDays));
allPapersItems.push(...filteredItems.map(entry => ({
id: entry.entries.id,
url: entry.entries.url,
title: entry.entries.title,
content_html: entry.entries.content,
date_published: entry.entries.publishedAt,
authors: [{ name: entry.entries.author }],
source: `huggingface-papers`,
})));
publishedAfter = data.data[data.data.length - 1].entries.publishedAt;
} else {
console.log(`No more data for Huggingface Papers, page ${i + 1}.`);
break;
}
} catch (error) {
console.error(`Error fetching Huggingface Papers data, page ${i + 1}:`, error);
break;
}
// Random wait time between 0 and 5 seconds to avoid rate limiting
await sleep(Math.random() * 5000);
}
const papersData = {
version: "https://jsonfeed.org/version/1.1",
title: "Huggingface Daily Papers Feeds",
home_page_url: "https://huggingface.co/papers",
description: "Aggregated Huggingface Daily Papers feeds",
language: "zh-cn",
items: allPapersItems
};
if (papersData.items.length === 0) {
console.log("No hgpapers found for today or after filtering.");
return papersData;
}
if (!env.OPEN_TRANSLATE === "true") {
console.warn("Skipping hgpapers translations.");
papersData.items = papersData.items.map(item => ({
...item,
title_zh: item.title || "",
content_html_zh: item.content_html || ""
}));
return papersData;
}
const itemsToTranslate = papersData.items.map((item, index) => ({
id: index,
original_title: item.title || ""
}));
const hasContentToTranslate = itemsToTranslate.some(item => item.original_title.trim() !== "");
if (!hasContentToTranslate) {
console.log("No non-empty hgpapers titles to translate for today's papers.");
papersData.items = papersData.items.map(item => ({ ...item, title_zh: item.title || "", content_html_zh: item.content_html || "" }));
return papersData;
}
const promptText = `You will be given a JSON array of paper data objects. Each object has an "id" and "original_title".
Translate "original_title" into Chinese.
Return a JSON array of objects. Each output object MUST have:
- "id": The same id from the input.
- "title_zh": Chinese translation of "original_title". Empty if original is empty.
Input: ${JSON.stringify(itemsToTranslate)}
Respond ONLY with the JSON array.`;
let translatedItemsMap = new Map();
try {
console.log(`Requesting translation for ${itemsToTranslate.length} hgpapers titles for today.`);
const chatResponse = await callChatAPI(env, promptText);
const parsedTranslations = JSON.parse(removeMarkdownCodeBlock(chatResponse)); // Assuming direct JSON array response
if (parsedTranslations) {
parsedTranslations.forEach(translatedItem => {
if (translatedItem && typeof translatedItem.id === 'number' &&
typeof translatedItem.title_zh === 'string') {
translatedItemsMap.set(translatedItem.id, translatedItem);
}
});
}
} catch (translationError) {
console.error("Failed to translate hgpapers titles in batch:", translationError.message);
}
papersData.items = papersData.items.map((originalItem, index) => {
const translatedData = translatedItemsMap.get(index);
return {
...originalItem,
title_zh: translatedData ? translatedData.title_zh : (originalItem.title || "")
};
});
return papersData;
},
transform: (papersData,sourceType) => {
const unifiedPapers = [];
if (papersData && Array.isArray(papersData.items)) {
papersData.items.forEach((item, index) => {
unifiedPapers.push({
id: item.id, // Use item.id from Folo data
type: sourceType,
url: item.url,
title: item.title_zh || item.title,
description: stripHtml(item.content_html || ""),
published_date: item.date_published,
authors: typeof item.authors === 'string' ? item.authors.split(',').map(s => s.trim()) : (item.authors ? item.authors.map(a => a.name) : []),
source: item.source || "Huggingface Papers", // Use existing source or default
details: {
content_html: item.content_html || ""
}
});
});
}
return unifiedPapers;
},
generateHtml: (item) => {
return `
<strong>${escapeHtml(item.title)}</strong><br>
<small>来源: ${escapeHtml(item.source || '未知')} | 发布日期: ${formatDateToChineseWithTime(item.published_date)}</small>
<div class="content-html">
${item.details.content_html || '无内容。'}<hr>
</div>
<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">在 ArXiv/来源 阅读</a>
`;
}
};
export default PapersDataSource;

138
src/dataSources/twitter.js Normal file
View File

@@ -0,0 +1,138 @@
import { getRandomUserAgent, sleep, isDateWithinLastDays, stripHtml, formatDateToChineseWithTime, escapeHtml} from '../helpers';
const TwitterDataSource = {
async fetch(env, foloCookie) {
const listId = env.TWITTER_LIST_ID;
const fetchPages = parseInt(env.TWITTER_FETCH_PAGES || '3', 10);
const allTwitterItems = [];
const filterDays = parseInt(env.FOLO_FILTER_DAYS || '3', 10);
if (!listId) {
console.error('TWITTER_LIST_ID is not set in environment variables.');
return {
version: "https://jsonfeed.org/version/1.1",
title: "Twitter Feeds",
home_page_url: "https://x.com/",
description: "Aggregated Twitter feeds from various users",
language: "zh-cn",
items: []
};
}
let publishedAfter = null;
for (let i = 0; i < fetchPages; i++) {
const userAgent = getRandomUserAgent();
const headers = {
'User-Agent': userAgent,
'Content-Type': 'application/json',
'accept': 'application/json',
'accept-language': 'zh-CN,zh;q=0.9',
'baggage': 'sentry-environment=stable,sentry-release=5251fa921ef6cbb6df0ac4271c41c2b4a0ce7c50,sentry-public_key=e5bccf7428aa4e881ed5cb713fdff181,sentry-trace_id=2da50ca5ad944cb794670097d876ada8,sentry-sampled=true,sentry-sample_rand=0.06211835167903246,sentry-sample_rate=1',
'origin': 'https://app.follow.is',
'priority': 'u=1, i',
'sec-ch-ua': '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
'sec-ch-ua-mobile': '?1',
'sec-ch-ua-platform': '"Android"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'x-app-name': 'Folo Web',
'x-app-version': '0.4.9',
};
// 直接使用传入的 foloCookie
if (foloCookie) {
headers['Cookie'] = foloCookie;
}
const body = {
listId: listId,
view: 1,
withContent: true,
};
if (publishedAfter) {
body.publishedAfter = publishedAfter;
}
try {
console.log(`Fetching Twitter data, page ${i + 1}...`);
const response = await fetch(env.FOLO_DATA_API, {
method: 'POST',
headers: headers,
body: JSON.stringify(body),
});
if (!response.ok) {
console.error(`Failed to fetch Twitter data, page ${i + 1}: ${response.statusText}`);
break;
}
const data = await response.json();
if (data && data.data && data.data.length > 0) {
const filteredItems = data.data.filter(entry => isDateWithinLastDays(entry.entries.publishedAt, filterDays));
allTwitterItems.push(...filteredItems.map(entry => ({
id: entry.entries.id,
url: entry.entries.url,
title: entry.entries.title,
content_html: entry.entries.content,
date_published: entry.entries.publishedAt,
authors: [{ name: entry.entries.author }],
source: entry.feeds.title && entry.feeds.title.includes('即刻圈子') ? `${entry.feeds.title} - ${entry.entries.author}` : `twitter-${entry.entries.author}`,
})));
publishedAfter = data.data[data.data.length - 1].entries.publishedAt;
} else {
console.log(`No more data for Twitter, page ${i + 1}.`);
break;
}
} catch (error) {
console.error(`Error fetching Twitter data, page ${i + 1}:`, error);
break;
}
// Random wait time between 0 and 5 seconds to avoid rate limiting
await sleep(Math.random() * 5000);
}
return {
version: "https://jsonfeed.org/version/1.1",
title: "Twitter Feeds",
home_page_url: "https://x.com/",
description: "Aggregated Twitter feeds from various users",
language: "zh-cn",
items: allTwitterItems
};
},
transform(rawData, sourceType) {
if (!rawData || !rawData.items) {
return [];
}
return rawData.items.map(item => ({
id: item.id,
type: sourceType,
url: item.url,
title: item.title,
description: stripHtml(item.content_html || ""),
published_date: item.date_published,
authors: item.authors ? item.authors.map(author => author.name).join(', ') : 'Unknown',
source: item.source || 'twitter', // Use existing source or default
details: {
content_html: item.content_html || ""
}
}));
},
generateHtml: (item) => {
return `
<strong>${escapeHtml(item.title)}</strong><br>
<small>来源: ${escapeHtml(item.source || '未知')} | 发布日期: ${formatDateToChineseWithTime(item.published_date)}</small>
<div class="content-html">
${item.details.content_html || '无内容。'}
</div>
<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">查看推文</a>
`;
}
};
export default TwitterDataSource;

137
src/dataSources/xiaohu.js Normal file
View File

@@ -0,0 +1,137 @@
import { getRandomUserAgent, sleep, isDateWithinLastDays, stripHtml, formatDateToChineseWithTime, escapeHtml } from '../helpers.js';
const XiaohuDataSource = {
fetch: async (env, foloCookie) => {
const feedId = env.XIAOHU_FEED_ID;
const fetchPages = parseInt(env.XIAOHU_FETCH_PAGES || '3', 10);
const allXiaohuItems = [];
const filterDays = parseInt(env.FOLO_FILTER_DAYS || '3', 10);
if (!feedId) {
console.error('XIAOHU_FEED_ID is not set in environment variables.');
return {
version: "https://jsonfeed.org/version/1.1",
title: "Xiaohu.AI Daily Feeds",
home_page_url: "https://www.xiaohu.ai",
description: "Aggregated Xiaohu.AI Daily feeds",
language: "zh-cn",
items: []
};
}
let publishedAfter = null;
for (let i = 0; i < fetchPages; i++) {
const userAgent = getRandomUserAgent();
const headers = {
'User-Agent': userAgent,
'Content-Type': 'application/json',
'accept': 'application/json',
'accept-language': 'zh-CN,zh;q=0.9',
'baggage': 'sentry-environment=stable,sentry-release=5251fa921ef6cbb6df0ac4271c41c2b4a0ce7c50,sentry-public_key=e5bccf7428aa4e881ed5cb713fdff181,sentry-trace_id=2da50ca5ad944cb794670097d876ada8,sentry-sampled=true,sentry-sample_rand=0.06211835167903246,sentry-sample_rate=1',
'origin': 'https://app.follow.is',
'priority': 'u=1, i',
'sec-ch-ua': '"Google Chrome";v="135", "Not-A.Brand";v="8", "Chromium";v="135"',
'sec-ch-ua-mobile': '?1',
'sec-ch-ua-platform': '"Android"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'x-app-name': 'Folo Web',
'x-app-version': '0.4.9',
};
// 直接使用传入的 foloCookie
if (foloCookie) {
headers['Cookie'] = foloCookie;
}
const body = {
feedId: feedId,
view: 1,
withContent: true,
};
if (publishedAfter) {
body.publishedAfter = publishedAfter;
}
try {
console.log(`Fetching Xiaohu.AI data, page ${i + 1}...`);
const response = await fetch(env.FOLO_DATA_API, {
method: 'POST',
headers: headers,
body: JSON.stringify(body),
});
if (!response.ok) {
console.error(`Failed to fetch Xiaohu.AI data, page ${i + 1}: ${response.statusText}`);
break;
}
const data = await response.json();
if (data && data.data && data.data.length > 0) {
const filteredItems = data.data.filter(entry => isDateWithinLastDays(entry.entries.publishedAt, filterDays));
allXiaohuItems.push(...filteredItems.map(entry => ({
id: entry.entries.id,
url: entry.entries.url,
title: entry.entries.title,
content_html: entry.entries.content,
date_published: entry.entries.publishedAt,
authors: [{ name: entry.entries.author }],
source: `xiaohu`,
})));
publishedAfter = data.data[data.data.length - 1].entries.publishedAt;
} else {
console.log(`No more data for Xiaohu.AI, page ${i + 1}.`);
break;
}
} catch (error) {
console.error(`Error fetching Xiaohu.AI data, page ${i + 1}:`, error);
break;
}
// Random wait time between 0 and 5 seconds to avoid rate limiting
await sleep(Math.random() * 5000);
}
return {
version: "https://jsonfeed.org/version/1.1",
title: "Xiaohu.AI Daily Feeds",
home_page_url: "https://www.xiaohu.ai",
description: "Aggregated Xiaohu.AI Daily feeds",
language: "zh-cn",
items: allXiaohuItems
};
},
transform: (rawData, sourceType) => {
const unifiedNews = [];
if (rawData && Array.isArray(rawData.items)) {
rawData.items.forEach((item) => {
unifiedNews.push({
id: item.id,
type: sourceType,
url: item.url,
title: item.title,
description: stripHtml(item.content_html || ""),
published_date: item.date_published,
authors: item.authors ? item.authors.map(a => a.name).join(', ') : 'Unknown',
source: item.source || 'Xiaohu.AI',
details: {
content_html: item.content_html || ""
}
});
});
}
return unifiedNews;
},
generateHtml: (item) => {
return `
<strong>${escapeHtml(item.title)}</strong><br>
<small>来源: ${escapeHtml(item.source || '未知')} | 发布日期: ${formatDateToChineseWithTime(item.published_date)}</small>
<div class="content-html">${item.details.content_html || '无内容。'}</div>
<a href="${escapeHtml(item.url)}" target="_blank" rel="noopener noreferrer">阅读更多</a>
`;
}
};
export default XiaohuDataSource;

90
src/github.js Normal file
View File

@@ -0,0 +1,90 @@
// src/github.js
/**
* Generic wrapper for calling the GitHub API.
*/
export async function callGitHubApi(env, path, method = 'GET', body = null) {
const GITHUB_TOKEN = env.GITHUB_TOKEN;
const GITHUB_REPO_OWNER = env.GITHUB_REPO_OWNER;
const GITHUB_REPO_NAME = env.GITHUB_REPO_NAME;
if (!GITHUB_TOKEN || !GITHUB_REPO_OWNER || !GITHUB_REPO_NAME) {
console.error("GitHub environment variables (GITHUB_TOKEN, GITHUB_REPO_OWNER, GITHUB_REPO_NAME) are not configured.");
throw new Error("GitHub API configuration is missing in environment variables.");
}
const url = `https://api.github.com/repos/${GITHUB_REPO_OWNER}/${GITHUB_REPO_NAME}${path}`;
const headers = {
'Authorization': `Bearer ${GITHUB_TOKEN}`,
'Accept': 'application/vnd.github.v3+json',
'User-Agent': 'Cloudflare-Worker-ContentBot/1.0'
};
if (method !== 'GET' && method !== 'DELETE' && body) {
headers['Content-Type'] = 'application/json';
}
const response = await fetch(url, {
method: method,
headers: headers,
body: body ? JSON.stringify(body) : null
});
if (!response.ok) {
const errorText = await response.text();
let errorJsonMessage = errorText;
try {
const errorJson = JSON.parse(errorText);
if (errorJson && errorJson.message) {
errorJsonMessage = errorJson.message;
if (errorJson.errors) {
errorJsonMessage += ` Details: ${JSON.stringify(errorJson.errors)}`;
}
}
} catch (e) { /* Ignore */ }
console.error(`GitHub API Error: ${response.status} ${response.statusText} for ${method} ${url}. Message: ${errorJsonMessage}`);
throw new Error(`GitHub API request to ${path} failed: ${response.status} - ${errorJsonMessage}`);
}
if (response.status === 204 || response.headers.get("content-length") === "0") {
return null;
}
return response.json();
}
/**
* Gets the SHA of a file from GitHub.
*/
export async function getGitHubFileSha(env, filePath) {
const GITHUB_BRANCH = env.GITHUB_BRANCH || 'main';
try {
const data = await callGitHubApi(env, `/contents/${filePath}?ref=${GITHUB_BRANCH}`);
return data && data.sha ? data.sha : null;
} catch (error) {
if (error.message.includes("404") || error.message.toLowerCase().includes("not found")) {
console.log(`File not found on GitHub: ${filePath} (branch: ${GITHUB_BRANCH})`);
return null;
}
console.error(`Error getting SHA for ${filePath}:`, error);
throw error;
}
}
/**
* Creates a new file or updates an existing one on GitHub.
*/
export async function createOrUpdateGitHubFile(env, filePath, content, commitMessage, existingSha = null) {
const GITHUB_BRANCH = env.GITHUB_BRANCH || 'main';
const base64Content = btoa(String.fromCharCode(...new TextEncoder().encode(content)));
const payload = {
message: commitMessage,
content: base64Content,
branch: GITHUB_BRANCH
};
if (existingSha) {
payload.sha = existingSha;
}
return callGitHubApi(env, `/contents/${filePath}`, 'PUT', payload);
}

View File

@@ -0,0 +1,47 @@
// src/handlers/commitToGitHub.js
import { getISODate, formatMarkdownText } from '../helpers.js';
import { getGitHubFileSha, createOrUpdateGitHubFile } from '../github.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' } });
}
try {
const formData = await request.formData();
const dateStr = formData.get('date') || getISODate();
const dailyMd = formData.get('daily_summary_markdown');
const podcastMd = formData.get('podcast_script_markdown');
const filesToCommit = [];
if (dailyMd) {
filesToCommit.push({ path: `daily/${dateStr}.md`, content: formatMarkdownText(dailyMd), description: "Daily Summary File" });
}
if (podcastMd) {
filesToCommit.push({ path: `podcast/${dateStr}.md`, content: podcastMd, description: "Podcast Script File" });
}
if (filesToCommit.length === 0) {
throw new Error("No markdown content provided for GitHub commit.");
}
const results = [];
for (const file of filesToCommit) {
try {
const existingSha = await getGitHubFileSha(env, file.path);
const commitMessage = `${existingSha ? 'Update' : 'Create'} ${file.description.toLowerCase()} for ${dateStr}`;
await createOrUpdateGitHubFile(env, file.path, file.content, commitMessage, existingSha);
results.push({ file: file.path, status: 'Success', message: `Successfully ${existingSha ? 'updated' : 'created'}.` });
console.log(`GitHub commit success for ${file.path}`);
} catch (err) {
console.error(`Failed to commit ${file.path} to GitHub:`, err);
results.push({ file: file.path, status: 'Failed', message: err.message });
}
}
return new Response(JSON.stringify({ status: 'success', date: dateStr, results: results }), { headers: { 'Content-Type': 'application/json; charset=utf-8' } });
} catch (error) {
console.error("Error in /commitToGitHub:", error);
return new Response(JSON.stringify({ status: 'error', message: error.message }), { status: 500, headers: { 'Content-Type': 'application/json; charset=utf-8' } });
}
}

View File

@@ -0,0 +1,294 @@
// src/handlers/genAIContent.js
import { getISODate, escapeHtml, stripHtml, removeMarkdownCodeBlock, formatDateToChinese, convertEnglishQuotesToChinese} from '../helpers.js';
import { getFromKV } from '../kv.js';
import { callChatAPIStream } from '../chatapi.js';
import { generateGenAiPageHtml } from '../htmlGenerators.js';
import { dataSources } from '../dataFetchers.js'; // Import dataSources
import { getSystemPromptSummarizationStepOne } from '../prompt/summarizationPromptStepOne.js';
import { getSystemPromptSummarizationStepTwo } from '../prompt/summarizationPromptStepTwo.js';
import { getSystemPromptPodcastFormatting } from '../prompt/podcastFormattingPrompt.js';
import { getSystemPromptDailyAnalysis } from '../prompt/dailyAnalysisPrompt.js'; // Import new prompt
export async function handleGenAIPodcastScript(request, env) {
let dateStr;
let selectedItemsParams = [];
let formData;
let outputOfCall1 = null; // This will be the summarized content from Call 1
let userPromptPodcastFormattingData = null;
let fullPromptForCall2_System = null;
let fullPromptForCall2_User = null;
let finalAiResponse = null;
try {
formData = await request.formData();
dateStr = formData.get('date');
selectedItemsParams = formData.getAll('selectedItems');
outputOfCall1 = formData.get('summarizedContent'); // Get summarized content from form data
if (!outputOfCall1) {
const errorHtml = generateGenAiPageHtml('生成AI播客脚本出错', '<p><strong>Summarized content is missing.</strong> Please go back and generate AI content first.</p>', dateStr, true, null);
return new Response(errorHtml, { status: 400, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
userPromptPodcastFormattingData = outputOfCall1;
fullPromptForCall2_System = getSystemPromptPodcastFormatting(env);
fullPromptForCall2_User = userPromptPodcastFormattingData;
console.log("Call 2 to Chat (Podcast Formatting): User prompt length:", userPromptPodcastFormattingData.length);
try {
let podcastChunks = [];
for await (const chunk of callChatAPIStream(env, userPromptPodcastFormattingData, fullPromptForCall2_System)) {
podcastChunks.push(chunk);
}
finalAiResponse = podcastChunks.join('');
if (!finalAiResponse || finalAiResponse.trim() === "") throw new Error("Chat podcast formatting call returned empty content.");
finalAiResponse = removeMarkdownCodeBlock(finalAiResponse); // Clean the output
console.log("Call 2 (Podcast Formatting) successful. Final output length:", finalAiResponse.length);
} catch (error) {
console.error("Error in Chat API Call 2 (Podcast Formatting):", error);
const errorHtml = generateGenAiPageHtml('生成AI播客脚本出错(播客文案)', `<p><strong>Failed during podcast formatting:</strong> ${escapeHtml(error.message)}</p>${error.stack ? `<pre>${escapeHtml(error.stack)}</pre>` : ''}`, dateStr, true, selectedItemsParams, null, null, fullPromptForCall2_System, fullPromptForCall2_User);
return new Response(errorHtml, { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
let promptsMarkdownContent = `# Prompts for ${dateStr}\n\n`;
promptsMarkdownContent += `## Call 2: Podcast Formatting\n\n`;
if (fullPromptForCall2_System) promptsMarkdownContent += `### System Instruction\n\`\`\`\n${fullPromptForCall2_System}\n\`\`\`\n\n`;
if (fullPromptForCall2_User) promptsMarkdownContent += `### User Input (Output of Call 1)\n\`\`\`\n${fullPromptForCall2_User}\n\`\`\`\n\n`;
let podcastScriptMarkdownContent = `# ${env.PODCAST_TITLE} ${formatDateToChinese(dateStr)}\n\n${removeMarkdownCodeBlock(finalAiResponse)}`;
const successHtml = generateGenAiPageHtml(
'AI播客脚本',
escapeHtml(finalAiResponse),
dateStr, false, selectedItemsParams,
null, null, // No Call 1 prompts for this page
fullPromptForCall2_System, fullPromptForCall2_User,
convertEnglishQuotesToChinese(removeMarkdownCodeBlock(promptsMarkdownContent)),
outputOfCall1, // No daily summary for this page
convertEnglishQuotesToChinese(podcastScriptMarkdownContent)
);
return new Response(successHtml, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
} catch (error) {
console.error("Error in /genAIPodcastScript (outer try-catch):", error);
const pageDateForError = dateStr || getISODate();
const itemsForActionOnError = Array.isArray(selectedItemsParams) ? selectedItemsParams : [];
const errorHtml = generateGenAiPageHtml('生成AI播客脚本出错', `<p><strong>Unexpected error:</strong> ${escapeHtml(error.message)}</p>${error.stack ? `<pre>${escapeHtml(error.stack)}</pre>` : ''}`, pageDateForError, true, itemsForActionOnError, null, null, fullPromptForCall2_System, fullPromptForCall2_User);
return new Response(errorHtml, { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
}
export async function handleGenAIContent(request, env) {
let dateStr;
let selectedItemsParams = [];
let formData;
let userPromptSummarizationData = null;
let fullPromptForCall1_System = null;
let fullPromptForCall1_User = null;
let outputOfCall1 = null;
try {
formData = await request.formData();
const dateParam = formData.get('date');
dateStr = dateParam ? dateParam : getISODate();
selectedItemsParams = formData.getAll('selectedItems');
if (selectedItemsParams.length === 0) {
const errorHtml = generateGenAiPageHtml('生成AI日报出错未选生成条目', '<p><strong>No items were selected.</strong> Please go back and select at least one item.</p>', dateStr, true, null);
return new Response(errorHtml, { status: 400, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
console.log(`Generating AI content for ${selectedItemsParams.length} selected item references from date ${dateStr}`);
const allFetchedData = {};
const fetchPromises = [];
for (const sourceType in dataSources) {
if (Object.hasOwnProperty.call(dataSources, sourceType)) {
fetchPromises.push(
getFromKV(env.DATA_KV, `${dateStr}-${sourceType}`).then(data => {
allFetchedData[sourceType] = data || [];
})
);
}
}
await Promise.allSettled(fetchPromises);
const selectedContentItems = [];
let validItemsProcessedCount = 0;
for (const selection of selectedItemsParams) {
const [type, idStr] = selection.split(':');
const itemsOfType = allFetchedData[type];
const item = itemsOfType ? itemsOfType.find(dataItem => String(dataItem.id) === idStr) : null;
if (item) {
let itemText = "";
// Dynamically generate itemText based on item.type
// Add new data sources
switch (item.type) {
case 'news':
itemText = `News Title: ${item.title}\nPublished: ${item.published_date}\nContent Summary: ${stripHtml(item.details.content_html)}`;
break;
case 'project':
itemText = `Project Name: ${item.title}\nPublished: ${item.published_date}\nUrl: ${item.url}\nDescription: ${item.description}\nStars: ${item.details.totalStars}`;
break;
case 'paper':
itemText = `Papers Title: ${item.title}\nPublished: ${item.published_date}\nUrl: ${item.url}\nAbstract/Content Summary: ${stripHtml(item.details.content_html)}`;
break;
case 'socialMedia':
itemText = `socialMedia Post by ${item.authors}Published: ${item.published_date}\nUrl: ${item.url}\nContent: ${stripHtml(item.details.content_html)}`;
break;
default:
// Fallback for unknown types or if more specific details are not available
itemText = `Type: ${item.type}\nTitle: ${item.title || 'N/A'}\nDescription: ${item.description || 'N/A'}\nURL: ${item.url || 'N/A'}`;
if (item.published_date) itemText += `\nPublished: ${item.published_date}`;
if (item.source) itemText += `\nSource: ${item.source}`;
if (item.details && item.details.content_html) itemText += `\nContent: ${stripHtml(item.details.content_html)}`;
break;
}
if (itemText) {
selectedContentItems.push(itemText);
validItemsProcessedCount++;
}
} else {
console.warn(`Could not find item for selection: ${selection} on date ${dateStr}.`);
}
}
if (validItemsProcessedCount === 0) {
const errorHtml = generateGenAiPageHtml('生成AI日报出错可生成条目为空', '<p><strong>Selected items could not be retrieved or resulted in no content.</strong> Please check the data or try different selections.</p>', dateStr, true, selectedItemsParams);
return new Response(errorHtml, { status: 404, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
//提示词内不能有英文引号,否则会存储数据缺失。
fullPromptForCall1_System = getSystemPromptSummarizationStepOne();
fullPromptForCall1_User = selectedContentItems.join('\n\n---\n\n'); // Keep this for logging/error reporting if needed
console.log("Call 1 to Chat (Summarization): User prompt length:", fullPromptForCall1_User.length);
try {
const chunkSize = 3;
const summaryPromises = [];
for (let i = 0; i < selectedContentItems.length; i += chunkSize) {
const chunk = selectedContentItems.slice(i, i + chunkSize);
const chunkPrompt = chunk.join('\n\n---\n\n'); // Join selected items with the separator
summaryPromises.push((async () => {
let summarizedChunks = [];
for await (const streamChunk of callChatAPIStream(env, chunkPrompt, fullPromptForCall1_System)) {
summarizedChunks.push(streamChunk);
}
return summarizedChunks.join('');
})());
}
const allSummarizedResults = await Promise.all(summaryPromises);
outputOfCall1 = allSummarizedResults.join('\n\n'); // Join all summarized parts
if (!outputOfCall1 || outputOfCall1.trim() === "") throw new Error("Chat summarization call returned empty content.");
outputOfCall1 = removeMarkdownCodeBlock(outputOfCall1); // Clean the output
console.log("Call 1 (Summarization) successful. Output length:", outputOfCall1.length);
} catch (error) {
console.error("Error in Chat API Call 1 (Summarization):", error);
const errorHtml = generateGenAiPageHtml('生成AI日报出错(分段处理)', `<p><strong>Failed during summarization:</strong> ${escapeHtml(error.message)}</p>${error.stack ? `<pre>${escapeHtml(error.stack)}</pre>` : ''}`, dateStr, true, selectedItemsParams, fullPromptForCall1_System, fullPromptForCall1_User);
return new Response(errorHtml, { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
// Call 2: Process outputOfCall1
let outputOfCall2 = null;
let fullPromptForCall2_System = getSystemPromptSummarizationStepTwo(); // Re-using summarization prompt for now
let fullPromptForCall2_User = outputOfCall1; // Input for Call 2 is output of Call 1
console.log("Call 2 to Chat (Processing Call 1 Output): User prompt length:", fullPromptForCall2_User.length);
try {
let processedChunks = [];
for await (const chunk of callChatAPIStream(env, fullPromptForCall2_User, fullPromptForCall2_System)) {
processedChunks.push(chunk);
}
outputOfCall2 = processedChunks.join('');
if (!outputOfCall2 || outputOfCall2.trim() === "") throw new Error("Chat processing call returned empty content.");
outputOfCall2 = removeMarkdownCodeBlock(outputOfCall2); // Clean the output
console.log("Call 2 (Processing Call 1 Output) successful. Output length:", outputOfCall2.length);
} catch (error) {
console.error("Error in Chat API Call 2 (Processing Call 1 Output):", error);
const errorHtml = generateGenAiPageHtml('生成AI日报出错(格式化)', `<p><strong>Failed during processing of summarized content:</strong> ${escapeHtml(error.message)}</p>${error.stack ? `<pre>${escapeHtml(error.stack)}</pre>` : ''}`, dateStr, true, selectedItemsParams, fullPromptForCall1_System, fullPromptForCall1_User, fullPromptForCall2_System, fullPromptForCall2_User);
return new Response(errorHtml, { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
let promptsMarkdownContent = `# Prompts for ${dateStr}\n\n`;
promptsMarkdownContent += `## Call 1: Content Summarization\n\n`;
if (fullPromptForCall1_System) promptsMarkdownContent += `### System Instruction\n\`\`\`\n${fullPromptForCall1_System}\n\`\`\`\n\n`;
if (fullPromptForCall1_User) promptsMarkdownContent += `### User Input\n\`\`\`\n${fullPromptForCall1_User}\n\`\`\`\n\n`;
promptsMarkdownContent += `## Call 2: Summarized Content Format\n\n`;
if (fullPromptForCall2_System) promptsMarkdownContent += `### System Instruction\n\`\`\`\n${fullPromptForCall2_System}\n\`\`\`\n\n`;
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)}`;
const successHtml = generateGenAiPageHtml(
'AI日报', // Title for Call 1 page
escapeHtml(outputOfCall2),
dateStr, false, selectedItemsParams,
fullPromptForCall1_System, fullPromptForCall1_User,
null, null, // Pass Call 2 prompts
convertEnglishQuotesToChinese(removeMarkdownCodeBlock(promptsMarkdownContent)),
convertEnglishQuotesToChinese(dailySummaryMarkdownContent),
null, // No podcast script for this page
outputOfCall1 // Pass summarized content for the next step (original outputOfCall1)
);
return new Response(successHtml, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
} catch (error) {
console.error("Error in /genAIContent (outer try-catch):", error);
const pageDateForError = dateStr || getISODate();
const itemsForActionOnError = Array.isArray(selectedItemsParams) ? selectedItemsParams : [];
const errorHtml = generateGenAiPageHtml('生成AI日报出错', `<p><strong>Unexpected error:</strong> ${escapeHtml(error.message)}</p>${error.stack ? `<pre>${escapeHtml(error.stack)}</pre>` : ''}`, pageDateForError, true, itemsForActionOnError, fullPromptForCall1_System, fullPromptForCall1_User, fullPromptForCall2_System, fullPromptForCall2_User);
return new Response(errorHtml, { status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
}
export async function handleGenAIDailyAnalysis(request, env) {
let dateStr;
let userPromptDailyAnalysisData = '';
let fullPromptForDailyAnalysis_System = null;
let finalAiResponse = null;
try {
const requestBody = await request.json();
dateStr = requestBody.date || getISODate();
const summarizedContent = requestBody.summarizedContent; // Get summarized content from request body
if (!summarizedContent || !summarizedContent.trim()) {
return new Response('未提供摘要内容进行分析。', { status: 400, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
userPromptDailyAnalysisData = summarizedContent; // Use summarized content as user prompt
console.log(`Generating AI daily analysis for date: ${dateStr} using summarized content.`);
fullPromptForDailyAnalysis_System = getSystemPromptDailyAnalysis();
console.log("Call to Chat (Daily Analysis): User prompt length:", userPromptDailyAnalysisData.length);
try {
let analysisChunks = [];
for await (const chunk of callChatAPIStream(env, userPromptDailyAnalysisData, fullPromptForDailyAnalysis_System)) {
analysisChunks.push(chunk);
}
finalAiResponse = analysisChunks.join('');
if (!finalAiResponse || finalAiResponse.trim() === "") throw new Error("Chat daily analysis call returned empty content.");
finalAiResponse = removeMarkdownCodeBlock(finalAiResponse); // Clean the output
console.log("Daily Analysis successful. Final output length:", finalAiResponse.length);
} catch (error) {
console.error("Error in Chat API Call (Daily Analysis):", error);
return new Response(`AI 日报分析失败: ${escapeHtml(error.message)}`, { status: 500, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
return new Response(finalAiResponse, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
} catch (error) {
console.error("Error in /genAIDailyAnalysis (outer try-catch):", error);
return new Response(`服务器错误: ${escapeHtml(error.message)}`, { status: 500, headers: { 'Content-Type': 'text/plain; charset=utf-8' } });
}
}

View File

@@ -0,0 +1,36 @@
// src/handlers/getContent.js
import { getISODate } from '../helpers.js';
import { getFromKV } from '../kv.js';
import { dataSources } from '../dataFetchers.js'; // Import dataSources
export async function handleGetContent(request, env) {
const url = new URL(request.url);
const dateParam = url.searchParams.get('date');
const dateStr = dateParam ? dateParam : getISODate();
console.log(`Getting content for date: ${dateStr}`);
try {
const responseData = {
date: dateStr,
message: `Successfully retrieved data for ${dateStr}.`
};
const fetchPromises = [];
for (const sourceType in dataSources) {
if (Object.hasOwnProperty.call(dataSources, sourceType)) {
fetchPromises.push(
getFromKV(env.DATA_KV, `${dateStr}-${sourceType}`).then(data => {
responseData[sourceType] = data || [];
})
);
}
}
await Promise.allSettled(fetchPromises);
return new Response(JSON.stringify(responseData), { headers: { 'Content-Type': 'application/json' } });
} catch (error) {
console.error("Error in /getContent:", error);
return new Response(JSON.stringify({ success: false, message: "Failed to get content.", error: error.message, date: dateStr }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}

View File

@@ -0,0 +1,31 @@
// src/handlers/getContentHtml.js
import { getISODate, escapeHtml, setFetchDate } from '../helpers.js';
import { getFromKV } from '../kv.js';
import { generateContentSelectionPageHtml } from '../htmlGenerators.js';
export async function handleGetContentHtml(request, env, dataCategories) {
const url = new URL(request.url);
const dateParam = url.searchParams.get('date');
const dateStr = dateParam ? dateParam : getISODate();
setFetchDate(dateStr);
console.log(`Getting HTML content for date: ${dateStr}`);
try {
const allData = {};
// Dynamically fetch data for each category based on dataCategories
for (const category of dataCategories) {
allData[category.id] = await getFromKV(env.DATA_KV, `${dateStr}-${category.id}`) || [];
}
const html = generateContentSelectionPageHtml(env, dateStr, allData, dataCategories);
return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
} catch (error) {
console.error("Error in /getContentHtml:", error);
// Ensure escapeHtml is used for error messages displayed in HTML
return new Response(`<h1>Error generating HTML content</h1><p>${escapeHtml(error.message)}</p><pre>${escapeHtml(error.stack)}</pre>`, {
status: 500, headers: { 'Content-Type': 'text/html; charset=utf-8' }
});
}
}

78
src/handlers/writeData.js Normal file
View File

@@ -0,0 +1,78 @@
// src/handlers/writeData.js
import { getISODate, getFetchDate } from '../helpers.js';
import { fetchAllData, fetchDataByCategory, dataSources } from '../dataFetchers.js'; // 导入 fetchDataByCategory 和 dataSources
import { storeInKV } from '../kv.js';
export async function handleWriteData(request, env) {
const dateParam = getFetchDate();
const dateStr = dateParam ? dateParam : getISODate();
console.log(`Starting /writeData process for date: ${dateStr}`);
let category = null;
let foloCookie = null;
try {
// 尝试解析请求体,获取 category 参数
if (request.headers.get('Content-Type')?.includes('application/json')) {
const requestBody = await request.json();
category = requestBody.category;
foloCookie = requestBody.foloCookie; // 获取 foloCookie
}
console.log(`Starting /writeData process for category: ${category || 'all'} with foloCookie presence: ${!!foloCookie}`);
let dataToStore = {};
let fetchPromises = [];
let successMessage = '';
if (category) {
// 只抓取指定分类的数据
const fetchedData = await fetchDataByCategory(env, category, foloCookie); // 传递 foloCookie
dataToStore[category] = fetchedData;
fetchPromises.push(storeInKV(env.DATA_KV, `${dateStr}-${category}`, fetchedData));
successMessage = `Data for category '${category}' fetched and stored.`;
console.log(`Transformed ${category}: ${fetchedData.length} items.`);
} else {
// 抓取所有分类的数据 (现有逻辑)
const allUnifiedData = await fetchAllData(env, foloCookie); // 传递 foloCookie
for (const sourceType in dataSources) {
if (Object.hasOwnProperty.call(dataSources, sourceType)) {
dataToStore[sourceType] = allUnifiedData[sourceType] || [];
fetchPromises.push(storeInKV(env.DATA_KV, `${dateStr}-${sourceType}`, dataToStore[sourceType]));
console.log(`Transformed ${sourceType}: ${dataToStore[sourceType].length} items.`);
}
}
successMessage = `All data categories fetched and stored.`;
}
await Promise.all(fetchPromises);
const errors = []; // Placeholder for potential future error aggregation from fetchAllData or fetchDataByCategory
if (errors.length > 0) {
console.warn("/writeData completed with errors:", errors);
return new Response(JSON.stringify({
success: false,
message: `${successMessage} Some errors occurred.`,
errors: errors,
...Object.fromEntries(Object.entries(dataToStore).map(([key, value]) => [`${key}ItemCount`, value.length]))
}), {
status: 200, headers: { 'Content-Type': 'application/json' }
});
} else {
console.log("/writeData process completed successfully.");
return new Response(JSON.stringify({
success: true,
message: successMessage,
...Object.fromEntries(Object.entries(dataToStore).map(([key, value]) => [`${key}ItemCount`, value.length]))
}), {
headers: { 'Content-Type': 'application/json' }
});
}
} catch (error) {
console.error("Unhandled error in /writeData:", error);
return new Response(JSON.stringify({ success: false, message: "An unhandled error occurred during data processing.", error: error.message, details: error.stack }), {
status: 500, headers: { 'Content-Type': 'application/json' }
});
}
}

246
src/helpers.js Normal file
View File

@@ -0,0 +1,246 @@
// src/helpers.js
/**
* 全域參數,用於指定資料抓取的日期。
* 預設為當前日期,格式為 YYYY-MM-DD。
*/
export let fetchDate = getISODate();
export function setFetchDate(date) {
fetchDate = date;
}
export function getFetchDate() {
return fetchDate;
}
/**
* Gets the current date or a specified date in YYYY-MM-DD format.
* @param {Date} [dateObj] - Optional Date object. Defaults to current date.
* @returns {string} Date string in YYYY-MM-DD format.
*/
export function getISODate(dateObj = new Date()) {
const options = {
year: 'numeric',
month: '2-digit',
day: '2-digit',
timeZone: 'Asia/Shanghai'
};
// 使用 'en-CA' 語言環境,因為它通常會產生 YYYY-MM-DD 格式的日期字串
const dateString = dateObj.toLocaleDateString('en-CA', options);
return dateString;
}
/**
* Escapes HTML special characters in a string.
* @param {*} unsafe The input to escape. If not a string, it's converted. Null/undefined become empty string.
* @returns {string} The escaped string.
*/
export function escapeHtml(unsafe) {
if (unsafe === null || typeof unsafe === 'undefined') {
return '';
}
const str = String(unsafe);
const map = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '&#039;'
};
return str.replace(/[&<>"']/g, (m) => map[m]);
}
/**
* Generic fetch wrapper with JSON parsing and error handling.
* @param {string} url - The URL to fetch.
* @param {object} [options] - Fetch options.
* @returns {Promise<object>} The JSON response or text for non-JSON.
* @throws {Error} If the fetch fails or response is not ok.
*/
export async function fetchData(url, options = {}) {
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}, url: ${url}`);
}
return response.json();
}
/**
* Removes markdown code block fences (```json or ```) from a string.
* @param {string} text - The input string potentially containing markdown code fences.
* @returns {string} The string with markdown code fences removed.
*/
export function removeMarkdownCodeBlock(text) {
if (!text) return '';
let cleanedText = text.trim();
const jsonFence = "```json";
const genericFence = "```";
if (cleanedText.startsWith(jsonFence)) {
cleanedText = cleanedText.substring(jsonFence.length);
} else if (cleanedText.startsWith(genericFence)) {
cleanedText = cleanedText.substring(genericFence.length);
}
if (cleanedText.endsWith(genericFence)) {
cleanedText = cleanedText.substring(0, cleanedText.length - genericFence.length);
}
return cleanedText.trim();
}
/**
* Strips HTML tags from a string and normalizes whitespace.
* @param {string} html - The HTML string.
* @returns {string} The text content without HTML tags.
*/
export function stripHtml(html) {
if (!html) return "";
// 處理 img 標籤,保留其 src 和 alt 屬性
let processedHtml = html.replace(/<img[^>]*src="([^"]*)"[^>]*alt="([^"]*)"[^>]*>/gi, (match, src, alt) => {
return alt ? `[图片: ${alt} ${src}]` : `[图片: ${src}]`;
});
processedHtml = processedHtml.replace(/<img[^>]*src="([^"]*)"[^>]*>/gi, '[图片: $1]');
// 移除所有其他 HTML 標籤,並正規化空白
return processedHtml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
/**
* 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).
* @param {number} days - The number of days to look back (e.g., 3 for today and the past 2 days).
* @returns {boolean} True if the date is within the last 'days', false otherwise.
*/
/**
* Converts a date string to a Date object representing the time in Asia/Shanghai timezone.
* This is crucial for consistent date comparisons across different environments.
* @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) {
// Create a Date object from the ISO string.
const date = new Date(dateString);
// 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).
* @param {number} days - The number of days to look back (e.g., 3 for today and the past 2 days).
* @returns {boolean} True if the date is within the last 'days', false otherwise.
*/
export function isDateWithinLastDays(dateString, days) {
// Convert both dates to Shanghai time for consistent comparison
const itemDate = convertToShanghaiTime(dateString);
const today = new Date(fetchDate);
// Normalize today to the start of its day in Shanghai time
today.setHours(0, 0, 0, 0);
const diffTime = today.getTime() - itemDate.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays >= 0 && diffDays < days;
}
/**
* Formats an ISO date string to "YYYY年M月D日" format.
* @param {string} isoDateString - The date string in ISO format (e.g., "2025-05-30T08:24:52.000Z").
* @returns {string} Formatted date string (e.g., "2025年5月30日").
*/
export function formatDateToChinese(isoDateString) {
if (!isoDateString) return '';
const date = new Date(isoDateString);
const options = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
timeZone: 'Asia/Shanghai'
};
return new Intl.DateTimeFormat('zh-CN', options).format(date);
}
/**
* Formats an ISO date string to "YYYY年M月D日 HH:MM:SS" format.
* @param {string} isoDateString - The date string in ISO format (e.g., "2025-05-30T08:24:52.000Z").
* @returns {string} Formatted date string (e.g., "2025年5月30日 08:24:52").
*/
export function formatDateToChineseWithTime(isoDateString) {
if (!isoDateString) return '';
const date = new Date(isoDateString);
const options = {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false, // 使用24小时制
timeZone: 'Asia/Shanghai' // 指定东8时区
};
// 使用 'zh-CN' 语言环境以确保中文格式
return new Intl.DateTimeFormat('zh-CN', options).format(date);
}
/**
* Converts English double quotes (") to Chinese double quotes (“”).
* @param {string} text - The input string.
* @returns {string} The string with Chinese double quotes.
*/
export function convertEnglishQuotesToChinese(text) {
const str = String(text);
return str.replace(/"/g, '“');
}
export function formatMarkdownText(text) {
const str = String(text);
return str.replace(/“/g, '"');
}
/**
* Generates a random User-Agent string.
* @returns {string} A random User-Agent string.
*/
export function getRandomUserAgent() {
const userAgents = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 13_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:108.0) Gecko/20100101 Firefox/108.0",
"Mozilla/5.0 (X11; Linux x86_64; rv:108.0) Gecko/20100101 Firefox/108.0",
];
return userAgents[Math.floor(Math.random() * userAgents.length)];
}
/**
* Pauses execution for a specified number of milliseconds.
* @param {number} ms - The number of milliseconds to sleep.
* @returns {Promise<void>} A promise that resolves after the specified time.
*/
export function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}

499
src/htmlGenerators.js Normal file
View File

@@ -0,0 +1,499 @@
// src/htmlGenerators.js
import { escapeHtml, formatDateToChinese, convertEnglishQuotesToChinese} from './helpers.js';
import { dataSources } from './dataFetchers.js'; // Import dataSources
function generateHtmlListForContentPage(items, dateStr) {
let listHtml = '';
if (!Array.isArray(items) || items.length === 0) {
listHtml += `<p>此日期无可用数据。抓取/筛选过程可能没有为此日期生成任何结果。</p>`;
return listHtml;
}
listHtml += '<ul class="item-list">';
items.forEach((item, index) => {
let displayContent = '';
let itemId = item.id;
// Use the generateHtml method from the corresponding data source
const dataSourceConfig = dataSources[item.type];
// console.log("item.type:", item.type);
// console.log("dataSourceConfig:", dataSourceConfig);
if (dataSourceConfig && dataSourceConfig.sources && dataSourceConfig.sources.length > 0 && dataSourceConfig.sources[0].generateHtml) {
displayContent = dataSourceConfig.sources[0].generateHtml(item);
} else {
// Fallback for unknown types or if generateHtml is not defined
displayContent = `<strong>未知项目类型: ${escapeHtml(item.type)}</strong><br>${escapeHtml(item.title || item.description || JSON.stringify(item))}`;
}
listHtml += `<li class="item-card">
<label>
<input type="checkbox" name="selectedItems" value="${item.type}:${itemId}" class="item-checkbox">
<div class="item-content">${displayContent}</div>
</label>
</li>`;
});
listHtml += '</ul>';
return listHtml;
}
export function generateContentSelectionPageHtml(env, dateStr, allData, dataCategories) {
// Ensure allData is an object and dataCategories is an array
const data = allData || {};
const categories = Array.isArray(dataCategories) ? dataCategories : [];
// Generate tab buttons and content dynamically
const tabButtonsHtml = categories.map((category, index) => `
<div class="tab-buttons-wrapper">
<button type="button" class="tab-button ${index === 0 ? 'active' : ''}" onclick="openTab(event, '${category.id}-tab')" ondblclick="confirmFetchCategoryData(this,'${category.id}')">${escapeHtml(category.name)}</button>
</div>
`).join('');
const tabContentsHtml = categories.map((category, index) => `
<div id="${category.id}-tab" class="tab-content ${index === 0 ? 'active' : ''}">
${generateHtmlListForContentPage(data[category.id], dateStr)}
</div>
`).join('');
return `
<!DOCTYPE html>
<html lang="zh-Hans">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${formatDateToChinese(escapeHtml(dateStr))} ${env.FOLO_FILTER_DAYS}天内的数据</title>
<style>
:root { --primary-color: #007bff; --light-gray: #f8f9fa; --medium-gray: #e9ecef; --dark-gray: #343a40; --line-height-normal: 1.4; --font-size-small: 0.9rem;}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; background-color: var(--light-gray); color: var(--dark-gray); padding: 1rem; }
.container { max-width: 1200px; margin: 0 auto; background-color: #fff; padding: 1rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
.header-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem; }
h1 { font-size: 1.8rem; color: var(--dark-gray); margin-bottom: 0.5rem; }
.submit-button { background-color: var(--primary-color); color: white; border: none; padding: 0.6rem 1.2rem; font-size: 0.9rem; border-radius: 5px; cursor: pointer; transition: background-color 0.2s; white-space: nowrap; }
.submit-button:hover { background-color: #0056b3; }
.tab-navigation { display: flex; flex-wrap: wrap; margin-bottom: 1rem; border-bottom: 1px solid var(--medium-gray); }
.tab-buttons-wrapper { display: flex; align-items: center; margin-right: 1rem; margin-bottom: 0.5rem; }
.tab-button { background-color: transparent; border: none; border-bottom: 3px solid transparent; padding: 0.8rem 1rem; cursor: pointer; font-size: 1rem; color: #555; transition: color 0.2s, border-color 0.2s; }
.tab-button.active { color: var(--primary-color); border-bottom-color: var(--primary-color); font-weight: 600; }
.tab-button:hover { color: var(--primary-color); }
.tab-content { display: none; animation: fadeIn 0.5s; }
.tab-content.active { display: block; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.item-list { list-style-type: none; counter-reset: item-counter; padding-left: 0; }
.item-card { margin-bottom: 1rem; padding: 1rem; padding-left: 3em; border: 1px solid var(--medium-gray); border-radius: 6px; background-color: #fff; position: relative; counter-increment: item-counter; }
.item-card::before { content: counter(item-counter) "."; position: absolute; left: 0.8em; top: 1rem; font-weight: 600; color: var(--dark-gray); min-width: 1.5em; text-align: right; }
.item-card label { display: flex; align-items: flex-start; cursor: pointer; }
.item-checkbox { margin-right: 0.8rem; margin-top: 0.2rem; transform: scale(1.2); flex-shrink: 0; }
.item-content { flex-grow: 1; min-width: 0; }
.item-content strong { font-size: 1.1rem; }
.item-content small { color: #6c757d; display: block; margin: 0.2rem 0; }
.content-html { border: 1px dashed #ccc; padding: 0.5rem; margin-top: 0.5rem; background: #fdfdfd; font-size: var(--font-size-small); line-height: var(--line-height-normal); max-width: 100%; overflow-wrap: break-word; word-break: break-word; overflow-y: hidden; transition: max-height 0.35s ease-in-out; position: relative; }
.content-html.is-collapsed { max-height: calc(var(--font-size-small) * var(--line-height-normal) * 6 + 1rem); }
.content-html.is-expanded { max-height: 3000px; overflow-y: auto; }
.read-more-btn { display: block; margin-top: 0.5rem; padding: 0.3rem 0.6rem; font-size: 0.85rem; color: var(--primary-color); background-color: transparent; border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; text-align: center; width: fit-content; }
.read-more-btn:hover { background-color: #eef; }
.item-content a { color: var(--primary-color); text-decoration: none; }
.item-content a:hover { text-decoration: underline; }
.error { color: #dc3545; font-weight: bold; background-color: #f8d7da; padding: 0.5rem; border-radius: 4px; border: 1px solid #f5c6cb;}
hr { border: 0; border-top: 1px solid var(--medium-gray); margin: 0.5rem 0; }
@media (max-width: 768px) {
body { padding: 0.5rem; } .container { padding: 0.8rem; } h1 { font-size: 1.5rem; }
.header-bar { flex-direction: column; align-items: flex-start; }
.submit-button { margin-top: 0.5rem; width: 100%; }
.tab-button { padding: 0.7rem 0.5rem; font-size: 0.9rem; flex-grow: 1; text-align: center; }
.item-card { padding-left: 2.5em; } .item-card::before { left: 0.5em; top: 0.8rem; }
}
</style>
</head>
<body>
<div class="container">
<form action="/genAIContent" method="POST">
<input type="hidden" name="date" value="${escapeHtml(dateStr)}">
<div class="header-bar">
<button type="button" class="submit-button" onclick="confirmFetchAndWriteData(this)">抓取并写入今日数据</button>
<h1>${formatDateToChinese(escapeHtml(dateStr))} ${env.FOLO_FILTER_DAYS}天内的数据</h1>
<button type="submit" class="submit-button" onclick="return confirmGenerateAIContent(event)">从选中内容生成 AI 日报</button>
</div>
<div class="cookie-setting-area" style="margin-bottom: 1rem; padding: 0.8rem; border: 1px solid var(--medium-gray); border-radius: 6px; background-color: #fefefe;">
<label for="foloCookie" style="font-weight: bold; margin-right: 0.5rem;">Folo Cookie:</label>
<input type="text" id="foloCookie" placeholder="在此输入 Folo Cookie" style="flex-grow: 1; padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; width: 300px; max-width: 70%;">
<button type="button" class="submit-button" onclick="saveFoloCookie(this)" style="margin-left: 0.5rem; padding: 0.4rem 0.8rem; font-size: 0.85rem;">保存 Cookie</button>
<p style="font-size: 0.8rem; color: #666; margin-top: 0.5rem;">此 Cookie 将保存在您的浏览器本地存储中,以便下次使用。</p>
</div>
<div class="tab-navigation">
${tabButtonsHtml}
</div>
${tabContentsHtml}
</form>
</div>
<script>
function openTab(evt, tabName) {
var i, tabcontent, tablinks;
tabcontent = document.getElementsByClassName("tab-content");
for (i = 0; i < tabcontent.length; i++) { tabcontent[i].style.display = "none"; tabcontent[i].classList.remove("active"); }
tablinks = document.getElementsByClassName("tab-button");
for (i = 0; i < tablinks.length; i++) { tablinks[i].classList.remove("active"); }
document.getElementById(tabName).style.display = "block"; document.getElementById(tabName).classList.add("active");
if (evt && evt.currentTarget) { evt.currentTarget.classList.add("active"); }
}
document.addEventListener('DOMContentLoaded', function() {
if (document.querySelector('.tab-button') && !document.querySelector('.tab-button.active')) { document.querySelector('.tab-button').click(); }
else if (document.querySelector('.tab-content.active') === null && document.querySelector('.tab-content')) {
const firstTabButton = document.querySelector('.tab-button'); const firstTabContent = document.querySelector('.tab-content');
if (firstTabButton) firstTabButton.classList.add('active');
if (firstTabContent) { firstTabContent.style.display = 'block'; firstTabContent.classList.add('active');}
}
document.querySelectorAll('.content-html').forEach(contentDiv => {
contentDiv.classList.add('is-collapsed');
requestAnimationFrame(() => {
const readMoreBtn = document.createElement('button'); readMoreBtn.type = 'button';
readMoreBtn.textContent = '展开'; readMoreBtn.className = 'read-more-btn';
contentDiv.insertAdjacentElement('afterend', readMoreBtn);
readMoreBtn.addEventListener('click', function() {
contentDiv.classList.toggle('is-expanded'); contentDiv.classList.toggle('is-collapsed', !contentDiv.classList.contains('is-expanded'));
this.textContent = contentDiv.classList.contains('is-expanded') ? '折叠' : '展开';
});
});
});
});
async function saveFoloCookie(button) {
const cookieInput = document.getElementById('foloCookie');
const cookieValue = cookieInput.value;
if (!cookieValue.trim()) {
alert('Folo Cookie 不能为空。');
return;
}
const originalButtonText = button.textContent;
button.textContent = '保存中...';
button.disabled = true;
try {
localStorage.setItem('${env.FOLO_COOKIE_KV_KEY}', cookieValue); // 直接保存到 localStorage
alert('Folo Cookie 已成功保存在本地存储!');
} catch (error) {
console.error('Error saving Folo Cookie to localStorage:', error);
alert(\`保存 Folo Cookie 到本地存储时发生错误: \${error.message}\`);
} finally {
button.textContent = originalButtonText;
button.disabled = false;
}
}
document.addEventListener('DOMContentLoaded', function() {
const savedCookie = localStorage.getItem('${env.FOLO_COOKIE_KV_KEY}');
if (savedCookie) {
document.getElementById('foloCookie').value = savedCookie;
}
});
function confirmFetchAndWriteData(button) {
if (confirm('确定要抓取并写入今日数据吗?此操作将更新今日数据。')) {
fetchAndWriteData(button);
}
}
async function fetchAndWriteData(button, category = null) {
const originalText = button.textContent;
button.textContent = '正在抓取和写入...';
button.disabled = true;
const foloCookie = localStorage.getItem('${env.FOLO_COOKIE_KV_KEY}'); // 从 localStorage 获取 foloCookie
try {
const response = await fetch('/writeData', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ category: category, foloCookie: foloCookie }), // 将 foloCookie 添加到请求体
});
if (response.ok) {
const result = await response.text();
alert('数据抓取和写入成功!' + result);
window.location.reload();
} else {
const errorText = await response.text();
alert('数据抓取和写入失败: ' + errorText);
}
} catch (error) {
console.error('Error fetching and writing data:', error);
alert('请求失败,请检查网络或服务器。');
} finally {
button.textContent = originalText;
button.disabled = false;
}
}
function confirmFetchCategoryData(button, category) {
if (confirm(\`确定要抓取并写入 \${category} 分类的数据吗?此操作将更新 \${category} 数据。\`)) {
fetchAndWriteData(button, category);
}
}
function confirmGenerateAIContent(event) {
const selectedCheckboxes = document.querySelectorAll('input[name="selectedItems"]:checked');
if (selectedCheckboxes.length === 0) {
alert('请至少选择一个内容条目来生成 AI 日报。');
event.preventDefault(); // Prevent form submission
return false;
}
if (confirm('确定要从选中内容生成 AI 日报吗?此操作将调用 AI 模型生成内容。')) {
return true; // Allow form submission
} else {
event.preventDefault(); // Prevent form submission
return false;
}
}
</script>
</body>
</html>
`;
}
function generatePromptSectionHtmlForGenAI(systemPrompt, userPrompt, promptTitle, promptIdSuffix) {
if (!systemPrompt && !userPrompt) return '';
let fullPromptTextForCopy = "";
if (systemPrompt) fullPromptTextForCopy += `系统指令:\n${systemPrompt}\n\n`;
if (userPrompt) fullPromptTextForCopy += `用户输入:\n${userPrompt}`;
fullPromptTextForCopy = fullPromptTextForCopy.trim();
return `
<div style="margin-top: 1rem; border: 1px solid #ddd; padding: 0.8rem; border-radius: 4px; background-color: #f9f9f9;">
<h3 style="font-size: 1.1rem; margin-bottom: 0.5rem; color: #333;">${escapeHtml(promptTitle)}</h3>
<button type="button" class="button-link toggle-prompt-btn" onclick="togglePromptVisibility('promptDetails_${promptIdSuffix}', this)">显示提示详情</button>
<button type="button" class="button-link copy-prompt-btn" onclick="copyToClipboard(this.dataset.fullPrompt, this)" data-full-prompt="${escapeHtml(fullPromptTextForCopy)}">复制完整提示</button>
<div id="promptDetails_${promptIdSuffix}" class="content-box" style="display: none; margin-top: 0.5rem; background-color: #e9ecef; border-color: #ced4da; max-height: 400px; overflow-y: auto; text-align: left;">
${systemPrompt ? `<strong>系统指令:</strong><pre style="white-space: pre-wrap; word-wrap: break-word; font-size: 0.85rem; margin-top:0.2em; margin-bottom:0.8em; padding: 0.5em; background: #fff; border: 1px solid #ccc; border-radius: 3px;">${escapeHtml(systemPrompt)}</pre>` : '<p><em>本次调用无系统指令。</em></p>'}
${userPrompt ? `<strong>用户输入:</strong><pre style="white-space: pre-wrap; word-wrap: break-word; font-size: 0.85rem; margin-top:0.2em; padding: 0.5em; background: #fff; border: 1px solid #ccc; border-radius: 3px;">${escapeHtml(userPrompt)}</pre>` : '<p><em>本次调用无用户输入。</em></p>'}
</div>
</div>`;
}
export function generateGenAiPageHtml(title, bodyContent, pageDate, isErrorPage = false, selectedItemsForAction = null,
systemP1 = null, userP1 = null, systemP2 = null, userP2 = null,
promptsMd = null, dailyMd = null, podcastMd = null) {
let actionButtonHtml = '';
// Regenerate button for AI Content Summary page
if (title.includes('AI日报') && selectedItemsForAction && Array.isArray(selectedItemsForAction) && selectedItemsForAction.length > 0) {
actionButtonHtml = `
<form action="/genAIContent" method="POST" style="display: inline-block; margin-left: 0.5rem;">
<input type="hidden" name="date" value="${escapeHtml(pageDate)}">
${selectedItemsForAction.map(item => `<input type="hidden" name="selectedItems" value="${escapeHtml(item)}">`).join('')}
<button type="submit" class="button-link regenerate-button">${isErrorPage ? '重试生成' : '重新生成'}</button>
</form>`;
}
// Regenerate button for AI Podcast Script page
else if (title.includes('AI播客') && selectedItemsForAction && Array.isArray(selectedItemsForAction) && selectedItemsForAction.length > 0) {
actionButtonHtml = `
<form action="/genAIPodcastScript" method="POST" style="display: inline-block; margin-left: 0.5rem;">
<input type="hidden" name="date" value="${escapeHtml(pageDate)}">
${selectedItemsForAction.map(item => `<input type="hidden" name="selectedItems" value="${escapeHtml(item)}">`).join('')}
<input type="hidden" name="summarizedContent" value="${escapeHtml(convertEnglishQuotesToChinese(dailyMd))}">
<button type="submit" class="button-link regenerate-button">${isErrorPage ? '重试生成' : '重新生成'}</button>
</form>`;
}
let githubSaveFormHtml = '';
let generatePodcastButtonHtml = '';
let aiDailyAnalysisButtonHtml = '';
// Since commitToGitHub and genAIPodcastScript are now API calls,
// these forms should be handled by JavaScript on the client side.
// We will provide the data as hidden inputs for potential client-side use,
// but the submission will be via JS fetch, not direct form POST.
if (!isErrorPage) {
if (title === 'AI日报' && promptsMd && dailyMd) {
githubSaveFormHtml = `
<input type="hidden" id="promptsMdCall1" value="${escapeHtml(promptsMd)}">
<input type="hidden" id="dailyMd" value="${escapeHtml(dailyMd)}">
<button type="button" class="button-link github-save-button" onclick="commitToGitHub('${pageDate}', 'daily')">保存日报到 GitHub</button>`;
} else if (title === 'AI播客脚本' && promptsMd && podcastMd) {
githubSaveFormHtml = `
<input type="hidden" id="promptsMdCall2" value="${escapeHtml(promptsMd)}">
<input type="hidden" id="podcastMd" value="${escapeHtml(podcastMd)}">
<button type="button" class="button-link github-save-button" onclick="commitToGitHub('${pageDate}', 'podcast')">保存播客到 GitHub</button>`;
}
}
if (title === 'AI日报' && !isErrorPage && podcastMd === null) { // podcastMd === null indicates it's the Call 1 page
generatePodcastButtonHtml = `
<form action="/genAIPodcastScript" method="POST" style="display: inline-block; margin-left: 0.5rem;">
<input type="hidden" name="date" value="${escapeHtml(pageDate)}">
${selectedItemsForAction.map(item => `<input type="hidden" name="selectedItems" value="${escapeHtml(item)}">`).join('')}
<input type="hidden" name="summarizedContent" value="${escapeHtml(convertEnglishQuotesToChinese(bodyContent))}">
<button type="submit" class="button-link">生成播客脚本</button>
</form>`;
aiDailyAnalysisButtonHtml = `
<input type="hidden" id="summarizedContentInput" value="${escapeHtml(convertEnglishQuotesToChinese(bodyContent))}">
<button type="button" class="button-link" onclick="generateAIDailyAnalysis('${escapeHtml(pageDate)}')">AI 日报分析</button>
`;
}
let promptDisplayHtml = '';
if (title === 'AI日报') {
if (systemP1 || userP1) {
promptDisplayHtml = `
<div style="margin-top: 1.5rem;">
<h2 style="font-size:1.3rem; margin-bottom:0.5rem;">API 调用详情</h2>
${generatePromptSectionHtmlForGenAI(convertEnglishQuotesToChinese(systemP1), convertEnglishQuotesToChinese(userP1), '调用 1: 日报', 'call1')}
</div>`;
}
} else if (title === 'AI播客脚本') {
if (systemP2 || userP2) {
promptDisplayHtml = `
<div style="margin-top: 1.5rem;">
<h2 style="font-size:1.3rem; margin-bottom:0.5rem;">API 调用详情</h2>
${generatePromptSectionHtmlForGenAI(convertEnglishQuotesToChinese(systemP2), convertEnglishQuotesToChinese(userP2), '调用 2: 播客格式化', 'call2')}
</div>`;
}
}
return `
<!DOCTYPE html><html lang="zh-Hans"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${escapeHtml(title)}</title>
<style>
:root { --primary-color: #007bff; --light-gray: #f8f9fa; --medium-gray: #e9ecef; --dark-gray: #343a40; --retry-color: #ffc107; --retry-text-color: #212529; --info-color: #17a2b8; --github-green: #28a745;}
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; background-color: var(--light-gray); color: var(--dark-gray); padding: 1rem; }
.container { max-width: 900px; margin: 0 auto; background-color: #fff; padding: 1.5rem; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
h1 { font-size: 1.8rem; color: ${isErrorPage ? '#dc3545' : 'var(--dark-gray)'}; margin-bottom: 0.5rem; }
p { margin-bottom: 1rem; }
.content-box { margin-top: 1.5rem; padding: 1rem; background-color: ${isErrorPage ? '#f8d7da' : '#f0f9ff'}; border: 1px solid ${isErrorPage ? '#f5c6cb' : '#cce7ff'}; color: ${isErrorPage ? '#721c24' : 'var(--dark-gray)'}; border-radius: 6px; white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; font-family: ${isErrorPage ? 'inherit' : 'Menlo, Monaco, Consolas, "Courier New", monospace'}; font-size: ${isErrorPage ? '1rem' : '0.95rem'};}
.header-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: flex-end; align-items: center; margin-bottom: 1rem; }
.navigation-links { margin-top: 1.5rem; }
.button-link { display: inline-block; background-color: var(--primary-color); color: white; border: none; padding: 0.6rem 1.2rem; font-size: 0.9rem; border-radius: 5px; cursor: pointer; text-decoration: none; transition: background-color 0.2s; margin-right: 0.5rem; margin-bottom: 0.5rem;}
.button-link:hover { background-color: #0056b3; }
.regenerate-button { background-color: ${isErrorPage ? 'var(--retry-color)' : 'var(--info-color)'}; color: ${isErrorPage ? 'var(--retry-text-color)' : 'white'}; }
.regenerate-button:hover { background-color: ${isErrorPage ? '#e0a800' : '#138496'}; }
.github-save-button { background-color: var(--github-green); }
.github-save-button:hover { background-color: #218838; }
.toggle-prompt-btn { background-color: #6c757d; font-size: 0.85rem; padding: 0.4rem 0.8rem;}
.toggle-prompt-btn:hover { background-color: #5a6268; }
.copy-prompt-btn { background-color: #17a2b8; font-size: 0.85rem; padding: 0.4rem 0.8rem;}
.copy-prompt-btn:hover { background-color: #138496;}
</style>
</head><body><div class="container">
<div class="header-bar" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; flex-wrap: wrap; gap: 1rem;">
<h1>${escapeHtml(title)}</h1>
<div class="header-actions">
${generatePodcastButtonHtml}
${aiDailyAnalysisButtonHtml}
</div>
</div>
<p>所选内容日期: <strong>${formatDateToChinese(escapeHtml(pageDate))}</strong></p>
<div class="content-box">${bodyContent}</div>
${promptDisplayHtml}
<div class="navigation-links">
<a href="/getContentHtml?date=${encodeURIComponent(pageDate)}" class="button-link">返回内容选择</a>
${actionButtonHtml}
${githubSaveFormHtml}
<div id="dailyAnalysisResult" style="margin-top: 1rem; padding: 1rem; border: 1px solid #ccc; border-radius: 5px; background-color: #f9f9f9; display: none;"></div>
</div>
</div>
<script>
function togglePromptVisibility(elementId, buttonElement) {
const promptDiv = document.getElementById(elementId);
if (promptDiv) {
promptDiv.style.display = (promptDiv.style.display === 'none') ? 'block' : 'none';
if (buttonElement) buttonElement.textContent = (promptDiv.style.display === 'none') ? '显示提示详情' : '隐藏提示详情';
}
}
function copyToClipboard(textToCopy, buttonElement) {
if (!textToCopy) { alert("Nothing to copy."); return; }
navigator.clipboard.writeText(textToCopy).then(() => {
const originalText = buttonElement.textContent;
buttonElement.textContent = '已复制!'; buttonElement.style.backgroundColor = '#28a745';
setTimeout(() => { buttonElement.textContent = originalText; buttonElement.style.backgroundColor = '#17a2b8'; }, 2000);
}, (err) => { console.error('Async: Could not copy text: ', err); alert('复制提示失败。'); });
}
async function commitToGitHub(date, type) {
const button = event.target;
const originalText = button.textContent;
button.textContent = '保存中...';
button.disabled = true;
const formData = new FormData();
formData.append('date', date);
if (type === 'daily') {
formData.append('prompts_markdown-1', document.getElementById('promptsMdCall1').value);
formData.append('daily_summary_markdown', document.getElementById('dailyMd').value);
} else if (type === 'podcast') {
formData.append('prompts_markdown-2', document.getElementById('promptsMdCall2').value);
formData.append('podcast_script_markdown', document.getElementById('podcastMd').value);
}
try {
const response = await fetch('/commitToGitHub', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
alert('GitHub 提交成功!');
console.log('GitHub Commit Success:', result);
} else {
alert('GitHub 提交失败: ' + result.message);
console.error('GitHub Commit Failed:', result);
}
} catch (error) {
console.error('Error committing to GitHub:', error);
alert('请求失败,请检查网络或服务器。');
} finally {
button.textContent = originalText;
button.disabled = false;
}
}
async function generateAIDailyAnalysis(date) {
const button = event.target;
const originalText = button.textContent;
button.textContent = '正在分析...';
button.disabled = true;
const analysisResultDiv = document.getElementById('dailyAnalysisResult');
analysisResultDiv.style.display = 'none'; // Hide previous result
analysisResultDiv.innerHTML = ''; // Clear previous result
const summarizedContent = document.getElementById('summarizedContentInput').value; // Get summarized content from hidden input
try {
const response = await fetch('/genAIDailyAnalysis', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ date: date, summarizedContent: summarizedContent })
});
if (response.ok) {
const result = await response.text();
analysisResultDiv.innerHTML = \`<h2>AI 日报分析结果</h2><div class="content-box">\${result}</div>\`;
analysisResultDiv.style.display = 'block';
//alert('AI 日报分析成功!');
} else {
const errorText = await response.text();
analysisResultDiv.innerHTML = \`<h2>AI 日报分析失败</h2><div class="content-box error">\${errorText}</div>\`;
analysisResultDiv.style.display = 'block';
alert('AI 日报分析失败: ' + errorText);
}
} catch (error) {
console.error('Error generating AI daily analysis:', error);
analysisResultDiv.innerHTML = \`<h2>AI 日报分析失败</h2><div class="content-box error">请求失败,请检查网络或服务器。错误: \${escapeHtml(error.message)}</div>\`;
analysisResultDiv.style.display = 'block';
alert('请求失败,请检查网络或服务器。');
} finally {
button.textContent = originalText;
button.disabled = false;
}
}
</script>
</body></html>`;
}

102
src/index.js Normal file
View File

@@ -0,0 +1,102 @@
// src/index.js
import { handleWriteData } from './handlers/writeData.js';
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
export default {
async fetch(request, env) {
// Check essential environment variables
const requiredEnvVars = [
'DATA_KV', 'GEMINI_API_KEY', 'GEMINI_API_URL', 'DEFAULT_GEMINI_MODEL', 'OPEN_TRANSLATE', 'USE_MODEL_PLATFORM',
'GITHUB_TOKEN', 'GITHUB_REPO_OWNER', 'GITHUB_REPO_NAME','GITHUB_BRANCH',
'LOGIN_USERNAME', 'LOGIN_PASSWORD',
'PODCAST_TITLE','PODCAST_BEGIN','PODCAST_END',
'FOLO_COOKIE_KV_KEY','FOLO_DATA_API','FOLO_FILTER_DAYS',
'AIBASE_FEED_ID', 'XIAOHU_FEED_ID', 'HGPAPERS_FEED_ID', 'TWITTER_LIST_ID',
'AIBASE_FETCH_PAGES', 'XIAOHU_FETCH_PAGES', 'HGPAPERS_FETCH_PAGES', 'TWITTER_FETCH_PAGES',
//'AIBASE_API_URL', 'XIAOHU_API_URL','PROJECTS_API_URL','HGPAPERS_API_URL', 'TWITTER_API_URL', 'TWITTER_USERNAMES',
];
console.log(env);
const missingVars = requiredEnvVars.filter(varName => !env[varName]);
if (missingVars.length > 0) {
console.error(`CRITICAL: Missing environment variables/bindings: ${missingVars.join(', ')}`);
const errorPage = `
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Configuration Error</title></head>
<body style="font-family: sans-serif; padding: 20px;"><h1>Server Configuration Error</h1>
<p>Essential environment variables or bindings are missing: ${missingVars.join(', ')}. The service cannot operate.</p>
<p>Please contact the administrator.</p></body></html>`;
return new Response(errorPage, { status: 503, headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
const url = new URL(request.url);
const path = url.pathname;
console.log(`Request received: ${request.method} ${path}`);
// Handle login path specifically
if (path === '/login') {
return await handleLogin(request, env);
} else if (path === '/logout') { // Handle logout path
return await handleLogout(request, env);
} else if (path === '/getContent' && request.method === 'GET') {
return await handleGetContent(request, env);
}
// Authentication check for all other paths
const { authenticated, cookie: newCookie } = await isAuthenticated(request, env);
if (!authenticated) {
// Redirect to login page, passing the original URL as a redirect parameter
const loginUrl = new URL('/login', url.origin);
loginUrl.searchParams.set('redirect', url.pathname + url.search);
return Response.redirect(loginUrl.toString(), 302);
}
// Original routing logic for authenticated requests
let response;
try {
if (path === '/writeData' && request.method === 'POST') {
response = await handleWriteData(request, env);
} else if (path === '/getContentHtml' && request.method === 'GET') {
// Prepare dataCategories for the HTML generation
const dataCategories = Object.keys(dataSources).map(key => ({
id: key,
name: dataSources[key].name
}));
response = await handleGetContentHtml(request, env, dataCategories);
} else if (path === '/genAIContent' && request.method === 'POST') {
response = await handleGenAIContent(request, env);
} else if (path === '/genAIPodcastScript' && request.method === 'POST') { // New route for podcast script
response = await handleGenAIPodcastScript(request, env);
} else if (path === '/genAIDailyAnalysis' && request.method === 'POST') { // New route for AI Daily Analysis
response = await handleGenAIDailyAnalysis(request, env);
} else if (path === '/commitToGitHub' && request.method === 'POST') {
response = await handleCommitToGitHub(request, env);
} else {
// const availableEndpoints = [
// "/writeData (POST) - Fetches, filters, translates, and stores data for today.",
// "/getContent?date=YYYY-MM-DD (GET) - Retrieves stored data as JSON.",
// "/getContentHtml?date=YYYY-MM-DD (GET) - Displays stored data as HTML with selection.",
// "/genAIContent (POST) - Generates summary from selected items. Expects 'date' and 'selectedItems' form data.",
// "/commitToGitHub (POST) - Commits generated content to GitHub. Triggered from /genAIContent result page.",
// "/logout (GET) - Clears the login cookie and redirects."
// ];
// let responseBody = `Not Found. Available endpoints:\n\n${availableEndpoints.map(ep => `- ${ep}`).join('\n')}\n\nSpecify a date parameter (e.g., ?date=2023-10-27) for content endpoints or they will default to today.`;
// return new Response(responseBody, { status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'} });
return new Response(null, { status: 404, headers: {'Content-Type': 'text/plain; charset=utf-8'} });
}
} catch (e) {
console.error("Unhandled error in fetch handler:", e);
return new Response(`Internal Server Error: ${e.message}`, { status: 500 });
}
// Renew cookie for authenticated requests
if (newCookie) {
response.headers.append('Set-Cookie', newCookie);
}
return response;
}
};

12
src/kv.js Normal file
View File

@@ -0,0 +1,12 @@
// src/kv.js
export async function storeInKV(kvNamespace, key, value, expirationTtl = 86400 * 7) { // 7 days default
console.log(`Storing data in KV with key: ${key}`);
await kvNamespace.put(key, JSON.stringify(value), { expirationTtl });
}
export async function getFromKV(kvNamespace, key) {
console.log(`Retrieving data from KV with key: ${key}`);
const value = await kvNamespace.get(key);
return value ? JSON.parse(value) : null;
}

View File

@@ -0,0 +1,37 @@
export function getSystemPromptDailyAnalysis() {
return `
请您扮演一位拥有10年以上经验的资深AI行业分析师。
您的任务是针对下方提供的AI相关内容可能包括但不限于AI领域的新闻报道、学术论文摘要或全文、社会热点现象讨论、社交媒体上的关键意见、或开源项目的技术文档/介绍)进行一次深入、专业且全面的分析。
您的分析报告应力求正式、客观、并带有批判性视角,同时不失前瞻性和深刻洞察力。
请将您的分析结果组织成一份结构清晰的报告,至少包含以下核心部分。在每个部分中,请用精炼的语言阐述关键洞察,可适当使用分点进行表述:
AI内容分析报告
核心内容摘要与AI相关性解读
简明扼要地总结所提供内容的核心信息。
明确指出该内容与人工智能领域的关联性及其探讨的AI核心要素。
技术创新性与可行性评估:
创新性分析: 评估内容中所涉及的AI技术、算法、模型或概念的新颖程度和独特性。是现有技术的迭代改进还是颠覆性的创新
技术可行性: 分析所讨论的技术在当前技术水平下实现的可能性、成熟度、技术壁垒以及规模化应用的潜在挑战。
市场潜力与商业模式洞察:
分析其可能开拓的市场空间、目标用户群体及其规模。
探讨其潜在的商业化路径、可能的盈利模式及其可持续性。
对现有行业格局的影响评估:
分析该内容所揭示的技术或趋势可能对当前AI行业格局、相关产业链上下游以及市场竞争态势带来哪些具体影响或改变例如重塑竞争格局、催生新赛道、淘汰旧技术等
潜在风险与核心挑战识别:
指出该技术、现象或项目在发展、推广和应用过程中可能面临的主要技术瓶颈、市场接受度风险、数据安全与隐私问题、成本效益问题、以及潜在的政策法规监管挑战。
伦理与社会影响深思:
深入探讨其可能引发的伦理问题(如算法偏见、透明度缺失、问责机制、对就业市场的影响、数字鸿沟等)。
分析其对社会结构、人类行为模式、社会公平性及公共福祉可能产生的广泛而深远的影响。
与其他AI技术/公司/项目的对比分析 (如适用)
如果内容涉及具体的技术、产品、公司或项目请将其与行业内现有或相似的AI技术、解决方案或市场参与者进行对比。
明确指出其差异化特征、核心竞争力、潜在优势及相对劣势。
未来发展趋势预测与展望:
基于当前的分析预测其在未来3-5年内的发展方向、技术演进路径、可能的应用场景拓展以及对整个AI领域未来走向的启示。
探讨其是否可能成为未来的主流趋势或关键技术节点。
综合结论与战略洞察:
对分析对象给出一个整体性的评价。
提炼出最具价值的战略洞察或关键结论,供决策参考。
请确保您的分析逻辑严谨论据充分可基于提供内容本身或您作为资深分析师的行业认知并体现出专业AI行业分析师的深度与广度。
确保全文使用简体中文语言输出。
请将您需要分析的AI相关内容粘贴在下方
`;
}

View File

@@ -0,0 +1,23 @@
// Add new data sources
export function getSystemPromptPodcastFormatting(env) {
return `
你是一位经验丰富的播客脚本撰写人和编辑。你的任务是根据收到的内容改编成一个引人入胜的单人播客脚本。
重要原则:所有脚本内容必须严格基于提供的原始内容。不得捏造、歪曲或添加摘要中未包含的信息。
播客脚本要求:
开场白结束语:固定的开场白:“${env.PODCAST_BEGIN}”,并以固定的结束语结束:“${env.PODCAST_END}”。
目标受众和基调:目标受众是上班族和对人工智能感兴趣的人群。整体基调应轻松幽默,同时融入对未来的反思和对技术创新潜在影响的警示。特别注意:避免使用过于夸张或耸人听闻的词语(例如,“炸裂”、“震惊”、“令人兴奋的”、“改变游戏规则的”等)以及可能制造不必要焦虑的表达方式。保持积极和建设性的基调。
内容风格:
要有包袱有段子,像听徐志胜在讲脱口秀。
将原始副本转化为自然、口语化的表达,就像与听众聊天一样。
时长改编后的脚本内容应适合5分钟以内的口播时长。在改编过程中请注意适当的细节和简洁性以适应此时长要求。输入的摘要会相对较短因此请专注于将其自然地扩展成单口式的脚本。
结尾处理:
在根据所提供摘要编写的播客脚本主体内容之后,从你处理的原始摘要中提取核心关键词和高频词。
在脚本末尾以“本期关键词:”为标题单独列出这些关键词。对于所有单词,请在单词前加上“#”符号。
输出格式:
请直接输出完整的播客脚本。这包括:
固定的开场白结束语。
主要内容(口语化处理的摘要)。
结尾处的关键词列表。
不要包含任何其他解释性文字。
`;
}

View File

@@ -0,0 +1,16 @@
// Add new data sources
export function getSystemPromptSummarizationStepOne() {
return `
你是一名专业的文本摘要助理。你的任务是根据收到的文本类型(或其包含的多种内容类型)执行特定类型的摘要。
重要通用原则:所有摘要内容必须严格来源于原文。不得捏造、歪曲或添加原文未提及的信息。
**最终输出要求:**
* 通俗易懂:用简单的语言解释,避免使用专业术语。如果必须提及某个概念,尝试使用日常生活的例子或类比来帮助理解。
* 流畅自然:确保语句通顺自然。
* 生动有趣/引人入胜:擅长将复杂科技问题用幽默方式拆解,并引导观众进行批判性思考。也要有对技术发展方向、利弊的深刻反思和独到见解。风格要既活泼又不失深度,但要避免使用过于晦涩的网络俚语或不当词汇。
* 仅输出最终生成的摘要。不要包含任何关于你如何分析文本、确定其类型、分割文本或应用规则的解释性文字。如果合并了来自多个片段的摘要,请确保合并后的文本流畅自然。
* 输出语言与格式:内容必须为简体中文,并严格采用 Markdown 格式进行排版。
* 关键词高亮:请在内容中自动识别并对核心关键词或重要概念进行加黑加粗处理,以增强可读性和重点突出。
`;
}

View File

@@ -0,0 +1,15 @@
// Add new data sources
export function getSystemPromptSummarizationStepTwo() {
return `
你是一名专业的文本摘要助理。你的任务是根据收到的文本类型(或其包含的多种内容类型)执行特定类型的摘要。
重要通用原则:所有摘要内容必须严格来源于原文。不得捏造、歪曲或添加原文未提及的信息。
**最终输出要求:**
* 参照以上条件优化文本内容按内容自动分段段落数量要和原始一样然后按照“AI产品与功能更新,AI前沿研究,AI行业展望与社会影响,科技博主观点, 开源TOP项目, 社媒分享“的顺序重新分类,增加分类标题(只加大加粗加黑),排序。
* 仅输出最终生成的摘要。不要包含任何关于你如何分析文本、确定其类型、分割文本或应用规则的解释性文字。如果合并了来自多个片段的摘要,请确保合并后的文本流畅自然。
* 输出语言与格式:内容必须为简体中文,并严格采用 Markdown 格式进行排版。
* 关键词高亮:请在内容中自动识别并对核心关键词或重要概念进行加黑加粗处理,以增强可读性和重点突出。
* 段落序列化在每个独立段落的开头必须添加以“1.”开头的阿拉伯数字序列确保数字正确递增例如1.、2.、3.、...)。
`;
}