Files
zyBooksBot/server.js
2025-10-05 04:34:51 -07:00

327 lines
12 KiB
JavaScript

const express = require('express');
const axios = require('axios');
const cors = require('cors');
const crypto = require('crypto');
const app = express();
const PORT = 3000;
// Configuration
const ZYBOOKS_CONFIG = {
BASE_URL: 'https://zyserver.zybooks.com/v1',
TIME_URL: 'https://zyserver2.zybooks.com/v1',
LEARN_URL: 'https://learn.zybooks.com',
HEADERS: {
'Host': 'zyserver.zybooks.com',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Language': 'en-US,en;q=0.5',
'Content-Type': 'application/json',
'Origin': 'https://learn.zybooks.com',
'Referer': 'https://learn.zybooks.com/'
}
};
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.static('public'));
// Global error handlers
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
});
// ==================== UTILITY FUNCTIONS ====================
function generateTimestamp(timeSpent = 0) {
const now = new Date();
const future = new Date(now.getTime() + timeSpent * 1000);
const ms = String(Math.floor(Math.random() * 1000)).padStart(3, '0');
return future.toISOString().replace(/\.\d{3}Z$/, `${ms}Z`);
}
function generateChecksum(actId, timestamp, auth, part, buildkey) {
const data = `content_resource/${actId}/activity${timestamp}${auth}${actId}${part}true${buildkey}`;
return crypto.createHash('md5').update(data).digest('hex');
}
async function getBuildkey(axiosInstance) {
const response = await axiosInstance.get(ZYBOOKS_CONFIG.LEARN_URL);
const match = response.data.match(/zybooks-web\/config\/environment[^>]*content="([^"]*)/);
const decoded = decodeURIComponent(match[1]);
const config = JSON.parse(decoded);
return config.APP.BUILDKEY;
}
async function calculateSectionParts(bookCode, chapterNumber, sectionNumber, authToken, axiosInstance) {
try {
const problemsResponse = await axiosInstance.get(
`${ZYBOOKS_CONFIG.BASE_URL}/zybook/${bookCode}/chapter/${chapterNumber}/section/${sectionNumber}?auth_token=${authToken}`
);
if (!problemsResponse.data || !problemsResponse.data.section) {
throw new Error('Invalid response: missing section data');
}
const problems = problemsResponse.data.section.content_resources;
if (!problems || !Array.isArray(problems)) {
throw new Error('Invalid response: missing or invalid content_resources');
}
const totalParts = problems.reduce((total, problem) => total + Math.max(problem.parts, 1), 0);
return { problems, totalParts };
} catch (error) {
console.error('Error in calculateSectionParts:', error.message);
throw error;
}
}
// Error response helper
function sendError(res, statusCode, message) {
console.error(`Error [${statusCode}]:`, message);
res.status(statusCode).json({ success: false, message });
}
// SSE message helpers
function sendSSEMessage(res, type, data) {
res.write(`data: ${JSON.stringify({ type, ...data })}\n\n`);
}
// Core function to solve a single part
async function solvePart(actId, secId, auth, part, code, axiosInstance, timeSpent = 0) {
// Simulate time spent
const spentTime = Math.floor(Math.random() * 60) + 1;
const newTimeSpent = timeSpent + spentTime;
// Send time record
await axiosInstance.post(`${ZYBOOKS_CONFIG.TIME_URL}/zybook/${code}/time_spent`, {
time_spent_records: [{
canonical_section_id: secId,
content_resource_id: actId,
part: part,
time_spent: spentTime,
timestamp: generateTimestamp(newTimeSpent)
}],
auth_token: auth
});
// Get buildkey and generate checksum
const buildkey = await getBuildkey(axiosInstance);
const timestamp = generateTimestamp(newTimeSpent);
const checksum = generateChecksum(actId, timestamp, auth, part, buildkey);
// Solve the problem
const response = await axiosInstance.post(
`${ZYBOOKS_CONFIG.BASE_URL}/content_resource/${actId}/activity`,
{
part: part,
complete: true,
metadata: "{}",
zybook_code: code,
auth_token: auth,
timestamp: timestamp,
__cs__: checksum
},
{ headers: ZYBOOKS_CONFIG.HEADERS }
);
return { success: response.data.success || false, timeSpent: newTimeSpent };
}
// ==================== API ROUTES ====================
// Login
app.post('/api/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return sendError(res, 400, 'Email and password are required');
}
const tempSession = axios.create();
const response = await tempSession.post(`${ZYBOOKS_CONFIG.BASE_URL}/signin`, { email, password });
if (!response.data.success || !response.data.session) {
return sendError(res, 401, 'Invalid credentials');
}
res.json({
success: true,
auth_token: response.data.session.auth_token,
user_id: response.data.session.user_id
});
} catch (error) {
sendError(res, 500, 'Login failed - Server error');
}
});
// Get books
app.get('/api/books/:userId', async (req, res) => {
try {
const { userId } = req.params;
const { auth_token } = req.query;
if (!userId || !auth_token) {
return sendError(res, 400, 'User ID and auth token are required');
}
const axiosInstance = axios.create();
const response = await axiosInstance.get(
`${ZYBOOKS_CONFIG.BASE_URL}/user/${userId}/items?items=%5B%22zybooks%22%5D&auth_token=${auth_token}`
);
const books = response.data.items.zybooks.filter(book => !book.autosubscribe);
res.json({ success: true, books });
} catch (error) {
sendError(res, 500, 'Failed to get books');
}
});
// Get chapters
app.get('/api/chapters/:bookCode', async (req, res) => {
try {
const { bookCode } = req.params;
const { auth_token } = req.query;
if (!bookCode || !auth_token) {
return sendError(res, 400, 'Book code and auth token are required');
}
const axiosInstance = axios.create();
const response = await axiosInstance.get(
`${ZYBOOKS_CONFIG.BASE_URL}/zybooks?zybooks=%5B%22${bookCode}%22%5D&auth_token=${auth_token}`
);
res.json({ success: true, chapters: response.data.zybooks[0].chapters });
} catch (error) {
sendError(res, 500, 'Failed to get chapters');
}
});
// Get section parts count
app.get('/api/section-parts/:bookCode/:chapterNumber/:sectionNumber', async (req, res) => {
try {
const { bookCode, chapterNumber, sectionNumber } = req.params;
const { auth_token } = req.query;
if (!bookCode || !chapterNumber || !sectionNumber || !auth_token) {
return sendError(res, 400, 'Missing required parameters');
}
const axiosInstance = axios.create();
const { totalParts } = await calculateSectionParts(bookCode, chapterNumber, sectionNumber, auth_token, axiosInstance);
res.json({ success: true, totalParts });
} catch (error) {
sendError(res, 500, 'Failed to get section parts');
}
});
// Solve section with SSE
app.post('/api/solve/section', async (req, res) => {
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});
try {
const { bookCode, chapter, section, auth, timeSpent = 0 } = req.body;
if (!bookCode || !chapter || !section || !auth) {
sendSSEMessage(res, 'error', { message: 'Missing required parameters' });
return res.end();
}
console.log(`Solving section: ${chapter.number}.${section.number}`);
const axiosInstance = axios.create();
let currentTimeSpent = timeSpent;
// Send start message
sendSSEMessage(res, 'start', {
message: `Starting section: ${chapter.number}.${section.number} - ${section.title}`
});
// Get problems and total parts
const { problems, totalParts } = await calculateSectionParts(bookCode, chapter.number, section.number, auth, axiosInstance);
sendSSEMessage(res, 'total', { total: totalParts });
let currentPart = 0;
let results = [];
// Process each problem
for (let i = 0; i < problems.length; i++) {
const problem = problems[i];
const parts = Math.max(problem.parts, 1);
// Process each part
for (let partIndex = 0; partIndex < parts; partIndex++) {
currentPart++;
const partNum = partIndex + 1;
const actualPartIndex = problem.parts > 0 ? partIndex : 0;
// Send progress
const progressMessage = parts > 1
? `Solving problem ${i + 1} part ${partNum}...`
: `Solving problem ${i + 1}...`;
sendSSEMessage(res, 'progress', {
current: currentPart,
total: totalParts,
message: progressMessage
});
// Solve the part
const result = await solvePart(problem.id, section.canonical_section_id, auth, actualPartIndex, bookCode, axiosInstance, currentTimeSpent);
currentTimeSpent = result.timeSpent; // Update time spent
results.push({ problem: i + 1, part: partNum, success: result.success });
// Send result
sendSSEMessage(res, 'result', {
current: currentPart,
total: totalParts,
problem: i + 1,
part: partNum,
success: result.success,
timeSpent: currentTimeSpent,
message: result.success
? `✅ Problem ${i + 1} part ${partNum} solved successfully`
: `❌ Problem ${i + 1} part ${partNum} failed`
});
console.log(`Problem ${i + 1}, Part ${partNum}: ${result.success ? 'Success' : 'Failed'}`);
await new Promise(resolve => setTimeout(resolve, 500));
}
}
// Send completion
const successCount = results.filter(r => r.success).length;
sendSSEMessage(res, 'complete', {
results,
successCount,
totalCount: results.length,
finalTimeSpent: currentTimeSpent,
message: `Completed! ${successCount}/${results.length} problems solved successfully`
});
res.end();
} catch (error) {
console.error('Section solving error:', error.message);
sendSSEMessage(res, 'error', { message: `Error solving section: ${error.message}` });
res.end();
}
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 ZyBook Auto Server running on http://localhost:${PORT}`);
});