@blocklet/crawler
Version:
blocklet crawler lib
314 lines (313 loc) • 9.85 kB
JavaScript
import * as PQueue from "p-queue";
import { joinURL } from "ufo";
import {
logger,
initPage,
useCache,
getSitemapList,
isAcceptCrawler,
env,
components,
getComponentInfo,
sleep,
closeBrowser,
getBrowser,
getRelativePath
} from "./utils.js";
import Cron from "@abtnode/cron";
import debounce from "lodash/debounce";
export { getRelativePath };
const formatHtml = (htmlString) => {
if (typeof htmlString !== "string") {
return "";
}
if (htmlString.includes("<h2>Unexpected Application Error!</h2>")) {
return "";
}
return htmlString;
};
const crawlQueue = new PQueue.default({ concurrency: 1, timeout: 60 * 1e3 });
export const getPageContent = async ({ url, formatPageContent }) => {
let page = await initPage();
let pageContent = null;
try {
const response = await page.goto(url, {
timeout: 60 * 1e3
// 60s
});
if (!response) {
throw new Error(`Failed to load page: response is null for ${url}`);
}
const statusCode = response.status();
if (![200, 304].includes(statusCode)) {
throw new Error(`Request failed with status ${statusCode}, in ${url}`);
}
await page.waitForNetworkIdle({
idleTime: 1 * 1e3
// 1s
});
if (formatPageContent) {
pageContent = await formatPageContent({ page, url });
} else {
pageContent = await page.content();
}
} catch (error) {
logger.error("Get page content error:", error.message || error);
} finally {
await page.close();
}
return formatHtml(pageContent);
};
export const getUrlInfoFromCache = async (url) => {
const cache = await useCache.get(getRelativePath(url));
return cache;
};
function getNextCrawlDate(lastmod) {
const now = /* @__PURE__ */ new Date();
const lastModTime = lastmod ? new Date(lastmod).getTime() || 0 : 0;
const daysDiff = Math.max(0, (now.getTime() - lastModTime) / (24 * 60 * 60 * 1e3));
const CRAWL_INTERVALS = /* @__PURE__ */ new Map([
[[-1, 0], 1],
// 无 lastmod
[[0, 3], 1],
// 3 天内活跃
[[3, 7], 3],
// 7 天内活跃
[[7, 30], 14],
// 30 天内活跃
[[30, 90], 30],
// 90 天内活跃
[[90, Infinity], 365]
// 长期不活跃
]);
const interval = Array.from(CRAWL_INTERVALS.entries()).find(
([[min, max]]) => lastmod ? daysDiff > min && daysDiff <= max : min === -1
)?.[1] || 90;
return new Date(now.getTime() + interval * 24 * 60 * 60 * 1e3).toISOString();
}
export const setUrlInfoToCache = async ({
url,
content,
lastmod,
nextDate
}) => {
if (!content || !url) {
return;
}
const lastmodValue = lastmod || (/* @__PURE__ */ new Date()).toISOString();
return await useCache.set(getRelativePath(url), {
content,
lastmod: lastmodValue,
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
nextDate: nextDate || getNextCrawlDate(lastmodValue)
});
};
export const crawlUrl = async ({
urls,
lastmodMap,
formatPageContent,
autoCloseBrowserCount = 50
}) => {
if (typeof urls === "string") {
urls = [urls];
}
const crawlUrlJob = ({ url, retryCount = 0, index }) => {
return async () => {
try {
if (index % autoCloseBrowserCount === 0) {
await closeBrowser({
trimCache: false
});
}
const canCrawl = await isAcceptCrawler(url);
if (canCrawl) {
const pageContent = await getPageContent({
url,
formatPageContent
});
if (pageContent) {
const lastmodValue = lastmodMap?.get(url) || (/* @__PURE__ */ new Date()).toISOString();
await setUrlInfoToCache({
url,
content: pageContent,
lastmod: lastmodValue,
nextDate: getNextCrawlDate(lastmodValue)
});
logger.info(`Crawler[${index}] ${url} success`);
} else if (retryCount < 3) {
retryCount++;
const timeout = 1e3 * 5 * retryCount;
logger.info(`Crawler[${index}] ${url} fail, continue after ${timeout}ms`);
await sleep(timeout);
crawlQueue.add(crawlUrlJob({ url, retryCount, index }), {
priority: retryCount
// Operations with greater priority will be scheduled first.
});
} else {
logger.info(`Crawler[${index}] ${url} fail reach 3 times, skip it`);
}
} else {
logger.info(`Crawler[${index}] ${url} skip`);
}
if (global.gc) {
global.gc();
}
} catch (error) {
logger.info(`Crawler[${index}] ${url} abort by error, skip it: ${error?.message || error?.reason || error}`);
}
};
};
for (const [index, url] of urls.entries()) {
await crawlQueue.add(crawlUrlJob({ url, index: index + 1 }));
}
};
const crawlBlockletRunningMap = /* @__PURE__ */ new Map();
export const crawlBlocklet = async () => {
const { mountPoint, did } = getComponentInfo();
if (crawlBlockletRunningMap.has(did) && crawlBlockletRunningMap.get(did)) {
logger.info(`Crawler blocklet ${did} is running, skip it`);
return;
}
try {
const browser = await getBrowser();
if (!browser) {
throw new Error("No Browser can use");
}
logger.info("Crawler blocklet existing can use browser");
} catch (error) {
logger.info(`Crawler blocklet abort by error: ${error?.message || error?.reason || error}`);
return;
}
const { appUrl } = env;
if (!appUrl) {
throw new Error("appUrl not found");
}
const sitemapList = await getSitemapList(appUrl);
const matchMountPoint = joinURL(appUrl, !mountPoint || mountPoint === "/" ? "" : mountPoint);
const otherMountPointList = components.filter((item) => item.mountPoint && item.mountPoint !== mountPoint).map((item) => item.mountPoint);
const blockletLocList = sitemapList.filter((item) => {
if (mountPoint !== "/") {
return item?.url?.indexOf(matchMountPoint) > -1;
}
return otherMountPointList.every((mountPoint2) => item?.url?.indexOf(mountPoint2) === -1);
});
const canUseBlockletLocList = [];
const lastmodMap = /* @__PURE__ */ new Map();
let skipBlockletLocTotal = 0;
let blockletLocTotal = 0;
await Promise.all(
blockletLocList.map(async (item) => {
let tempLocList = [];
if (item.url) {
tempLocList.push(item.url);
}
if (item?.links?.length > 0) {
tempLocList.push(...item.links?.map((ytem) => ytem.url));
}
blockletLocTotal += tempLocList.length;
tempLocList = (await Promise.all(
tempLocList.map(async (loc) => {
try {
const { lastmod: cacheLastmod, nextDate } = await useCache.get(getRelativePath(loc));
if (item.lastmod && new Date(cacheLastmod).getTime() === new Date(item.lastmod).getTime()) {
skipBlockletLocTotal++;
return false;
}
if (nextDate && new Date(nextDate).getTime() >= (/* @__PURE__ */ new Date()).getTime()) {
skipBlockletLocTotal++;
return false;
}
return loc;
} catch (error) {
}
return loc;
})
)).filter(Boolean);
tempLocList.forEach((loc) => {
if (item.lastmod) lastmodMap.set(loc, item.lastmod);
});
canUseBlockletLocList.push(...tempLocList);
})
);
const crawlerLogText = (step = "") => [
`Crawler sitemap.xml about ${did} ${step}: `,
{
blockletLocTotal,
canUseBlockletLocTotal: canUseBlockletLocList.length,
skipBlockletLocTotal,
lastmodMapTotal: lastmodMap.size
}
];
logger.info(...crawlerLogText("start"));
try {
crawlBlockletRunningMap.set(did, true);
await crawlUrl({
// @ts-ignore
urls: canUseBlockletLocList,
lastmodMap,
formatPageContent: async ({ page, url }) => {
const pageContent = await page.evaluate(() => {
const removeElements = (tagName) => {
const elements = document.querySelectorAll(tagName);
for (let i = elements.length - 1; i >= 0; i--) {
try {
elements[i]?.parentNode?.removeChild(elements[i]);
} catch (error) {
}
}
};
removeElements('[id="uploader-container"]');
removeElements('[class^="uppy-"]');
removeElements('[id="point-up-component"]');
const meta = document.createElement("meta");
meta.name = "blocklet-crawler";
meta.content = "true";
document.head.appendChild(meta);
return document.documentElement.outerHTML;
});
return pageContent;
}
});
logger.info(...crawlerLogText("success"));
await closeBrowser({
trimCache: true
});
} catch (error) {
logger.info(`Crawler blocklet abort by error`, error);
} finally {
crawlBlockletRunningMap.delete(did);
}
};
const CRON_CRAWL_BLOCKLET_KEY = "cron-crawl-blocklet";
let cronCrawlBlockletJob = null;
export const initCronCrawlBlocklet = ({
time = "0 0 */12 * * *",
// every 12 hours
options
} = {}) => {
if (!cronCrawlBlockletJob) {
cronCrawlBlockletJob = Cron.init({
context: {},
jobs: [
{
name: CRON_CRAWL_BLOCKLET_KEY,
time,
fn: debounce(crawlBlocklet),
options: { runOnInit: false, ...options }
}
],
onError: (err) => {
console.error("run job failed", err);
}
});
}
return cronCrawlBlockletJob;
};
export const cancelCronCrawlBlocklet = () => {
if (cronCrawlBlockletJob) {
cronCrawlBlockletJob.jobs[CRON_CRAWL_BLOCKLET_KEY].stop();
cronCrawlBlockletJob = null;
crawlQueue.clear();
logger.info("Cron crawl blocklet stop, clear crawl queue");
}
};