UNPKG

@blocklet/crawler

Version:

blocklet crawler lib

570 lines (569 loc) 20.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); var _exportNames = { api: true, sleep: true, clearCheckBrowserTimer: true, closeBrowser: true, getBrowser: true, CRAWLER_FLAG: true, isSelfCrawler: true, initPage: true, getDefaultRobotsUrl: true, getDefaultSitemapUrl: true, isAcceptCrawler: true, getSitemapList: true, isBotUserAgent: true, getBlockletPathname: true, getComponentInfo: true, getFullUrl: true, getRelativePath: true, cachePool: true, withCache: true, formatKey: true, useCache: true, checkBrowserDownloaded: true, getPuppeteerrc: true, puppeteer: true }; exports.isSelfCrawler = exports.isBotUserAgent = exports.isAcceptCrawler = exports.initPage = exports.getSitemapList = exports.getRelativePath = exports.getPuppeteerrc = exports.getFullUrl = exports.getDefaultSitemapUrl = exports.getDefaultRobotsUrl = exports.getComponentInfo = exports.getBrowser = exports.getBlockletPathname = exports.formatKey = exports.closeBrowser = exports.clearCheckBrowserTimer = exports.checkBrowserDownloaded = exports.cachePool = exports.api = exports.CRAWLER_FLAG = void 0; Object.defineProperty(exports, "puppeteer", { enumerable: true, get: function () { return _puppeteer.default; } }); exports.withCache = exports.useCache = exports.sleep = void 0; var _puppeteer = _interopRequireDefault(require("@blocklet/puppeteer")); var _ufo = require("ufo"); var _path = require("path"); var _axios = _interopRequireDefault(require("axios")); var _stream = require("stream"); var _fsExtra = _interopRequireDefault(require("fs-extra")); var _robotsParser = _interopRequireDefault(require("robots-parser")); var _redis = require("redis"); var _genericPool = require("generic-pool"); var _sitemap = require("sitemap"); var _flattenDeep = _interopRequireDefault(require("lodash/flattenDeep")); var _uniq = _interopRequireDefault(require("lodash/uniq")); var _config = _interopRequireWildcard(require("@blocklet/sdk/lib/config")); Object.keys(_config).forEach(function (key) { if (key === "default" || key === "__esModule") return; if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; if (key in exports && exports[key] === _config[key]) return; Object.defineProperty(exports, key, { enumerable: true, get: function () { return _config[key]; } }); }); var _util = require("util"); var _child_process = require("child_process"); var _component = require("@blocklet/sdk/lib/component"); function _getRequireWildcardCache(e) { if ("function" != typeof WeakMap) return null; var r = new WeakMap(), t = new WeakMap(); return (_getRequireWildcardCache = function (e) { return e ? t : r; })(e); } function _interopRequireWildcard(e, r) { if (!r && e && e.__esModule) return e; if (null === e || "object" != typeof e && "function" != typeof e) return { default: e }; var t = _getRequireWildcardCache(r); if (t && t.has(e)) return t.get(e); var n = { __proto__: null }, a = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var u in e) if ("default" !== u && {}.hasOwnProperty.call(e, u)) { var i = a ? Object.getOwnPropertyDescriptor(e, u) : null; i && (i.get || i.set) ? Object.defineProperty(n, u, i) : n[u] = e[u]; } return n.default = e, t && t.set(e, n), n; } function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; } const { logger } = _config.default; const execAsync = (0, _util.promisify)(_child_process.exec); let browser; let checkBrowserTimer; const BROWSER_WS_ENDPOINT_KEY = `browserWSEndpoint-${process.env.BLOCKLET_DID || "unknown"}`; let cachedRedisUrl = null; const api = exports.api = _axios.default.create({ timeout: 1e3 * 10, // 10s headers: { "Content-Type": "application/json" } }); const sleep = ms => { return new Promise(resolve => { setTimeout(resolve, ms); }); }; exports.sleep = sleep; const clearCheckBrowserTimer = () => { if (checkBrowserTimer) { clearInterval(checkBrowserTimer); checkBrowserTimer = null; } }; exports.clearCheckBrowserTimer = clearCheckBrowserTimer; 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.default.trimCache(); const { cacheDirectory, temporaryDirectory } = getPuppeteerrc(); if (cacheDirectory) { _fsExtra.default.emptyDirSync(cacheDirectory); } if (temporaryDirectory) { _fsExtra.default.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); } }; exports.closeBrowser = closeBrowser; 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.default.connect({ browserWSEndpoint }); logger.info("Connect browser success"); } else { throw new Error("no browserWSEndpoint to connect"); } }); } catch (error) { try { browser = await _puppeteer.default.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; }; exports.getBrowser = getBrowser; const CRAWLER_FLAG = exports.CRAWLER_FLAG = "x-crawler"; const isSelfCrawler = req => { const ua = req.get("user-agent") || ""; return req.get(CRAWLER_FLAG) === "true" || `${ua}`.toLowerCase().indexOf("headless") !== -1; }; exports.isSelfCrawler = isSelfCrawler; 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; }; exports.initPage = initPage; 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"]; const getDefaultRobotsUrl = req => { const { origin } = new URL(getFullUrl(req)); return (0, _ufo.joinURL)(origin, "robots.txt?nocache=1"); }; exports.getDefaultRobotsUrl = getDefaultRobotsUrl; const getDefaultSitemapUrl = req => { const { origin } = new URL(getFullUrl(req)); return (0, _ufo.joinURL)(origin, "sitemap.xml?nocache=1"); }; exports.getDefaultSitemapUrl = getDefaultSitemapUrl; const isAcceptCrawler = async req => { let acceptCrawler = true; const robotsUrl = getDefaultRobotsUrl(req); const { data: robotsTxt } = await api.get(robotsUrl).catch(() => ({ data: "" })); if (robotsTxt) { const robots = (0, _robotsParser.default)(robotsUrl, robotsTxt); acceptCrawler = !!(await robots.isAllowed(getFullUrl(req))); } return acceptCrawler; }; exports.isAcceptCrawler = isAcceptCrawler; 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 = (0, _robotsParser.default)(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 = _stream.Readable.from([sitemapTxt]); const sitemapJson = await (0, _sitemap.parseSitemap)(stream); return sitemapJson; } return []; })); return (0, _uniq.default)((0, _flattenDeep.default)(sitemapList.filter(Boolean))); }; exports.getSitemapList = getSitemapList; 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; }; exports.isBotUserAgent = isBotUserAgent; const getBlockletPathname = req => { return req.headers["x-path-prefix"] ? (0, _ufo.joinURL)(req.headers["x-path-prefix"], req.originalUrl) : req.originalUrl; }; exports.getBlockletPathname = getBlockletPathname; const getComponentInfo = () => { const { env, components } = _config.default; return components.find(item => item.did === env.componentDid) || {}; }; exports.getComponentInfo = getComponentInfo; const getFullUrl = req => { if (typeof req === "string") { return req; } return (0, _ufo.joinURL)(_config.default.env.appUrl, getBlockletPathname(req)); }; exports.getFullUrl = getFullUrl; const getRelativePath = url => { try { return new URL(url).pathname; } catch (error) {} return url; }; exports.getRelativePath = getRelativePath; 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 = (0, _component.getComponentWebEndpoint)(_config.default.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 = (0, _redis.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"; } const cachePool = exports.cachePool = (0, _genericPool.createPool)({ create: async () => { let cache = null; try { const redisUrl = await detectBestRedisUrl(); const redisClient = (0, _redis.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, }); const withCache = async cb => { const client = await cachePool.acquire(); if (client) { try { return await cb(client); } finally { await cachePool.release(client); } } }; exports.withCache = withCache; const cacheKeyPrefix = process.env?.BLOCKLET_REAL_DID ? process.env.BLOCKLET_REAL_DID + ":" : ""; const formatKey = key => { return `${cacheKeyPrefix}${key}`; }; exports.formatKey = formatKey; const useCache = exports.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)); }); } }; const checkBrowserDownloaded = async () => { try { const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH || "/usr/bin/chromium"; if (_fsExtra.default.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 Promise.resolve().then(() => require("@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); } }; exports.checkBrowserDownloaded = checkBrowserDownloaded; const getPuppeteerrc = () => { const cacheDir = process.env.BLOCKLET_CACHE_DIR; if (_fsExtra.default.existsSync(cacheDir) === false) { _fsExtra.default.mkdirSync(cacheDir, { recursive: true }); } const cacheDirectory = (0, _path.join)(cacheDir, "puppeteer", "cache"); const temporaryDirectory = (0, _path.join)(cacheDir, "puppeteer", "tmp"); _fsExtra.default.mkdirSync(cacheDirectory, { recursive: true }); _fsExtra.default.mkdirSync(temporaryDirectory, { recursive: true }); let config2 = { // Changes the cache location for Puppeteer. cacheDirectory, temporaryDirectory }; try { _fsExtra.default.ensureDirSync(cacheDirectory); _fsExtra.default.ensureDirSync(temporaryDirectory); } catch (error) { config2 = {}; } const fileContent = `module.exports = ${JSON.stringify(config2, null, 2)}`; const puppeteerrcPath = (0, _path.join)(process.env.BLOCKLET_APP_DIR, ".puppeteerrc.js"); try { const puppeteerrcContent = _fsExtra.default.readFileSync(puppeteerrcPath, "utf-8"); if (puppeteerrcContent !== fileContent) { throw new Error(".puppeteerrc.js not right"); } } catch (error) { logger.info("Resolve the .puppeteerrc.js error bug"); _fsExtra.default.writeFileSync(puppeteerrcPath, fileContent); } return config2; }; exports.getPuppeteerrc = getPuppeteerrc;