327 lines
12 KiB
JavaScript
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}`);
|
|
});
|