Files
crm-ai/bridge/src/faq-fetcher.js
T

125 lines
3.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('node:vm');
function stripHtml(value) {
return String(value || '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/\s+/g, ' ')
.trim();
}
function extractFaqFromLdJson(html, max = 50) {
const re = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(String(html)))) {
try {
const data = JSON.parse(m[1].trim());
const candidates = Array.isArray(data) ? data : [data];
for (const item of candidates) {
const main = item && (item.mainEntity || (item['@type'] === 'FAQPage' && item.mainEntity));
if (!Array.isArray(main)) continue;
const out = main
.filter((q) => q && q.name && q.acceptedAnswer && q.acceptedAnswer.text)
.map((q) => ({ q: stripHtml(q.name), a: stripHtml(q.acceptedAnswer.text) }))
.filter((q) => q.q && q.a)
.slice(0, max);
if (out.length) return out;
}
} catch {
// 非 JSON-LD 块忽略
}
}
return [];
}
// Nuxt SSR 页面(如 dropshipping.yehwang.com)没有 FAQPage JSON-LD,
// FAQ 数据内嵌在 window.__NUXT__ 的 payload 里(data[].info.content = [{title, content(HTML)}])。
// 在隔离 sandbox 里求值这段自执行脚本,只取回返回对象,不接触宿主全局。
function extractNuxtPayload(html) {
const m = String(html || '').match(/__NUXT__=([\s\S]*?)<\/script>/);
if (!m) return null;
try {
return vm.runInNewContext(m[1].replace(/;\s*$/, ''), Object.create(null), { timeout: 2000 });
} catch {
return null;
}
}
function extractFaqFromNuxt(html, max = 50) {
const payload = extractNuxtPayload(html);
if (!payload || !payload.data) return [];
const out = [];
const seen = new Set();
const walk = (node) => {
if (out.length >= max) return;
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (!node || typeof node !== 'object') return;
if (typeof node.title === 'string' && typeof node.content === 'string') {
const q = stripHtml(node.title);
const a = stripHtml(node.content);
if (q && a && !seen.has(q)) {
seen.add(q);
out.push({ q, a });
}
}
for (const value of Object.values(node)) {
if (value && typeof value === 'object') walk(value);
}
};
walk(payload.data);
return out;
}
function loadFaqConfig(file) {
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return { enabled: false, pages: [] };
}
}
function createFaqFetcher(config = {}) {
const pages = config.pages || [];
const timeoutMs = config.timeoutMs || 15000;
const max = config.maxFaqsPerPage || 50;
async function fetchPage(page) {
const res = await fetch(page.url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36', Accept: 'text/html,application/xhtml+xml' },
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) throw new Error(`fetch ${page.url} failed: HTTP ${res.status}`);
const html = await res.text();
return page.extractor === 'nuxt' ? extractFaqFromNuxt(html, max) : extractFaqFromLdJson(html, max);
}
async function fetchAll() {
const out = [];
for (const page of pages) {
try {
const questions = await fetchPage(page);
out.push({ ...page, questions });
} catch (err) {
out.push({ ...page, questions: [], error: err.message });
}
}
return out;
}
return { fetchPage, fetchAll, pages };
}
module.exports = { createFaqFetcher, loadFaqConfig, extractFaqFromLdJson, extractFaqFromNuxt, extractNuxtPayload, stripHtml };