wa-dev-tools/routes/parser.js

265 lines
9.6 KiB
JavaScript

const express = require('express');
const { JSDOM } = require('jsdom');
const { Readability } = require('@mozilla/readability');
const iconv = require('iconv-lite');
const geoip = require('geoip-lite');
const log = require('../lib/logger');
const { validateUrl } = require('../lib/ssrf');
const rateLimit = require('express-rate-limit');
const router = express.Router();
// Rate limit parser endpoints (prevent DDoS via server)
const parserLimiter = rateLimit({
keyGenerator: (req) => (req.session && req.session.user && req.session.user.id) ? "user_" + req.session.user.id : req.ip, windowMs: 60000, max: 20, message: { error: 'Слишком много запросов' } });
// In-memory cache (max 50 entries, 10 min TTL)
const cache = new Map();
const CACHE_TTL = 10 * 60 * 1000;
const CACHE_MAX = 50;
function getCached(url) {
const entry = cache.get(url);
if (!entry) return null;
if (Date.now() - entry.ts > CACHE_TTL) { cache.delete(url); return null; }
return entry.data;
}
function setCache(url, data) {
if (cache.size >= CACHE_MAX) {
const oldest = cache.keys().next().value;
cache.delete(oldest);
}
cache.set(url, { data, ts: Date.now() });
}
function getCountry(ip) {
const clean = ip.replace(/^::ffff:/, '');
if (clean === '127.0.0.1' || clean === '::1') return 'local';
const geo = geoip.lookup(clean);
return geo ? geo.country : '??';
}
function detectCharset(contentType, htmlBuffer) {
if (contentType) {
const match = contentType.match(/charset=([^\s;]+)/i);
if (match) return match[1].trim().toLowerCase();
}
const head = htmlBuffer.slice(0, 4096).toString('ascii');
const metaMatch = head.match(/charset=["']?([^"'\s;>]+)/i);
if (metaMatch) return metaMatch[1].trim().toLowerCase();
return 'utf-8';
}
async function fetchAndParse(url) {
const cached = getCached(url);
if (cached) return cached;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
let response;
try {
// Follow redirects manually, re-validating each hop against SSRF rules.
const MAX_REDIRECTS = 5;
let currentUrl = url;
for (let i = 0; ; i++) {
response = await fetch(currentUrl, {
signal: controller.signal,
redirect: 'manual',
headers: {
'User-Agent': 'Mozilla/5.0 (compatible; WAParser/1.0)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'ru,en;q=0.5',
},
});
if (![301, 302, 303, 307, 308].includes(response.status)) break;
if (i >= MAX_REDIRECTS) throw new Error('Слишком много редиректов');
const loc = response.headers.get('location');
if (!loc) break;
let nextUrl;
try { nextUrl = new URL(loc, currentUrl).href; } catch { throw new Error('Некорректный редирект'); }
const rcheck = await validateUrl(nextUrl);
if (!rcheck.safe) throw new Error('Редирект на запрещённый адрес');
currentUrl = rcheck.url;
}
} finally {
clearTimeout(timeout);
}
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('text/html') && !contentType.includes('application/xhtml')) {
throw new Error('URL не содержит HTML-страницу');
}
const contentLength = parseInt(response.headers.get('content-length') || '0', 10);
if (contentLength > 2 * 1024 * 1024) throw new Error('Страница слишком большая (>2MB)');
// Stream the body and abort as soon as the 2MB cap is exceeded (avoids buffering huge/unbounded responses)
const MAX_HTML = 2 * 1024 * 1024;
const bodyReader = response.body?.getReader();
const chunks = [];
let total = 0;
if (bodyReader) {
while (true) {
const { done, value } = await bodyReader.read();
if (done) break;
total += value.length;
if (total > MAX_HTML) { try { await bodyReader.cancel(); } catch {} throw new Error('Страница слишком большая (>2MB)'); }
chunks.push(value);
}
}
const htmlBuffer = Buffer.concat(chunks.map((c) => Buffer.from(c)));
const charset = detectCharset(contentType, htmlBuffer);
let html;
if (charset === 'utf-8' || charset === 'utf8') html = htmlBuffer.toString('utf-8');
else if (iconv.encodingExists(charset)) html = iconv.decode(htmlBuffer, charset);
else html = htmlBuffer.toString('utf-8');
const dom = new JSDOM(html, { url });
const doc = dom.window.document;
const getMeta = (name) => {
const el = doc.querySelector(`meta[property="${name}"], meta[name="${name}"]`);
return el ? el.getAttribute('content') : null;
};
const metadata = {
og_title: getMeta('og:title'),
og_description: getMeta('og:description'),
og_image: getMeta('og:image'),
og_site_name: getMeta('og:site_name'),
description: getMeta('description'),
author: getMeta('author') || getMeta('article:author'),
date: getMeta('article:published_time') || getMeta('date') || getMeta('pubdate'),
};
const reader = new Readability(doc);
const article = reader.parse();
let images = [];
let links = [];
if (article && article.content) {
const contentDom = new JSDOM(article.content);
const contentDoc = contentDom.window.document;
contentDoc.querySelectorAll('img').forEach(img => {
const src = img.getAttribute('src') || img.getAttribute('data-src');
if (src) { try { images.push(new URL(src, url).href); } catch {} }
});
contentDoc.querySelectorAll('a[href]').forEach(a => {
const href = a.getAttribute('href');
if (href && href.startsWith('http')) links.push({ text: a.textContent.trim(), href });
});
contentDom.window.close();
}
let cleanHtml = article ? article.content : '';
if (cleanHtml) {
const cleanDom = new JSDOM(cleanHtml);
const cleanDoc = cleanDom.window.document;
cleanDoc.querySelectorAll('script, style, iframe, object, embed').forEach(el => el.remove());
cleanDoc.querySelectorAll('*').forEach(el => {
Array.from(el.attributes).forEach(attr => {
const n = attr.name.toLowerCase();
if (n.startsWith('on') || n.startsWith('data-')) { el.removeAttribute(attr.name); return; }
// Strip dangerous URL schemes from href/src/action/xlink:href (javascript:, data:, vbscript:)
if (['href', 'src', 'action', 'formaction', 'xlink:href'].includes(n) && /^\s*(javascript|data|vbscript):/i.test(attr.value)) {
el.removeAttribute(attr.name);
}
});
});
cleanHtml = cleanDoc.body.innerHTML;
cleanDom.window.close();
}
const contentText = article ? article.textContent : '';
const wordCount = contentText ? contentText.trim().split(/\s+/).filter(Boolean).length : 0;
const result = {
url,
title: article ? article.title : (metadata.og_title || doc.title || ''),
author: metadata.author || (article ? article.byline : null),
site: metadata.og_site_name || new URL(url).hostname,
date_published: metadata.date || null,
excerpt: article ? article.excerpt : (metadata.og_description || metadata.description || ''),
content_html: cleanHtml,
content_text: contentText,
lead_image: metadata.og_image || (images.length > 0 ? images[0] : null),
images: [...new Set(images)],
links,
word_count: wordCount,
lang: article ? article.lang : (doc.documentElement.lang || null),
};
dom.window.close();
setCache(url, result);
return result;
}
// Full parse
router.get('/parse', parserLimiter, async (req, res) => {
const check = await validateUrl(req.query.url);
if (!check.safe) return res.status(400).json({ error: check.error });
try {
const result = await fetchAndParse(check.url);
const country = getCountry(req.ip);
const msg = `[${country}] ${req.ip} parse: ${check.url} → "${result.title}" (${result.word_count} words)`;
log.info(msg);
log.logToFile(msg);
res.json(result);
} catch (err) {
res.status(500).json({ error: err.message || 'Ошибка парсинга' });
}
});
// Metadata only
router.get('/metadata', parserLimiter, async (req, res) => {
const check = await validateUrl(req.query.url);
if (!check.safe) return res.status(400).json({ error: check.error });
try {
const result = await fetchAndParse(check.url);
res.json({
url: result.url, title: result.title, description: result.excerpt,
image: result.lead_image, site: result.site, author: result.author,
date_published: result.date_published, lang: result.lang,
});
} catch (err) {
res.status(500).json({ error: err.message || 'Ошибка' });
}
});
// Text only
router.get('/text', parserLimiter, async (req, res) => {
const check = await validateUrl(req.query.url);
if (!check.safe) return res.status(400).json({ error: check.error });
try {
const result = await fetchAndParse(check.url);
res.type('text/plain').send(result.content_text);
} catch (err) {
res.status(500).json({ error: err.message || 'Ошибка' });
}
});
// Preview card
router.get('/preview', parserLimiter, async (req, res) => {
const check = await validateUrl(req.query.url);
if (!check.safe) return res.status(400).json({ error: check.error });
try {
const result = await fetchAndParse(check.url);
res.json({ url: result.url, title: result.title, description: result.excerpt, image: result.lead_image, site: result.site });
} catch (err) {
res.status(500).json({ error: err.message || 'Ошибка' });
}
});
// Parser page
router.get('/parser', (req, res) => {
res.sendFile(require('path').join(__dirname, '..', 'public', 'parser.html'));
});
module.exports = router;