This commit is contained in:
@@ -287,6 +287,90 @@ const dbOps = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getCachedOutgoingCalls(url, now) {
|
||||||
|
const callsKey = `outgoing-calls:${url}`;
|
||||||
|
if (dbType === 'postgres') {
|
||||||
|
const client = await pgPool.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
'SELECT html FROM cache WHERE url = $1 AND expires_at > $2',
|
||||||
|
[callsKey, now]
|
||||||
|
);
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
return JSON.parse(result.rows[0].html);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const getCached = db.prepare('SELECT html FROM cache WHERE url = ? AND expires_at > ?');
|
||||||
|
const result = getCached.get(callsKey, now);
|
||||||
|
return result ? JSON.parse(result.html) : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setCacheOutgoingCalls(url, callsData, now, expiresAt) {
|
||||||
|
const callsKey = `outgoing-calls:${url}`;
|
||||||
|
const html = JSON.stringify(callsData);
|
||||||
|
if (dbType === 'postgres') {
|
||||||
|
const client = await pgPool.connect();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO cache (url, html, created_at, expires_at) VALUES ($1, $2, $3, $4) ON CONFLICT (url) DO UPDATE SET html = $2, created_at = $3, expires_at = $4',
|
||||||
|
[callsKey, html, now, expiresAt]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const setCache = db.prepare('INSERT OR REPLACE INTO cache (url, html, created_at, expires_at) VALUES (?, ?, ?, ?)');
|
||||||
|
setCache.run(callsKey, html, now, expiresAt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCachedResultingUrl(url, now) {
|
||||||
|
const resultingUrlKey = `resulting-url:${url}`;
|
||||||
|
if (dbType === 'postgres') {
|
||||||
|
const client = await pgPool.connect();
|
||||||
|
try {
|
||||||
|
const result = await client.query(
|
||||||
|
'SELECT html FROM cache WHERE url = $1 AND expires_at > $2',
|
||||||
|
[resultingUrlKey, now]
|
||||||
|
);
|
||||||
|
if (result.rows.length > 0) {
|
||||||
|
return JSON.parse(result.rows[0].html);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const getCached = db.prepare('SELECT html FROM cache WHERE url = ? AND expires_at > ?');
|
||||||
|
const result = getCached.get(resultingUrlKey, now);
|
||||||
|
return result ? JSON.parse(result.html) : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async setCacheResultingUrl(url, resultingUrlData, now, expiresAt) {
|
||||||
|
const resultingUrlKey = `resulting-url:${url}`;
|
||||||
|
const html = JSON.stringify(resultingUrlData);
|
||||||
|
if (dbType === 'postgres') {
|
||||||
|
const client = await pgPool.connect();
|
||||||
|
try {
|
||||||
|
await client.query(
|
||||||
|
'INSERT INTO cache (url, html, created_at, expires_at) VALUES ($1, $2, $3, $4) ON CONFLICT (url) DO UPDATE SET html = $2, created_at = $3, expires_at = $4',
|
||||||
|
[resultingUrlKey, html, now, expiresAt]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const setCache = db.prepare('INSERT OR REPLACE INTO cache (url, html, created_at, expires_at) VALUES (?, ?, ?, ?)');
|
||||||
|
setCache.run(resultingUrlKey, html, now, expiresAt);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async deleteExpired(now) {
|
async deleteExpired(now) {
|
||||||
if (dbType === 'postgres') {
|
if (dbType === 'postgres') {
|
||||||
const client = await pgPool.connect();
|
const client = await pgPool.connect();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { fetchHtml, extractSeo, extractMetaTags } = require('./scraper');
|
const { fetchHtml, extractSeo, extractMetaTags, extractOutgoingCalls, extractResultingUrl } = require('./scraper');
|
||||||
const dbOps = require('./database');
|
const dbOps = require('./database');
|
||||||
const { authenticateApiKey } = require('./middleware');
|
const { authenticateApiKey } = require('./middleware');
|
||||||
const { isBrowserConnected } = require('./browser');
|
const { isBrowserConnected } = require('./browser');
|
||||||
@@ -155,4 +155,78 @@ router.get('/meta', authenticateApiKey, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Outgoing calls capture endpoint
|
||||||
|
router.get('/outgoing-calls', authenticateApiKey, async (req, res) => {
|
||||||
|
const { url, skipCache } = req.query;
|
||||||
|
|
||||||
|
// Validate URL parameter
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required parameter: url',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
let urlObj;
|
||||||
|
try {
|
||||||
|
urlObj = new URL(url);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid URL format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse skipCache (accept 'true', '1', 'yes', etc.)
|
||||||
|
const shouldSkipCache = skipCache === 'true' || skipCache === '1' || skipCache === 'yes';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const callsData = await extractOutgoingCalls(url, shouldSkipCache);
|
||||||
|
res.json(callsData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Outgoing calls capture error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
url: url,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resulting URL endpoint
|
||||||
|
router.get('/resulting-url', authenticateApiKey, async (req, res) => {
|
||||||
|
const { url, skipCache } = req.query;
|
||||||
|
|
||||||
|
// Validate URL parameter
|
||||||
|
if (!url) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Missing required parameter: url',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate URL format
|
||||||
|
let urlObj;
|
||||||
|
try {
|
||||||
|
urlObj = new URL(url);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Invalid URL format',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse skipCache (accept 'true', '1', 'yes', etc.)
|
||||||
|
const shouldSkipCache = skipCache === 'true' || skipCache === '1' || skipCache === 'yes';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resultingUrlData = await extractResultingUrl(url, shouldSkipCache);
|
||||||
|
res.json(resultingUrlData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Resulting URL error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
original_url: url,
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -289,8 +289,283 @@ async function extractMetaTags(url, skipCache = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function extractOutgoingCalls(url, skipCache = false) {
|
||||||
|
let page = null;
|
||||||
|
try {
|
||||||
|
// Check cache first (unless skipCache is true)
|
||||||
|
if (!skipCache) {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = await dbOps.getCachedOutgoingCalls(url, now);
|
||||||
|
if (cached) {
|
||||||
|
console.log(`Outgoing calls cache hit for: ${url}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Capturing outgoing calls from: ${url}`);
|
||||||
|
|
||||||
|
// Get browser instance
|
||||||
|
const browserInstance = await getBrowser();
|
||||||
|
|
||||||
|
// Create new page
|
||||||
|
page = await browserInstance.newPage();
|
||||||
|
|
||||||
|
// Set reasonable timeouts
|
||||||
|
page.setDefaultTimeout(30000);
|
||||||
|
page.setDefaultNavigationTimeout(30000);
|
||||||
|
|
||||||
|
// List to store all network requests
|
||||||
|
const networkRequests = [];
|
||||||
|
|
||||||
|
// Static asset extensions to skip
|
||||||
|
const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot'];
|
||||||
|
|
||||||
|
// Set up network request listener BEFORE navigation
|
||||||
|
page.on('request', (request) => {
|
||||||
|
const requestUrl = request.url();
|
||||||
|
const method = request.method();
|
||||||
|
const headers = request.headers();
|
||||||
|
|
||||||
|
// Skip static assets and common non-API requests
|
||||||
|
if (staticExtensions.some(ext => requestUrl.endsWith(ext))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip data URLs and blob URLs
|
||||||
|
if (requestUrl.startsWith('data:') || requestUrl.startsWith('blob:')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip same-origin requests that are likely static assets
|
||||||
|
if (requestUrl.startsWith(url) && staticExtensions.some(staticExt => requestUrl.toLowerCase().includes(staticExt))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the request details
|
||||||
|
const requestData = {
|
||||||
|
url: requestUrl,
|
||||||
|
method: method,
|
||||||
|
headers: headers,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
networkRequests.push(requestData);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Navigate to URL and wait for network to be idle
|
||||||
|
try {
|
||||||
|
await page.goto(url, {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error during page navigation: ${error.message}`);
|
||||||
|
// Continue anyway to capture any requests that were made
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait a bit more to catch any delayed requests
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Process and categorize the requests
|
||||||
|
const apiCalls = [];
|
||||||
|
for (const req of networkRequests) {
|
||||||
|
// Determine if this looks like an API call
|
||||||
|
let isApiCall = false;
|
||||||
|
let apiType = 'unknown';
|
||||||
|
|
||||||
|
const urlLower = req.url.toLowerCase();
|
||||||
|
const contentType = (req.headers['content-type'] || '').toLowerCase();
|
||||||
|
|
||||||
|
// Check for common API patterns
|
||||||
|
if (['/api/', '/rest/', '/graphql', '/json', '/xml'].some(pattern => urlLower.includes(pattern))) {
|
||||||
|
isApiCall = true;
|
||||||
|
apiType = 'rest';
|
||||||
|
} else if (req.url.endsWith('.json')) {
|
||||||
|
isApiCall = true;
|
||||||
|
apiType = 'json';
|
||||||
|
} else if (contentType.includes('application/json')) {
|
||||||
|
isApiCall = true;
|
||||||
|
apiType = 'json';
|
||||||
|
} else if (contentType.includes('application/xml')) {
|
||||||
|
isApiCall = true;
|
||||||
|
apiType = 'xml';
|
||||||
|
} else if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) {
|
||||||
|
// These methods are typically API calls
|
||||||
|
isApiCall = true;
|
||||||
|
apiType = 'rest';
|
||||||
|
} else if (['api.', 'rest.', 'graphql.'].some(domain => req.url.includes(domain))) {
|
||||||
|
isApiCall = true;
|
||||||
|
apiType = 'rest';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter headers (exclude common browser headers)
|
||||||
|
const filteredHeaders = {};
|
||||||
|
const excludedHeaders = ['user-agent', 'accept-encoding', 'accept-language', 'cache-control'];
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (!excludedHeaders.includes(key.toLowerCase())) {
|
||||||
|
filteredHeaders[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include all requests but mark API calls specifically
|
||||||
|
const callInfo = {
|
||||||
|
url: req.url,
|
||||||
|
method: req.method,
|
||||||
|
is_api_call: isApiCall,
|
||||||
|
api_type: isApiCall ? apiType : null,
|
||||||
|
headers: filteredHeaders
|
||||||
|
};
|
||||||
|
|
||||||
|
apiCalls.push(callInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by whether it's an API call (API calls first), then by URL
|
||||||
|
apiCalls.sort((a, b) => {
|
||||||
|
if (a.is_api_call !== b.is_api_call) {
|
||||||
|
// API calls first (true comes before false)
|
||||||
|
// If a is API call and b is not, a comes first (return -1)
|
||||||
|
// If a is not API call and b is, b comes first (return 1)
|
||||||
|
return a.is_api_call ? -1 : 1;
|
||||||
|
}
|
||||||
|
return a.url.localeCompare(b.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract unique domains
|
||||||
|
const domains = new Set();
|
||||||
|
for (const call of apiCalls) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(call.url);
|
||||||
|
domains.add(urlObj.hostname);
|
||||||
|
} catch (e) {
|
||||||
|
// Skip invalid URLs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
status: 'success',
|
||||||
|
url: url,
|
||||||
|
total_requests: apiCalls.length,
|
||||||
|
api_calls: apiCalls.filter(call => call.is_api_call),
|
||||||
|
other_requests: apiCalls.filter(call => !call.is_api_call),
|
||||||
|
summary: {
|
||||||
|
api_calls_count: apiCalls.filter(call => call.is_api_call).length,
|
||||||
|
other_requests_count: apiCalls.filter(call => !call.is_api_call).length,
|
||||||
|
unique_domains: domains.size
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + (config.CACHE_EXPIRY_HOURS * 60 * 60 * 1000);
|
||||||
|
await dbOps.setCacheOutgoingCalls(url, result, now, expiresAt);
|
||||||
|
console.log(`Cached outgoing calls for: ${url} (expires in ${config.CACHE_EXPIRY_HOURS}h)`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error capturing outgoing calls from ${url}:`, error.message);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
url: url,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Always close the page, even on error
|
||||||
|
if (page) {
|
||||||
|
try {
|
||||||
|
await page.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
console.error('Error closing page:', closeError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractResultingUrl(url, skipCache = false) {
|
||||||
|
let page = null;
|
||||||
|
try {
|
||||||
|
// Check cache first (unless skipCache is true)
|
||||||
|
if (!skipCache) {
|
||||||
|
const now = Date.now();
|
||||||
|
const cached = await dbOps.getCachedResultingUrl(url, now);
|
||||||
|
if (cached) {
|
||||||
|
console.log(`Resulting URL cache hit for: ${url}`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Getting resulting URL for: ${url}`);
|
||||||
|
|
||||||
|
// Get browser instance
|
||||||
|
const browserInstance = await getBrowser();
|
||||||
|
|
||||||
|
// Create new page
|
||||||
|
page = await browserInstance.newPage();
|
||||||
|
|
||||||
|
// Set reasonable timeouts
|
||||||
|
page.setDefaultTimeout(30000);
|
||||||
|
page.setDefaultNavigationTimeout(30000);
|
||||||
|
|
||||||
|
// Navigate to URL and wait for network to be idle
|
||||||
|
let response = null;
|
||||||
|
try {
|
||||||
|
response = await page.goto(url, {
|
||||||
|
waitUntil: 'networkidle',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Error during page navigation: ${error.message}`);
|
||||||
|
// Continue anyway to get the final URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the final URL after any redirects
|
||||||
|
const finalUrl = page.url;
|
||||||
|
|
||||||
|
// Get response status if available
|
||||||
|
const statusCode = response ? response.status() : null;
|
||||||
|
|
||||||
|
// Check if there was a redirect
|
||||||
|
const redirected = finalUrl !== url;
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
status: 'success',
|
||||||
|
original_url: url,
|
||||||
|
resulting_url: finalUrl,
|
||||||
|
status_code: statusCode,
|
||||||
|
redirected: redirected
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in cache
|
||||||
|
const now = Date.now();
|
||||||
|
const expiresAt = now + (config.CACHE_EXPIRY_HOURS * 60 * 60 * 1000);
|
||||||
|
await dbOps.setCacheResultingUrl(url, result, now, expiresAt);
|
||||||
|
console.log(`Cached resulting URL for: ${url} (expires in ${config.CACHE_EXPIRY_HOURS}h)`);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting resulting URL for ${url}:`, error.message);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
original_url: url,
|
||||||
|
error: error.message
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Always close the page, even on error
|
||||||
|
if (page) {
|
||||||
|
try {
|
||||||
|
await page.close();
|
||||||
|
} catch (closeError) {
|
||||||
|
console.error('Error closing page:', closeError.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchHtml,
|
fetchHtml,
|
||||||
extractSeo,
|
extractSeo,
|
||||||
extractMetaTags,
|
extractMetaTags,
|
||||||
|
extractOutgoingCalls,
|
||||||
|
extractResultingUrl,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user