@blocklet/crawler
Version:
blocklet crawler lib
592 lines (591 loc) • 16.9 kB
JavaScript
import puppeteer from "@blocklet/puppeteer";
import { joinURL } from "ufo";
import { join } from "path";
import axios from "axios";
import { Readable } from "stream";
import fs from "fs-extra";
import robotsParser from "robots-parser";
import { createClient } from "redis";
import { createPool } from "generic-pool";
import { parseSitemap } from "sitemap";
import flattenDeep from "lodash/flattenDeep";
import uniq from "lodash/uniq";
import config from "@blocklet/sdk/lib/config";
import { promisify } from "util";
import { exec } from "child_process";
import { getComponentWebEndpoint } from "@blocklet/sdk/lib/component";
export * from "@blocklet/sdk/lib/config";
const { logger } = config;
const execAsync = promisify(exec);
let browser;
let checkBrowserTimer;
const BROWSER_WS_ENDPOINT_KEY = `browserWSEndpoint-${process.env.BLOCKLET_DID || "unknown"}`;
let cachedRedisUrl = null;
export const api = axios.create({
timeout: 1e3 * 10,
// 10s
headers: {
"Content-Type": "application/json"
}
});
export const sleep = (ms) => {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
};
export const clearCheckBrowserTimer = () => {
if (checkBrowserTimer) {
clearInterval(checkBrowserTimer);
checkBrowserTimer = null;
}
};
export const closeBrowser = async ({ trimCache = true } = {}) => {
try {
if (browser) {
const pages = await browser.pages().catch(() => []);
await Promise.all(pages.map((page) => page.close().catch(() => {
})));
await browser.close().catch((err) => {
logger.warn("Browser close failed with error:", err);
});
browser = null;
await useCache.remove(BROWSER_WS_ENDPOINT_KEY);
}
clearCheckBrowserTimer();
if (trimCache) {
await puppeteer.trimCache();
const { cacheDirectory, temporaryDirectory } = getPuppeteerrc();
if (cacheDirectory) {
fs.emptyDirSync(cacheDirectory);
}
if (temporaryDirectory) {
fs.emptyDirSync(temporaryDirectory);
}
logger.info("Trim cache success");
}
logger.info("Close browser success");
if (global.gc) {
global.gc();
}
} catch (error) {
logger.error("Failed to close browser:", error);
}
};
export const getBrowser = async () => {
if (!browser) {
await sleep(Math.floor(Math.random() * 1e3 * 5));
await checkBrowserDownloaded();
try {
await withCache(async (client) => {
let browserWSEndpoint = await client.get(BROWSER_WS_ENDPOINT_KEY);
if (!browserWSEndpoint) {
await sleep(Math.floor(Math.random() * 1e3 * 5));
browserWSEndpoint = await client.get(BROWSER_WS_ENDPOINT_KEY);
}
if (browserWSEndpoint) {
browser = await puppeteer.connect({
browserWSEndpoint
});
logger.info("Connect browser success");
} else {
throw new Error("no browserWSEndpoint to connect");
}
});
} catch (error) {
try {
browser = await puppeteer.launch({
headless: true,
args: [
// docs: https://peter.sh/experiments/chromium-command-line-switches/
"--no-first-run",
"--hide-scrollbars",
"--no-sandbox",
"--no-zygote",
"--disable-setuid-sandbox",
"--disable-gpu",
"--disable-dev-shm-usage",
"--disable-site-isolation-trials",
"--disable-accelerated-2d-canvas",
"--disable-extensions",
"--js-flags=--max_old_space_size=512",
// 限制V8内存
"--disable-background-networking",
"--disable-default-apps",
// '--disable-web-security', // 允许跨域请求
"--disable-software-rasterizer",
"--disable-crash-reporter",
"--disable-service-workers",
"--disable-notifications",
"--disable-infobars",
"--font-render-hinting=none"
]
});
logger.info("Launch browser success");
const browserWSEndpoint = await browser.wsEndpoint();
await withCache(async (client) => {
await client.set(BROWSER_WS_ENDPOINT_KEY, browserWSEndpoint);
});
} catch (error2) {
logger.error("launch browser failed: ", error2);
}
} finally {
if (browser) {
let count = 0;
checkBrowserTimer = setInterval(async () => {
if (browser) {
const pages = await browser.pages().catch(() => []);
if (pages.length === 1 && pages[0].url() === "about:blank") {
count++;
logger.debug(`Browser inactive count: ${count}/3`);
} else {
count = 0;
}
if (count >= 3) {
logger.info("Browser inactive for 3 minutes, closing...");
await closeBrowser({
trimCache: true
});
}
}
}, 1e3 * 60);
}
}
}
if (!browser) {
throw new Error("No browser to use, should install redis or browser");
}
return browser;
};
export const CRAWLER_FLAG = "x-crawler";
export const isSelfCrawler = (req) => {
const ua = req.get("user-agent") || "";
return req.get(CRAWLER_FLAG) === "true" || `${ua}`.toLowerCase().indexOf("headless") !== -1;
};
export const initPage = async ({
abortResourceTypes = [
"image",
// 图片
"media",
// 媒体文件
"font",
// 字体
"websocket",
// websocket 连接
"manifest",
// manifest 文件
"other"
// 其他资源
]
} = {}) => {
const browser2 = await getBrowser();
const page = await browser2.newPage();
await page.setViewport({ width: 1440, height: 900 });
await page.setExtraHTTPHeaders({
[CRAWLER_FLAG]: "true"
});
if (abortResourceTypes.length > 0) {
await page.setRequestInterception(true);
page.on("request", (req) => {
if (abortResourceTypes.includes(req.resourceType())) {
return req.abort();
}
return req.continue();
});
}
return page;
};
const botUserAgents = [
/bot/i,
/spider/i,
/facebookexternalhit/i,
/simplepie/i,
/yahooseeker/i,
/embedly/i,
/quora link preview/i,
/outbrain/i,
/vkshare/i,
/monit/i,
/Pingability/i,
/Monitoring/i,
/WinHttpRequest/i,
/Apache-HttpClient/i,
/getprismatic.com/i,
/python-requests/i,
/Twurly/i,
/yandex/i,
/browserproxy/i,
/crawler/i,
/Qwantify/i,
/Yahoo/i,
/pinterest/i,
/Tumblr/i,
/Tumblr Agent/i,
/WhatsApp/i,
/Google-Structured-Data-Testing-Tool/i,
/Google-InspectionTool/i,
/Googlebot/i,
/GPTBot/i,
/Applebot/i,
// AI bots
/Anthropic-ai/i,
/Claude-Web/i,
/anthropic-ai-scraper/i,
/Google-Extended/i,
/GoogleOther/i,
/CCBot\/\d/i,
/Bytespider/i,
/BingBot/i,
/Baiduspider/i,
/Sogou/i,
/Perplexity/i,
/Cohere-ai/i,
/xlts-bot/i,
/THAAS/i,
/YisouSpider/i,
/AlibabaGroup/i,
/adaptive-edge-crawler/i
];
const isSpider = (ua) => botUserAgents.some((spider) => {
return spider.test(ua);
});
const staticFileExtensions = [
"ai",
"avi",
"css",
"dat",
"dmg",
"doc",
"doc",
"exe",
"flv",
"gif",
"ico",
"iso",
"jpeg",
"jpg",
"js",
"less",
"m4a",
"m4v",
"mov",
"mp3",
"mp4",
"mpeg",
"mpg",
"pdf",
"png",
"ppt",
"psd",
"rar",
"rss",
"svg",
"swf",
"tif",
"torrent",
"ttf",
"txt",
"wav",
"wmv",
"woff",
"xls",
"xml",
"zip"
];
export const getDefaultRobotsUrl = (req) => {
const { origin } = new URL(getFullUrl(req));
return joinURL(origin, "robots.txt?nocache=1");
};
export const getDefaultSitemapUrl = (req) => {
const { origin } = new URL(getFullUrl(req));
return joinURL(origin, "sitemap.xml?nocache=1");
};
export const isAcceptCrawler = async (req) => {
let acceptCrawler = true;
const robotsUrl = getDefaultRobotsUrl(req);
const { data: robotsTxt } = await api.get(robotsUrl).catch(() => ({
data: ""
}));
if (robotsTxt) {
const robots = robotsParser(robotsUrl, robotsTxt);
acceptCrawler = !!await robots.isAllowed(getFullUrl(req));
}
return acceptCrawler;
};
export const getSitemapList = async (req) => {
const robotsUrl = getDefaultRobotsUrl(req);
let sitemapUrlList = [getDefaultSitemapUrl(req)];
const { data: robotsTxt } = await api.get(robotsUrl).catch(() => ({
data: ""
}));
if (robotsTxt) {
const robots = robotsParser(robotsUrl, robotsTxt);
let robotsTxtSitemapUrlList = await robots.getSitemaps() || [];
if (robotsTxtSitemapUrlList.length > 0) {
sitemapUrlList = robotsTxtSitemapUrlList;
}
}
const sitemapList = await Promise.all(
sitemapUrlList.map(async (sitemapUrl) => {
const newUrl = new URL(sitemapUrl);
newUrl.searchParams.set("nocache", "1");
sitemapUrl = newUrl.toString();
const { data: sitemapTxt } = await api.get(sitemapUrl).catch(() => ({
data: ""
}));
if (sitemapTxt) {
const stream = Readable.from([sitemapTxt]);
const sitemapJson = await parseSitemap(stream);
return sitemapJson;
}
return [];
})
);
return uniq(flattenDeep(sitemapList.filter(Boolean)));
};
export const isBotUserAgent = (req) => {
const ua = req.get("user-agent");
const excludeUrlPattern = new RegExp(`\\.(${staticFileExtensions.join("|")})$`, "i");
if (ua === void 0 || !isSpider(ua) || excludeUrlPattern.test(req.path)) {
return false;
}
return true;
};
export const getBlockletPathname = (req) => {
return req.headers["x-path-prefix"] ? joinURL(req.headers["x-path-prefix"], req.originalUrl) : req.originalUrl;
};
export const getComponentInfo = () => {
const { env, components } = config;
return components.find((item) => item.did === env.componentDid) || {};
};
export const getFullUrl = (req) => {
if (typeof req === "string") {
return req;
}
return joinURL(config.env.appUrl, getBlockletPathname(req));
};
export const getRelativePath = (url) => {
try {
return new URL(url).pathname;
} catch (error) {
}
return url;
};
let ttl = 1e3 * 60 * 60 * 24 * 7;
const MAX_REDIS_RETRY = 3;
async function getDefaultGateway() {
try {
const { stdout } = await execAsync("cat /proc/net/route | grep '^eth0' | head -1 | awk '{print $3}'");
if (stdout.trim()) {
const hex = stdout.trim();
const ip = [
parseInt(hex.slice(6, 8), 16),
parseInt(hex.slice(4, 6), 16),
parseInt(hex.slice(2, 4), 16),
parseInt(hex.slice(0, 2), 16)
].join(".");
return ip;
}
} catch (err) {
}
return null;
}
async function detectBestRedisUrl() {
if (cachedRedisUrl) {
logger.info(`Using cached Redis connection: ${cachedRedisUrl}`);
return cachedRedisUrl;
}
logger.info("Starting to detect best Redis connection...");
const defaultGateway = await getDefaultGateway();
const possibleUrls = [
// environment variable priority
process.env.REDIS_URL,
// by blocklet host
process.env.BLOCKLET_HOST ? `redis://${process.env.BLOCKLET_HOST}:6379` : null,
// default gateway
defaultGateway ? `redis://${defaultGateway}:6379` : null,
// by web component endpoint
(() => {
try {
const endpoint = getComponentWebEndpoint(config.env.componentDid);
if (endpoint) {
const url = new URL(endpoint);
return `redis://${url.hostname}:6379`;
}
} catch (err) {
logger.warn(`Failed to get component endpoint: ${err.message}`);
}
return null;
})(),
// common Docker gateway
"redis://192.168.0.1:6379",
// Docker special DNS
"redis://host.docker.internal:6379",
// local Redis (last try)
"redis://localhost:6379"
].filter(Boolean);
for (const url of possibleUrls) {
try {
logger.debug(`Testing Redis connection: ${url}`);
const testClient = createClient({ url });
testClient.on("error", () => {
});
await Promise.race([
testClient.connect(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Connection timeout")), 2e3))
]);
await testClient.ping();
await testClient.disconnect();
logger.info(`\u2705 Found available Redis connection: ${url}`);
cachedRedisUrl = url;
return url;
} catch (err) {
logger.debug(`Redis connection failed ${url}: ${err.message}`);
}
}
logger.warn("\u26A0\uFE0F No available Redis connection found, using default value");
return "redis://localhost:6379";
}
export const cachePool = createPool(
{
create: async () => {
let cache = null;
try {
const redisUrl = await detectBestRedisUrl();
const redisClient = createClient({
url: redisUrl,
socket: {
// @ts-ignore
reconnectStrategy: (retries) => {
if (retries >= MAX_REDIS_RETRY) {
return new Error("Retry Time Exhausted");
}
return Math.min(retries * 500, 1e3 * 3);
}
}
});
redisClient.on("error", (err) => logger.warn("Redis Client Error:", err));
await redisClient.connect();
logger.info(`Successfully connected to Redis: ${redisUrl}`);
cache = redisClient;
} catch (error) {
logger.warn("Redis connection failed", error);
}
return cache;
},
destroy: async (client) => {
if (client.isReady) {
await client.quit();
}
}
},
{
max: 2,
// 2 clients
min: 0
// evictionRunIntervalMillis: 0,
}
);
export const withCache = async (cb) => {
const client = await cachePool.acquire();
if (client) {
try {
return await cb(client);
} finally {
await cachePool.release(client);
}
}
};
const cacheKeyPrefix = process.env?.BLOCKLET_REAL_DID ? process.env.BLOCKLET_REAL_DID + ":" : "";
export const formatKey = (key) => {
return `${cacheKeyPrefix}${key}`;
};
export const useCache = {
get: async (key) => {
return withCache(async (client) => {
const value = await client.get(formatKey(key));
try {
return JSON.parse(value);
} catch (error) {
}
return value;
});
},
set: async (key, value, options) => {
return withCache((client) => {
const formatValue = typeof value === "string" ? value : JSON.stringify(value);
return client.set(formatKey(key), formatValue, { PX: ttl, ...options });
});
},
remove: async (key) => {
return withCache((client) => {
return client.del(formatKey(key));
});
},
list: async (key = "*") => {
return withCache((client) => {
return client.keys(formatKey(key));
});
}
};
export const checkBrowserDownloaded = async () => {
try {
const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium";
if (fs.existsSync(executablePath)) {
try {
logger.info(`System Chromium found and tested successfully: ${executablePath}`);
return;
} catch (err) {
logger.warn(`System Chromium exists but test failed, will try to download`);
}
}
const { downloadBrowser } = await (async () => {
try {
return await import("@blocklet/puppeteer/internal/node/install.js");
} catch (err) {
logger.warn(
"Skipping browser installation because the Puppeteer build is not available. Run `npm install` again after you have re-built Puppeteer."
);
}
})();
if (downloadBrowser) {
await downloadBrowser();
logger.info("Browser download completed successfully");
}
} catch (error) {
logger.warn("Browser download failed", error);
}
};
export const getPuppeteerrc = () => {
const cacheDir = process.env.BLOCKLET_CACHE_DIR;
if (fs.existsSync(cacheDir) === false) {
fs.mkdirSync(cacheDir, { recursive: true });
}
const cacheDirectory = join(cacheDir, "puppeteer", "cache");
const temporaryDirectory = join(cacheDir, "puppeteer", "tmp");
fs.mkdirSync(cacheDirectory, { recursive: true });
fs.mkdirSync(temporaryDirectory, { recursive: true });
let config2 = {
// Changes the cache location for Puppeteer.
cacheDirectory,
temporaryDirectory
};
try {
fs.ensureDirSync(cacheDirectory);
fs.ensureDirSync(temporaryDirectory);
} catch (error) {
config2 = {};
}
const fileContent = `module.exports = ${JSON.stringify(config2, null, 2)}`;
const puppeteerrcPath = join(process.env.BLOCKLET_APP_DIR, ".puppeteerrc.js");
try {
const puppeteerrcContent = fs.readFileSync(puppeteerrcPath, "utf-8");
if (puppeteerrcContent !== fileContent) {
throw new Error(".puppeteerrc.js not right");
}
} catch (error) {
logger.info("Resolve the .puppeteerrc.js error bug");
fs.writeFileSync(puppeteerrcPath, fileContent);
}
return config2;
};
export { puppeteer };