opensource
This commit is contained in:
177
src/auth.js
Normal file
177
src/auth.js
Normal 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
567
src/chatapi.js
Normal 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
91
src/dataFetchers.js
Normal 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
139
src/dataSources/aibase.js
Normal 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;
|
||||
113
src/dataSources/github-trending.js
Normal file
113
src/dataSources/github-trending.js
Normal 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;
|
||||
204
src/dataSources/huggingface-papers.js
Normal file
204
src/dataSources/huggingface-papers.js
Normal 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
138
src/dataSources/twitter.js
Normal 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
137
src/dataSources/xiaohu.js
Normal 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
90
src/github.js
Normal 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);
|
||||
}
|
||||
47
src/handlers/commitToGitHub.js
Normal file
47
src/handlers/commitToGitHub.js
Normal 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' } });
|
||||
}
|
||||
}
|
||||
294
src/handlers/genAIContent.js
Normal file
294
src/handlers/genAIContent.js
Normal 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' } });
|
||||
}
|
||||
}
|
||||
36
src/handlers/getContent.js
Normal file
36
src/handlers/getContent.js
Normal 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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
31
src/handlers/getContentHtml.js
Normal file
31
src/handlers/getContentHtml.js
Normal 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
78
src/handlers/writeData.js
Normal 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
246
src/helpers.js
Normal 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 = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
};
|
||||
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
499
src/htmlGenerators.js
Normal 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
102
src/index.js
Normal 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
12
src/kv.js
Normal 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;
|
||||
}
|
||||
37
src/prompt/dailyAnalysisPrompt.js
Normal file
37
src/prompt/dailyAnalysisPrompt.js
Normal 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相关内容粘贴在下方:
|
||||
`;
|
||||
}
|
||||
23
src/prompt/podcastFormattingPrompt.js
Normal file
23
src/prompt/podcastFormattingPrompt.js
Normal file
@@ -0,0 +1,23 @@
|
||||
// Add new data sources
|
||||
export function getSystemPromptPodcastFormatting(env) {
|
||||
return `
|
||||
你是一位经验丰富的播客脚本撰写人和编辑。你的任务是根据收到的内容改编成一个引人入胜的单人播客脚本。
|
||||
重要原则:所有脚本内容必须严格基于提供的原始内容。不得捏造、歪曲或添加摘要中未包含的信息。
|
||||
播客脚本要求:
|
||||
开场白结束语:固定的开场白:“${env.PODCAST_BEGIN}”,并以固定的结束语结束:“${env.PODCAST_END}”。
|
||||
目标受众和基调:目标受众是上班族和对人工智能感兴趣的人群。整体基调应轻松幽默,同时融入对未来的反思和对技术创新潜在影响的警示。特别注意:避免使用过于夸张或耸人听闻的词语(例如,“炸裂”、“震惊”、“令人兴奋的”、“改变游戏规则的”等)以及可能制造不必要焦虑的表达方式。保持积极和建设性的基调。
|
||||
内容风格:
|
||||
要有包袱有段子,像听徐志胜在讲脱口秀。
|
||||
将原始副本转化为自然、口语化的表达,就像与听众聊天一样。
|
||||
时长:改编后的脚本内容应适合5分钟以内的口播时长。在改编过程中,请注意适当的细节和简洁性,以适应此时长要求。输入的摘要会相对较短,因此请专注于将其自然地扩展成单口式的脚本。
|
||||
结尾处理:
|
||||
在根据所提供摘要编写的播客脚本主体内容之后,从你处理的原始摘要中提取核心关键词和高频词。
|
||||
在脚本末尾以“本期关键词:”为标题单独列出这些关键词。对于所有单词,请在单词前加上“#”符号。
|
||||
输出格式:
|
||||
请直接输出完整的播客脚本。这包括:
|
||||
固定的开场白结束语。
|
||||
主要内容(口语化处理的摘要)。
|
||||
结尾处的关键词列表。
|
||||
不要包含任何其他解释性文字。
|
||||
`;
|
||||
}
|
||||
16
src/prompt/summarizationPromptStepOne.js
Normal file
16
src/prompt/summarizationPromptStepOne.js
Normal file
@@ -0,0 +1,16 @@
|
||||
// Add new data sources
|
||||
export function getSystemPromptSummarizationStepOne() {
|
||||
return `
|
||||
你是一名专业的文本摘要助理。你的任务是根据收到的文本类型(或其包含的多种内容类型)执行特定类型的摘要。
|
||||
|
||||
重要通用原则:所有摘要内容必须严格来源于原文。不得捏造、歪曲或添加原文未提及的信息。
|
||||
|
||||
**最终输出要求:**
|
||||
* 通俗易懂:用简单的语言解释,避免使用专业术语。如果必须提及某个概念,尝试使用日常生活的例子或类比来帮助理解。
|
||||
* 流畅自然:确保语句通顺自然。
|
||||
* 生动有趣/引人入胜:擅长将复杂科技问题用幽默方式拆解,并引导观众进行批判性思考。也要有对技术发展方向、利弊的深刻反思和独到见解。风格要既活泼又不失深度,但要避免使用过于晦涩的网络俚语或不当词汇。
|
||||
* 仅输出最终生成的摘要。不要包含任何关于你如何分析文本、确定其类型、分割文本或应用规则的解释性文字。如果合并了来自多个片段的摘要,请确保合并后的文本流畅自然。
|
||||
* 输出语言与格式:内容必须为简体中文,并严格采用 Markdown 格式进行排版。
|
||||
* 关键词高亮:请在内容中自动识别并对核心关键词或重要概念进行加黑加粗处理,以增强可读性和重点突出。
|
||||
`;
|
||||
}
|
||||
15
src/prompt/summarizationPromptStepTwo.js
Normal file
15
src/prompt/summarizationPromptStepTwo.js
Normal file
@@ -0,0 +1,15 @@
|
||||
// Add new data sources
|
||||
export function getSystemPromptSummarizationStepTwo() {
|
||||
return `
|
||||
你是一名专业的文本摘要助理。你的任务是根据收到的文本类型(或其包含的多种内容类型)执行特定类型的摘要。
|
||||
|
||||
重要通用原则:所有摘要内容必须严格来源于原文。不得捏造、歪曲或添加原文未提及的信息。
|
||||
|
||||
**最终输出要求:**
|
||||
* 参照以上条件优化文本内容,按内容自动分段,段落数量要和原始一样,然后按照“AI产品与功能更新,AI前沿研究,AI行业展望与社会影响,科技博主观点, 开源TOP项目, 社媒分享“的顺序重新分类,增加分类标题(只加大加粗加黑),排序。
|
||||
* 仅输出最终生成的摘要。不要包含任何关于你如何分析文本、确定其类型、分割文本或应用规则的解释性文字。如果合并了来自多个片段的摘要,请确保合并后的文本流畅自然。
|
||||
* 输出语言与格式:内容必须为简体中文,并严格采用 Markdown 格式进行排版。
|
||||
* 关键词高亮:请在内容中自动识别并对核心关键词或重要概念进行加黑加粗处理,以增强可读性和重点突出。
|
||||
* 段落序列化:在每个独立段落的开头,必须添加以“1.”开头的阿拉伯数字序列,确保数字正确递增(例如,1.、2.、3.、...)。
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user