diff --git a/application/package.json b/application/package.json index 2ab517d..04bef0b 100644 --- a/application/package.json +++ b/application/package.json @@ -26,6 +26,7 @@ "geoip-lite": "^1.4.6", "npmlog": "^6.0.0", "rimraf": "^3.0.2", + "robots-parser": "^3.0.0", "striptags": "^3.2.0", "typescript-collections": "^1.3.3", "zod": "^3.19.1" diff --git a/application/src/Fediverse/NodeInfo/retrieveDomainNodeInfo.ts b/application/src/Fediverse/NodeInfo/retrieveDomainNodeInfo.ts index a9b1bf6..bf49dc7 100644 --- a/application/src/Fediverse/NodeInfo/retrieveDomainNodeInfo.ts +++ b/application/src/Fediverse/NodeInfo/retrieveDomainNodeInfo.ts @@ -1,16 +1,18 @@ +import RobotsTxt from '../RobotsTxt/RobotsTxt.js' import { retrieveWellKnown } from './retrieveWellKnown' import { retrieveNodeInfo, NodeInfo } from './retrieveNodeInfo' import { NoSupportedLinkError } from './NoSupportedLinkError' export const retrieveDomainNodeInfo = async ( - domain: string + domain: string, + robotsTxt: RobotsTxt ): Promise => { - const wellKnown = await retrieveWellKnown(domain) + const wellKnown = await retrieveWellKnown(domain, robotsTxt) const link = wellKnown.links.find( (link) => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0' ) if (typeof link === 'undefined') { throw new NoSupportedLinkError(domain) } - return await retrieveNodeInfo(link.href) + return await retrieveNodeInfo(link.href, robotsTxt) } diff --git a/application/src/Fediverse/NodeInfo/retrieveNodeInfo.ts b/application/src/Fediverse/NodeInfo/retrieveNodeInfo.ts index a70c06c..8428d91 100644 --- a/application/src/Fediverse/NodeInfo/retrieveNodeInfo.ts +++ b/application/src/Fediverse/NodeInfo/retrieveNodeInfo.ts @@ -1,7 +1,7 @@ -import axios from 'axios' import { z } from 'zod' import { assertSuccessJsonResponse } from '../assertSuccessJsonResponse' import { getDefaultTimeoutMilliseconds } from '../getDefaultTimeoutMilliseconds' +import RobotsTxt from '../RobotsTxt/RobotsTxt.js' const schema = z.object({ name: z.string().optional(), @@ -27,9 +27,9 @@ const schema = z.object({ export type NodeInfo = z.infer -export const retrieveNodeInfo = async (url: string): Promise => { +export const retrieveNodeInfo = async (url: string, robotsTxt: RobotsTxt): Promise => { console.info('Retrieving node info', { url }) - const nodeInfoResponse = await axios.get(url, { + const nodeInfoResponse = await robotsTxt.getIfAllowed(url, { timeout: getDefaultTimeoutMilliseconds() }) assertSuccessJsonResponse(nodeInfoResponse) diff --git a/application/src/Fediverse/NodeInfo/retrieveWellKnown.ts b/application/src/Fediverse/NodeInfo/retrieveWellKnown.ts index b45f4ec..d5ca66c 100644 --- a/application/src/Fediverse/NodeInfo/retrieveWellKnown.ts +++ b/application/src/Fediverse/NodeInfo/retrieveWellKnown.ts @@ -1,7 +1,7 @@ -import axios from 'axios' import { assertSuccessJsonResponse } from '../assertSuccessJsonResponse' import { z } from 'zod' import { getDefaultTimeoutMilliseconds } from '../getDefaultTimeoutMilliseconds' +import RobotsTxt from '../RobotsTxt/RobotsTxt.js' const wellKnownSchema = z.object({ links: z.array( @@ -14,10 +14,10 @@ const wellKnownSchema = z.object({ export type WellKnown = z.infer -export const retrieveWellKnown = async (domain: string): Promise => { +export const retrieveWellKnown = async (domain: string, robotsTxt: RobotsTxt): Promise => { console.info('Retrieving well known', { domain }) const wellKnownUrl = `https://${domain}/.well-known/nodeinfo` - const wellKnownResponse = await axios.get(wellKnownUrl, { + const wellKnownResponse = await robotsTxt.getIfAllowed(wellKnownUrl, { timeout: getDefaultTimeoutMilliseconds(), maxContentLength: 5000 }) diff --git a/application/src/Fediverse/Providers/FeedProviderMethod.ts b/application/src/Fediverse/Providers/FeedProviderMethod.ts index 3ce0518..88990ac 100644 --- a/application/src/Fediverse/Providers/FeedProviderMethod.ts +++ b/application/src/Fediverse/Providers/FeedProviderMethod.ts @@ -1,6 +1,8 @@ +import RobotsTxt from '../RobotsTxt/RobotsTxt.js' import { FeedData } from './FeedData' export type FeedProviderMethod = ( domain: string, - page: number + page: number, + robotsTxt: RobotsTxt ) => Promise diff --git a/application/src/Fediverse/Providers/Mastodon/retrieveLocalPublicUsersPage.ts b/application/src/Fediverse/Providers/Mastodon/retrieveLocalPublicUsersPage.ts index b4b19ce..facdf9d 100644 --- a/application/src/Fediverse/Providers/Mastodon/retrieveLocalPublicUsersPage.ts +++ b/application/src/Fediverse/Providers/Mastodon/retrieveLocalPublicUsersPage.ts @@ -1,4 +1,3 @@ -import axios from 'axios' import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse' import { z } from 'zod' import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds' @@ -53,9 +52,10 @@ const replaceEmojis = (text: string, emojis: Emoji[]): string => { export const retrieveLocalPublicUsersPage: FeedProviderMethod = async ( domain, - page + page, + robotsTxt ): Promise => { - const response = await axios.get('https://' + domain + '/api/v1/directory', { + const response = await robotsTxt.getIfAllowed(`https://${domain}/api/v1/directory`, { params: { limit, offset: page * limit, diff --git a/application/src/Fediverse/Providers/Mastodon/retrievePeers.ts b/application/src/Fediverse/Providers/Mastodon/retrievePeers.ts index 1f59749..823702b 100644 --- a/application/src/Fediverse/Providers/Mastodon/retrievePeers.ts +++ b/application/src/Fediverse/Providers/Mastodon/retrievePeers.ts @@ -1,4 +1,3 @@ -import axios from 'axios' import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse' import { z } from 'zod' import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds' @@ -7,12 +6,12 @@ import { NoMoreNodesError } from '../NoMoreNodesError' const schema = z.array(z.string()) -export const retrievePeers: NodeProviderMethod = async (domain, page) => { +export const retrievePeers: NodeProviderMethod = async (domain, page, robotsTxt) => { if (page !== 0) { throw new NoMoreNodesError('peer') } - const response = await axios.get( - 'https://' + domain + '/api/v1/instance/peers', + const response = await robotsTxt.getIfAllowed( + `https://${domain}/api/v1/instance/peers`, { timeout: getDefaultTimeoutMilliseconds() } diff --git a/application/src/Fediverse/Providers/Misskey/retrieveInstancesPage.ts b/application/src/Fediverse/Providers/Misskey/retrieveInstancesPage.ts index fef24a0..142c197 100644 --- a/application/src/Fediverse/Providers/Misskey/retrieveInstancesPage.ts +++ b/application/src/Fediverse/Providers/Misskey/retrieveInstancesPage.ts @@ -1,4 +1,3 @@ -import axios from 'axios' import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse' import { z } from 'zod' import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds' @@ -15,10 +14,11 @@ const schema = z.array( export const retrieveInstancesPage: NodeProviderMethod = async ( domain, - page + page, + robotsTxt ) => { - const response = await axios.post( - 'https://' + domain + '/api/federation/instances', + const response = await robotsTxt.postIfAllowed( + `https://${domain}/api/federation/instances`, { host: null, blocked: null, diff --git a/application/src/Fediverse/Providers/Misskey/retrieveUsersPage.ts b/application/src/Fediverse/Providers/Misskey/retrieveUsersPage.ts index 7b124e0..6693ee9 100644 --- a/application/src/Fediverse/Providers/Misskey/retrieveUsersPage.ts +++ b/application/src/Fediverse/Providers/Misskey/retrieveUsersPage.ts @@ -1,4 +1,3 @@ -import axios from 'axios' import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse' import { z } from 'zod' import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds' @@ -68,10 +67,11 @@ const parseDescription = (description: string | null): string => { export const retrieveUsersPage: FeedProviderMethod = async ( domain, - page + page, + robotsTxt ): Promise => { - const response = await axios.post( - 'https://' + domain + '/api/users', + const response = await robotsTxt.postIfAllowed( + `https://${domain}/api/users`, { state: 'all', origin: 'local', diff --git a/application/src/Fediverse/Providers/NodeProviderMethod.ts b/application/src/Fediverse/Providers/NodeProviderMethod.ts index 310212d..23287fa 100644 --- a/application/src/Fediverse/Providers/NodeProviderMethod.ts +++ b/application/src/Fediverse/Providers/NodeProviderMethod.ts @@ -1,4 +1,7 @@ +import RobotsTxt from '../RobotsTxt/RobotsTxt.js' + export type NodeProviderMethod = ( domain: string, - page: number + page: number, + robotsTxt: RobotsTxt ) => Promise diff --git a/application/src/Fediverse/Providers/Peertube/retrieveAccounts.ts b/application/src/Fediverse/Providers/Peertube/retrieveAccounts.ts index ffa5208..e193d12 100644 --- a/application/src/Fediverse/Providers/Peertube/retrieveAccounts.ts +++ b/application/src/Fediverse/Providers/Peertube/retrieveAccounts.ts @@ -1,5 +1,4 @@ import { FeedData } from '../FeedData' -import axios from 'axios' import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse' import { z } from 'zod' import { avatarSchema } from './Avatar' @@ -29,8 +28,8 @@ const schema = z.object({ ) }) -export const retrieveAccounts: FeedProviderMethod = async (domain, page) => { - const response = await axios.get(`https://${domain}/api/v1/accounts`, { +export const retrieveAccounts: FeedProviderMethod = async (domain, page, robotsTxt) => { + const response = await robotsTxt.getIfAllowed(`https://${domain}/api/v1/accounts`, { params: { count: limit, sort: 'createdAt', diff --git a/application/src/Fediverse/Providers/Peertube/retrieveFollowers.ts b/application/src/Fediverse/Providers/Peertube/retrieveFollowers.ts index c855c35..3093f41 100644 --- a/application/src/Fediverse/Providers/Peertube/retrieveFollowers.ts +++ b/application/src/Fediverse/Providers/Peertube/retrieveFollowers.ts @@ -1,4 +1,3 @@ -import axios from 'axios' import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse' import { z } from 'zod' import { getDefaultTimeoutMilliseconds } from '../../getDefaultTimeoutMilliseconds' @@ -21,8 +20,8 @@ const schema = z.object({ ) }) -export const retrieveFollowers: NodeProviderMethod = async (domain, page) => { - const response = await axios.get( +export const retrieveFollowers: NodeProviderMethod = async (domain, page, robotsTxt) => { + const response = await robotsTxt.getIfAllowed( `https://${domain}/api/v1/server/followers`, { params: { diff --git a/application/src/Fediverse/Providers/Peertube/retrieveVideoChannels.ts b/application/src/Fediverse/Providers/Peertube/retrieveVideoChannels.ts index de27040..c975447 100644 --- a/application/src/Fediverse/Providers/Peertube/retrieveVideoChannels.ts +++ b/application/src/Fediverse/Providers/Peertube/retrieveVideoChannels.ts @@ -1,5 +1,4 @@ import { FeedData } from '../FeedData' -import axios from 'axios' import { assertSuccessJsonResponse } from '../../assertSuccessJsonResponse' import { z } from 'zod' import { FieldData } from '../FieldData' @@ -38,9 +37,10 @@ const schema = z.object({ export const retrieveVideoChannels: FeedProviderMethod = async ( domain, - page + page, + robotsTxt ) => { - const response = await axios.get(`https://${domain}/api/v1/video-channels`, { + const response = await robotsTxt.getIfAllowed(`https://${domain}/api/v1/video-channels`, { params: { count: limit, sort: 'createdAt', diff --git a/application/src/Fediverse/RobotsTxt/RobotsTxt.ts b/application/src/Fediverse/RobotsTxt/RobotsTxt.ts new file mode 100644 index 0000000..01fb4a7 --- /dev/null +++ b/application/src/Fediverse/RobotsTxt/RobotsTxt.ts @@ -0,0 +1,7 @@ +import { AxiosRequestConfig, AxiosResponse } from 'axios' + +export default interface RobotsTxt { + isAllowed: (url: string) => boolean + getIfAllowed: , D = any>(url: string, config?: AxiosRequestConfig) => Promise + postIfAllowed: , D = any>(url: string, data?: D, config?: AxiosRequestConfig) => Promise +} diff --git a/application/src/Fediverse/RobotsTxt/RobotsTxtError.ts b/application/src/Fediverse/RobotsTxt/RobotsTxtError.ts new file mode 100644 index 0000000..ecd96a0 --- /dev/null +++ b/application/src/Fediverse/RobotsTxt/RobotsTxtError.ts @@ -0,0 +1,7 @@ +export class RobotsTxtError extends Error { + public readonly url + public constructor (url: string) { + super('Request was blocked by robots.txt') + this.url = url + } +} diff --git a/application/src/Fediverse/RobotsTxt/fetchRobotsTxt.ts b/application/src/Fediverse/RobotsTxt/fetchRobotsTxt.ts new file mode 100644 index 0000000..a2b3743 --- /dev/null +++ b/application/src/Fediverse/RobotsTxt/fetchRobotsTxt.ts @@ -0,0 +1,41 @@ +import axios, { AxiosRequestConfig, AxiosResponse } from 'axios' +import robotsParser from 'robots-parser' +import RobotsTxt from './RobotsTxt.js' +import { RobotsTxtError } from './RobotsTxtError.js' + +const userAgent = 'FediCrawl/1.0' + +export default async function fetchRobotsTxt (domain: string): Promise { + console.info('Fetching robots.txt', { domain }) + const url = `https://${domain}/robots.txt` + let content = '' + try { + const robotsTxt = await axios.get(url) + content = robotsTxt.data + } catch (error) { + console.info('Robots.txt not found', { error, url }) + } + const robots = robotsParser(url, content) + const isAllowed = (url: string): boolean => robots.isAllowed(url, userAgent) ?? true + return { + isAllowed, + getIfAllowed: async , D = any>(url: string, config?: AxiosRequestConfig): Promise => { + if (!isAllowed(url)) { + throw new RobotsTxtError(url) + } + return await axios.get(url, { + headers: { 'User-Agent': userAgent }, + ...config + }) + }, + postIfAllowed: async , D = any>(url: string, data?: D, config?: AxiosRequestConfig): Promise => { + if (!isAllowed(url)) { + throw new RobotsTxtError(url) + } + return await axios.post(url, data, { + headers: { 'User-Agent': userAgent }, + ...config + }) + } + } +} diff --git a/application/src/Jobs/Feeds/refreshFeeds.ts b/application/src/Jobs/Feeds/refreshFeeds.ts index d56f9f5..fb310e3 100644 --- a/application/src/Jobs/Feeds/refreshFeeds.ts +++ b/application/src/Jobs/Feeds/refreshFeeds.ts @@ -1,3 +1,4 @@ +import RobotsTxt from '../../Fediverse/RobotsTxt/RobotsTxt.js' import { refreshFeedsOnPage } from './refreshFeedsOnPage' import { FeedProvider } from '../../Fediverse/Providers/FeedProvider' import Node from '../../Storage/Definitions/Node' @@ -6,7 +7,8 @@ import { ElasticClient } from '../../Storage/ElasticClient' export const refreshFeeds = async ( elastic: ElasticClient, provider: FeedProvider, - node: Node + node: Node, + robotsTxt: RobotsTxt ): Promise => { try { // noinspection InfiniteLoopJS @@ -16,7 +18,7 @@ export const refreshFeeds = async ( provider: provider.getKey(), page }) - await refreshFeedsOnPage(elastic, provider, node, page) + await refreshFeedsOnPage(elastic, provider, node, page, robotsTxt) } } catch (error) { console.info('Feed search finished', { diff --git a/application/src/Jobs/Feeds/refreshFeedsOnPage.ts b/application/src/Jobs/Feeds/refreshFeedsOnPage.ts index 8841c71..79c9e31 100644 --- a/application/src/Jobs/Feeds/refreshFeedsOnPage.ts +++ b/application/src/Jobs/Feeds/refreshFeedsOnPage.ts @@ -1,3 +1,4 @@ +import RobotsTxt from '../../Fediverse/RobotsTxt/RobotsTxt.js' import { refreshOrAddFeed } from './refreshOrAddFeed' import { FeedProvider } from '../../Fediverse/Providers/FeedProvider' import Node from '../../Storage/Definitions/Node' @@ -8,9 +9,10 @@ export const refreshFeedsOnPage = async ( elastic: ElasticClient, provider: FeedProvider, node: Node, - page: number + page: number, + robotsTxt: RobotsTxt ): Promise => { - const feedData = await provider.retrieveFeeds(node.domain, page) + const feedData = await provider.retrieveFeeds(node.domain, page, robotsTxt) console.info('Retrieved feeds', { count: feedData.length, domain: node.domain, diff --git a/application/src/Jobs/NodeInfo/refreshNodeInfo.ts b/application/src/Jobs/NodeInfo/refreshNodeInfo.ts index 985bee5..28d7493 100644 --- a/application/src/Jobs/NodeInfo/refreshNodeInfo.ts +++ b/application/src/Jobs/NodeInfo/refreshNodeInfo.ts @@ -1,15 +1,17 @@ import { retrieveDomainNodeInfo } from '../../Fediverse/NodeInfo/retrieveDomainNodeInfo' +import RobotsTxt from '../../Fediverse/RobotsTxt/RobotsTxt.js' import { updateNodeInfo } from '../../Storage/Nodes/updateNodeInfo' import Node from '../../Storage/Definitions/Node' import { ElasticClient } from '../../Storage/ElasticClient' export const refreshNodeInfo = async ( elastic: ElasticClient, - node: Node + node: Node, + robotsTxt: RobotsTxt ): Promise => { console.info('Updating info of node', { nodeDomain: node.domain }) try { - const nodeInfo = await retrieveDomainNodeInfo(node.domain) + const nodeInfo = await retrieveDomainNodeInfo(node.domain, robotsTxt) return await updateNodeInfo(elastic, node, nodeInfo) } catch (error) { console.warn('Failed to update node info', error) diff --git a/application/src/Jobs/Nodes/findNewNodes.ts b/application/src/Jobs/Nodes/findNewNodes.ts index 8bf8604..0bebd53 100644 --- a/application/src/Jobs/Nodes/findNewNodes.ts +++ b/application/src/Jobs/Nodes/findNewNodes.ts @@ -1,4 +1,5 @@ import { NodeProvider } from '../../Fediverse/Providers/NodeProvider' +import RobotsTxt from '../../Fediverse/RobotsTxt/RobotsTxt.js' import { findNewNodesOnPage } from './findNewNodesOnPage' import Node from '../../Storage/Definitions/Node' import { ElasticClient } from '../../Storage/ElasticClient' @@ -6,7 +7,8 @@ import { ElasticClient } from '../../Storage/ElasticClient' export const findNewNodes = async ( elastic: ElasticClient, provider: NodeProvider, - node: Node + node: Node, + robotsTxt: RobotsTxt ): Promise => { try { // noinspection InfiniteLoopJS @@ -15,7 +17,7 @@ export const findNewNodes = async ( domain: node.domain, provider: provider.getKey() }) - await findNewNodesOnPage(elastic, provider, node, page) + await findNewNodesOnPage(elastic, provider, node, page, robotsTxt) } } catch (error) { console.info('Node search finished', { diff --git a/application/src/Jobs/Nodes/findNewNodesOnPage.ts b/application/src/Jobs/Nodes/findNewNodesOnPage.ts index 799a529..a80e404 100644 --- a/application/src/Jobs/Nodes/findNewNodesOnPage.ts +++ b/application/src/Jobs/Nodes/findNewNodesOnPage.ts @@ -1,3 +1,4 @@ +import RobotsTxt from '../../Fediverse/RobotsTxt/RobotsTxt.js' import { createMissingNodes } from '../../Storage/Nodes/createMissingNodes' import { NodeProvider } from '../../Fediverse/Providers/NodeProvider' import Node from '../../Storage/Definitions/Node' @@ -8,9 +9,10 @@ export const findNewNodesOnPage = async ( elastic: ElasticClient, provider: NodeProvider, node: Node, - page: number + page: number, + robotsTxt: RobotsTxt ): Promise => { - let domains = await provider.retrieveNodes(node.domain, page) + let domains = await provider.retrieveNodes(node.domain, page, robotsTxt) domains = domains.filter(isDomainNotBanned) console.log('Found nodes', { count: domains.length, diff --git a/application/src/Jobs/processNextNode.ts b/application/src/Jobs/processNextNode.ts index 4a1cc69..44286c9 100644 --- a/application/src/Jobs/processNextNode.ts +++ b/application/src/Jobs/processNextNode.ts @@ -1,3 +1,4 @@ +import fetchRobotsTxt from '../Fediverse/RobotsTxt/fetchRobotsTxt.js' import { fetchNodeToProcess } from '../Storage/Nodes/fetchNodeToProcess' import { ProviderRegistry } from '../Fediverse/Providers/ProviderRegistry' import { setNodeRefreshed } from '../Storage/Nodes/setNodeRefreshed' @@ -21,7 +22,8 @@ export const processNextNode = async ( node = await setNodeRefreshAttempted(elastic, node) node = await refreshNodeIps(elastic, node) - node = await refreshNodeInfo(elastic, node) + const robotsTxt = await fetchRobotsTxt(node.domain) + node = await refreshNodeInfo(elastic, node, robotsTxt) const softwareName = node.softwareName ?? '' if (!providerRegistry.containsKey(softwareName)) { @@ -41,7 +43,7 @@ export const processNextNode = async ( domain: node.domain, provider: nodeProvider.getKey() }) - return await findNewNodes(elastic, nodeProvider, node) + return await findNewNodes(elastic, nodeProvider, node, robotsTxt) }) ) @@ -51,7 +53,7 @@ export const processNextNode = async ( domain: node.domain, provider: feedProvider.getKey() }) - return await refreshFeeds(elastic, feedProvider, node) + return await refreshFeeds(elastic, feedProvider, node, robotsTxt) }) ) diff --git a/application/src/Storage/Nodes/assertNodeIndex.ts b/application/src/Storage/Nodes/assertNodeIndex.ts index e6fd1a5..4ec0a39 100644 --- a/application/src/Storage/Nodes/assertNodeIndex.ts +++ b/application/src/Storage/Nodes/assertNodeIndex.ts @@ -8,22 +8,6 @@ const assertNodeIndex = async (elastic: ElasticClient): Promise => { id: 'node', description: 'Default node pipeline', processors: [ - { - geoip: { - ignore_missing: true, - field: 'serverIps', - properties: [ - 'location', - 'continent_name', - 'country_name', - 'country_iso_code', - 'region_iso_code', - 'region_name', - 'city_name' - ], - target_field: 'geoip' - } - }, { grok: { ignore_missing: true, diff --git a/application/yarn.lock b/application/yarn.lock index 0df6dd8..b040bd8 100644 --- a/application/yarn.lock +++ b/application/yarn.lock @@ -3071,6 +3071,11 @@ rimraf@^3.0.2: dependencies: glob "^7.1.3" +robots-parser@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/robots-parser/-/robots-parser-3.0.0.tgz#66af89306302ecd004455f2f24298310d0966631" + integrity sha512-6xkze3WRdneibICBAzMKcXyTKQw5shA3GbwoEJy7RSvxpZNGF0GMuYKE1T0VMP4fwx/fQs0n0mtriOqRtk5L1w== + run-parallel@^1.1.9: version "1.2.0" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee"