#!/usr/bin/env node import { mkdir, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, relative, resolve, sep } from 'node:path' import process from 'node:process' import { pathToFileURL } from 'node:url' export const AMAP_ENDPOINT = 'https://restapi.amap.com/v5/place/text' export const TARGET_ADCODE = '440311' export const MAX_RESULTS_PER_QUERY = 200 export const DEFAULT_TYPE_GROUPS = Object.freeze([ Object.freeze({ name: 'scenic-attractions', types: Object.freeze(['110000']), }), Object.freeze({ name: 'public-culture', types: Object.freeze([ '140100', '140200', '140300', '140400', '140500', '140600', '140700', '140800', ]), }), Object.freeze({ name: 'leisure-and-performing-arts', types: Object.freeze([ '080401', '080501', '080502', '080503', '080504', '080505', '080602', '080603', ]), }), Object.freeze({ name: 'farms', types: Object.freeze(['170402']), }), ]) export const DEFAULT_KEYWORDS = Object.freeze([ '景区', '公园', '森林公园', '湿地公园', '郊野公园', '绿道', '博物馆', '纪念馆', '文化馆', '艺术馆', '美术馆', '图书馆', '展览馆', '科技馆', '剧院', '农场', '田园', '生态园', '采摘园', '古村', '非遗', ]) const RETRYABLE_AMAP_INFOCODES = new Set([ '10004', '10016', '10019', '10020', '10021', ]) const PROTECTED_OUTPUT_DIRECTORIES = ['src', 'scripts', 'test', 'tests'] const HELP_TEXT = ` Collect unreviewed AMap POI 2.0 candidates for Shenzhen Guangming District. Usage: AMAP_WEB_SERVICE_KEY=... node scripts/fetch-amap-pois.mjs [options] Options: --output JSON output path. Defaults to the system temp dir. --type-group Repeatable group; codes are separated with "|". Custom groups replace the default type groups. --keyword Repeatable supplementary keyword. Custom keywords replace the default keywords. --no-types Disable default type-group queries. --no-keywords Disable default keyword queries. --page-size <1..25> Results requested per page. Default: 25. --max-pages Pages per query. Default: 8; never over 200 results. --throttle-ms Minimum delay between requests. Default: 500. --retries <0..10> Retries for transient failures. Default: 3. --retry-base-ms Exponential retry base delay. Default: 1000. --timeout-ms Per-request timeout. Default: 15000. --overwrite Replace an existing output file. --dry-run Print the query plan without reading the API key. --help Show this help. The region is fixed to adcode 440311 with city_limit=true. The API key can only be read from AMAP_WEB_SERVICE_KEY and is never written to output or logs. Candidate data must be reviewed before it is published. ` class PublicError extends Error { constructor(message, { code = 'ERROR', retryable = false } = {}) { super(message) this.name = 'PublicError' this.code = code this.retryable = retryable } } function isRecord(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) } function toText(value) { if (Array.isArray(value)) { for (const entry of value) { const text = toText(entry) if (text !== null) return text } return null } if (typeof value === 'string') { const text = value.trim() return text === '' ? null : text } if (typeof value === 'number' && Number.isFinite(value)) return String(value) return null } function toFiniteNumber(value) { const text = toText(value) if (text === null) return null const number = Number(text) return Number.isFinite(number) ? number : null } export function parseAmapLocation(value) { const text = toText(value) if (text === null) return null const [longitudeText, latitudeText, ...rest] = text.split(',') if (rest.length > 0) return null const longitude = Number(longitudeText) const latitude = Number(latitudeText) if ( !Number.isFinite(longitude) || !Number.isFinite(latitude) || longitude < -180 || longitude > 180 || latitude < -90 || latitude > 90 ) { return null } return { longitude, latitude } } function normalizePhoto(photo) { if (!isRecord(photo)) return null const url = toText(photo.url) if (url === null) return null return { title: toText(photo.title), url, } } function normalizePhotos(value) { const entries = Array.isArray(value) ? value : isRecord(value) ? [value] : [] const photos = [] const seenUrls = new Set() for (const entry of entries) { const photo = normalizePhoto(entry) if (photo === null || seenUrls.has(photo.url)) continue seenUrls.add(photo.url) photos.push(photo) } return photos } function normalizeQueryMatch(query) { if (!isRecord(query)) return null const id = toText(query.id) const kind = toText(query.kind) const label = toText(query.label) if (id === null || kind === null || label === null) return null return { id, kind, label } } export function isPoiInAdcode(rawPoi, targetAdcode = TARGET_ADCODE) { return isRecord(rawPoi) && toText(rawPoi.adcode) === targetAdcode } export function normalizeAmapPoi(rawPoi, query = null) { if (!isRecord(rawPoi)) return null const id = toText(rawPoi.id) const name = toText(rawPoi.name) const location = parseAmapLocation(rawPoi.location) if (id === null || name === null || location === null) return null const business = isRecord(rawPoi.business) ? rawPoi.business : {} const match = normalizeQueryMatch(query) return { id, source: 'amap', name, parentId: toText(rawPoi.parent), longitude: location.longitude, latitude: location.latitude, coordinateSystem: 'GCJ02', type: toText(rawPoi.type), typecode: toText(rawPoi.typecode), provinceName: toText(rawPoi.pname), provinceCode: toText(rawPoi.pcode), cityName: toText(rawPoi.cityname), cityCode: toText(rawPoi.citycode), districtName: toText(rawPoi.adname), adcode: toText(rawPoi.adcode), address: toText(rawPoi.address), business: { alias: toText(business.alias), telephone: toText(business.tel), sourceRating: toFiniteNumber(business.rating), openingHoursToday: toText(business.opentime_today), openingHoursWeek: toText(business.opentime_week), }, photos: normalizePhotos(rawPoi.photos), matchedQueries: match === null ? [] : [match], sourceOccurrences: 1, } } export function normalizeAmapPage(rawPois, query, targetAdcode = TARGET_ADCODE) { const result = { pois: [], filteredOutByAdcode: 0, rejectedInvalid: 0, } if (!Array.isArray(rawPois)) return result for (const rawPoi of rawPois) { if (!isPoiInAdcode(rawPoi, targetAdcode)) { result.filteredOutByAdcode += 1 continue } const poi = normalizeAmapPoi(rawPoi, query) if (poi === null) { result.rejectedInvalid += 1 continue } result.pois.push(poi) } return result } function firstPresent(first, second) { if (first !== null && first !== undefined && first !== '') return first return second ?? null } function mergePhotos(first, second) { const merged = [] const byUrl = new Map() for (const photo of [...first, ...second]) { if (!isRecord(photo)) continue const url = toText(photo.url) if (url === null) continue const existing = byUrl.get(url) if (existing) { existing.title = firstPresent(existing.title, toText(photo.title)) continue } const normalized = { title: toText(photo.title), url } byUrl.set(url, normalized) merged.push(normalized) } return merged } function mergeQueryMatches(first, second) { const matches = [] const seenIds = new Set() for (const entry of [...first, ...second]) { const match = normalizeQueryMatch(entry) if (match === null || seenIds.has(match.id)) continue seenIds.add(match.id) matches.push(match) } return matches } function cloneCandidate(poi) { return { ...poi, business: { ...(isRecord(poi.business) ? poi.business : {}) }, photos: mergePhotos([], Array.isArray(poi.photos) ? poi.photos : []), matchedQueries: mergeQueryMatches( [], Array.isArray(poi.matchedQueries) ? poi.matchedQueries : [], ), sourceOccurrences: Number.isInteger(poi.sourceOccurrences) ? poi.sourceOccurrences : 1, } } function mergeCandidates(first, second) { const merged = cloneCandidate(first) const next = cloneCandidate(second) const scalarFields = [ 'name', 'parentId', 'type', 'typecode', 'provinceName', 'provinceCode', 'cityName', 'cityCode', 'districtName', 'adcode', 'address', ] for (const field of scalarFields) merged[field] = firstPresent(merged[field], next[field]) const businessFields = [ 'alias', 'telephone', 'sourceRating', 'openingHoursToday', 'openingHoursWeek', ] for (const field of businessFields) { merged.business[field] = firstPresent( merged.business[field], next.business[field], ) } merged.photos = mergePhotos(merged.photos, next.photos) merged.matchedQueries = mergeQueryMatches( merged.matchedQueries, next.matchedQueries, ) merged.sourceOccurrences += next.sourceOccurrences return merged } export function dedupePois(pois) { if (!Array.isArray(pois)) return [] const byId = new Map() for (const poi of pois) { if (!isRecord(poi)) continue const id = toText(poi.id) if (id === null) continue const normalized = cloneCandidate({ ...poi, id }) const existing = byId.get(id) byId.set(id, existing ? mergeCandidates(existing, normalized) : normalized) } return [...byId.values()] } function validateTypeGroup(value) { const separatorIndex = value.indexOf('=') if (separatorIndex <= 0 || separatorIndex === value.length - 1) { throw new PublicError( 'Invalid --type-group. Expected a name and six-digit codes, for example scenic=110000.', { code: 'INVALID_ARGUMENT' }, ) } const name = value.slice(0, separatorIndex).trim() const types = value .slice(separatorIndex + 1) .split('|') .map(type => type.trim()) .filter(Boolean) if (!/^[\w-]+$/.test(name) || types.length === 0) { throw new PublicError('Invalid --type-group.', { code: 'INVALID_ARGUMENT' }) } if (types.some(type => !/^\d{6}$/.test(type))) { throw new PublicError('Every POI type code must contain exactly six digits.', { code: 'INVALID_ARGUMENT', }) } return { name, types: [...new Set(types)] } } function readOptionValue(argv, index, inlineValue, optionName) { if (inlineValue !== null) return { value: inlineValue, nextIndex: index } const value = argv[index + 1] if (value === undefined || value.startsWith('--')) { throw new PublicError(`Missing value for ${optionName}.`, { code: 'INVALID_ARGUMENT', }) } return { value, nextIndex: index + 1 } } function parseInteger(value, name, { minimum, maximum = Number.MAX_SAFE_INTEGER }) { if (!/^\d+$/.test(value)) { throw new PublicError(`${name} must be an integer.`, { code: 'INVALID_ARGUMENT', }) } const number = Number(value) if (!Number.isSafeInteger(number) || number < minimum || number > maximum) { throw new PublicError(`${name} must be between ${minimum} and ${maximum}.`, { code: 'INVALID_ARGUMENT', }) } return number } export function parseCliArgs(argv) { const config = { output: null, pageSize: 25, maxPages: null, throttleMs: 500, retries: 3, retryBaseMs: 1000, timeoutMs: 15000, overwrite: false, dryRun: false, help: false, defaultTypesEnabled: true, defaultKeywordsEnabled: true, customTypeGroups: [], customKeywords: [], } for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] const separatorIndex = argument.indexOf('=') const optionName = separatorIndex === -1 ? argument : argument.slice(0, separatorIndex) const inlineValue = separatorIndex === -1 ? null : argument.slice(separatorIndex + 1) if (optionName === '--key' || optionName === '--api-key') { throw new PublicError( 'The API key can only be provided through AMAP_WEB_SERVICE_KEY.', { code: 'KEY_ARGUMENT_FORBIDDEN' }, ) } if (['--overwrite', '--dry-run', '--help', '--no-types', '--no-keywords'].includes(optionName)) { if (inlineValue !== null) { throw new PublicError('Boolean options do not accept a value.', { code: 'INVALID_ARGUMENT', }) } if (optionName === '--overwrite') config.overwrite = true else if (optionName === '--dry-run') config.dryRun = true else if (optionName === '--help') config.help = true else if (optionName === '--no-types') config.defaultTypesEnabled = false else config.defaultKeywordsEnabled = false continue } const valueOptions = new Set([ '--output', '--type-group', '--keyword', '--page-size', '--max-pages', '--throttle-ms', '--retries', '--retry-base-ms', '--timeout-ms', ]) if (!valueOptions.has(optionName)) { throw new PublicError('Unknown option. Run with --help for usage.', { code: 'INVALID_ARGUMENT', }) } const parsed = readOptionValue(argv, index, inlineValue, optionName) index = parsed.nextIndex if (optionName === '--output') { if (parsed.value.trim() === '') throw new PublicError('--output cannot be empty.', { code: 'INVALID_ARGUMENT' }) config.output = parsed.value } else if (optionName === '--type-group') { config.customTypeGroups.push(validateTypeGroup(parsed.value)) } else if (optionName === '--keyword') { const keyword = parsed.value.trim() if (keyword === '' || keyword.length > 80) { throw new PublicError('--keyword must contain between 1 and 80 characters.', { code: 'INVALID_ARGUMENT', }) } config.customKeywords.push(keyword) } else if (optionName === '--page-size') { config.pageSize = parseInteger(parsed.value, '--page-size', { minimum: 1, maximum: 25, }) } else if (optionName === '--max-pages') { config.maxPages = parseInteger(parsed.value, '--max-pages', { minimum: 1 }) } else if (optionName === '--throttle-ms') { config.throttleMs = parseInteger(parsed.value, '--throttle-ms', { minimum: 0 }) } else if (optionName === '--retries') { config.retries = parseInteger(parsed.value, '--retries', { minimum: 0, maximum: 10, }) } else if (optionName === '--retry-base-ms') { config.retryBaseMs = parseInteger(parsed.value, '--retry-base-ms', { minimum: 0 }) } else { config.timeoutMs = parseInteger(parsed.value, '--timeout-ms', { minimum: 1 }) } } const maximumPages = Math.ceil(MAX_RESULTS_PER_QUERY / config.pageSize) config.maxPages ??= maximumPages if (config.maxPages > maximumPages) { throw new PublicError( `--max-pages cannot exceed ${maximumPages} with the selected page size.`, { code: 'INVALID_ARGUMENT' }, ) } const typeGroups = config.customTypeGroups.length > 0 ? config.customTypeGroups : config.defaultTypesEnabled ? DEFAULT_TYPE_GROUPS.map(group => ({ name: group.name, types: [...group.types] })) : [] const keywords = config.customKeywords.length > 0 ? [...new Set(config.customKeywords)] : config.defaultKeywordsEnabled ? [...DEFAULT_KEYWORDS] : [] if (!config.help && typeGroups.length === 0 && keywords.length === 0) { throw new PublicError('At least one type group or keyword query is required.', { code: 'INVALID_ARGUMENT', }) } return { output: config.output, pageSize: config.pageSize, maxPages: config.maxPages, throttleMs: config.throttleMs, retries: config.retries, retryBaseMs: config.retryBaseMs, timeoutMs: config.timeoutMs, overwrite: config.overwrite, dryRun: config.dryRun, help: config.help, typeGroups, keywords, } } export function buildQueryPlan(typeGroups, keywords) { const queries = [] for (const group of typeGroups) { queries.push({ id: `types:${group.name}`, kind: 'types', label: group.name, types: [...group.types], }) } for (let index = 0; index < keywords.length; index += 1) { queries.push({ id: `keyword:${index + 1}`, kind: 'keyword', label: keywords[index], keyword: keywords[index], }) } return queries } function sleep(milliseconds) { return new Promise(resolvePromise => setTimeout(resolvePromise, milliseconds)) } function createThrottler(intervalMs) { let lastRequestStartedAt = 0 return async function throttle() { const elapsed = Date.now() - lastRequestStartedAt const remaining = intervalMs - elapsed if (remaining > 0) await sleep(remaining) lastRequestStartedAt = Date.now() } } export function buildAmapSearchParams(query, pageSize, pageNumber) { const searchParams = new URLSearchParams() searchParams.set('region', TARGET_ADCODE) searchParams.set('city_limit', 'true') searchParams.set('page_size', String(pageSize)) searchParams.set('page_num', String(pageNumber)) searchParams.set('show_fields', 'business,photos') if (query.kind === 'types') searchParams.set('types', query.types.join('|')) else searchParams.set('keywords', query.keyword) return searchParams } function buildRequestUrl(apiKey, query, pageSize, pageNumber) { const url = new URL(AMAP_ENDPOINT) url.search = buildAmapSearchParams(query, pageSize, pageNumber).toString() url.searchParams.set('key', apiKey) return url } function isRetryableHttpStatus(status) { return status === 408 || status === 425 || status === 429 || status >= 500 } async function requestAmapPage({ apiKey, query, pageSize, pageNumber, timeoutMs, fetchImpl, }) { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) let response try { response = await fetchImpl( buildRequestUrl(apiKey, query, pageSize, pageNumber), { method: 'GET', headers: { accept: 'application/json' }, signal: controller.signal, }, ) } catch { const timedOut = controller.signal.aborted throw new PublicError( timedOut ? 'The AMap request timed out.' : 'The AMap request failed at the network layer.', { code: timedOut ? 'TIMEOUT' : 'NETWORK_ERROR', retryable: true }, ) } finally { clearTimeout(timeout) } if (!response.ok) { throw new PublicError(`AMap returned HTTP ${response.status}.`, { code: `HTTP_${response.status}`, retryable: isRetryableHttpStatus(response.status), }) } let payload try { payload = await response.json() } catch { throw new PublicError('AMap returned invalid JSON.', { code: 'INVALID_JSON', retryable: true, }) } if (!isRecord(payload)) { throw new PublicError('AMap returned an invalid response object.', { code: 'INVALID_RESPONSE', retryable: true, }) } if (String(payload.status) !== '1') { const infocode = /^\d+$/.test(String(payload.infocode)) ? String(payload.infocode) : 'UNKNOWN' throw new PublicError(`AMap rejected the request (infocode ${infocode}).`, { code: `AMAP_${infocode}`, retryable: RETRYABLE_AMAP_INFOCODES.has(infocode), }) } if (!Array.isArray(payload.pois)) { throw new PublicError('AMap response did not contain a POI array.', { code: 'INVALID_RESPONSE', retryable: true, }) } return payload } async function requestWithRetry(options, throttle, onRetry) { const { retries, retryBaseMs } = options for (let attempt = 0; attempt <= retries; attempt += 1) { await throttle() try { return await requestAmapPage(options) } catch (error) { const retryable = error instanceof PublicError && error.retryable if (!retryable || attempt === retries) throw error const delayMs = retryBaseMs * (2 ** attempt) + Math.floor(Math.random() * 250) onRetry({ attempt: attempt + 1, retries, delayMs, code: error.code }) await sleep(delayMs) } } throw new PublicError('Request retry loop ended unexpectedly.') } export async function collectAmapPois(config, { apiKey, fetchImpl = globalThis.fetch, log = console, } = {}) { if (typeof apiKey !== 'string' || apiKey.trim() === '') { throw new PublicError('AMAP_WEB_SERVICE_KEY is required.', { code: 'MISSING_KEY', }) } if (typeof fetchImpl !== 'function') { throw new PublicError('This Node.js runtime does not provide fetch().', { code: 'FETCH_UNAVAILABLE', }) } const queries = buildQueryPlan(config.typeGroups, config.keywords) const throttle = createThrottler(config.throttleMs) const collected = [] const queryStats = [] const totals = { requests: 0, retries: 0, received: 0, acceptedBeforeDedupe: 0, filteredOutByAdcode: 0, rejectedInvalid: 0, } for (let queryIndex = 0; queryIndex < queries.length; queryIndex += 1) { const query = queries[queryIndex] const stats = { id: query.id, kind: query.kind, label: query.label, pagesFetched: 0, received: 0, acceptedBeforeDedupe: 0, filteredOutByAdcode: 0, rejectedInvalid: 0, stopReason: null, } for (let pageNumber = 1; pageNumber <= config.maxPages; pageNumber += 1) { const payload = await requestWithRetry( { apiKey, query, pageSize: config.pageSize, pageNumber, timeoutMs: config.timeoutMs, retries: config.retries, retryBaseMs: config.retryBaseMs, fetchImpl, }, throttle, ({ attempt, retries, delayMs, code }) => { totals.retries += 1 log.warn( `[${queryIndex + 1}/${queries.length}] ${query.id}: transient ${code}; ` + `retry ${attempt}/${retries} in ${delayMs} ms.`, ) }, ) totals.requests += 1 stats.pagesFetched += 1 stats.received += payload.pois.length totals.received += payload.pois.length const normalized = normalizeAmapPage(payload.pois, query) stats.acceptedBeforeDedupe += normalized.pois.length stats.filteredOutByAdcode += normalized.filteredOutByAdcode stats.rejectedInvalid += normalized.rejectedInvalid totals.acceptedBeforeDedupe += normalized.pois.length totals.filteredOutByAdcode += normalized.filteredOutByAdcode totals.rejectedInvalid += normalized.rejectedInvalid collected.push(...normalized.pois) log.info( `[${queryIndex + 1}/${queries.length}] ${query.id} page ${pageNumber}: ` + `received ${payload.pois.length}, accepted ${normalized.pois.length}.`, ) if (payload.pois.length < config.pageSize) { stats.stopReason = 'last-page' break } if (pageNumber === config.maxPages) { const requestedCapacity = pageNumber * config.pageSize stats.stopReason = requestedCapacity >= MAX_RESULTS_PER_QUERY ? 'amap-200-result-cap' : 'configured-page-limit' } } stats.stopReason ??= 'configured-page-limit' queryStats.push(stats) } const pois = dedupePois(collected) return { schemaVersion: 1, generatedAt: new Date().toISOString(), source: { provider: 'amap', api: 'POI 2.0 text search', endpoint: AMAP_ENDPOINT, region: TARGET_ADCODE, cityLimit: true, coordinateSystem: 'GCJ02', showFields: ['business', 'photos'], }, reviewStatus: 'unreviewed-candidates', notice: 'Candidates require manual verification, content review, and image-rights review before publication.', collection: { pageSize: config.pageSize, maxPagesPerQuery: config.maxPages, throttleMs: config.throttleMs, queryCount: queries.length, queries: queryStats, }, stats: { ...totals, duplicatesMerged: totals.acceptedBeforeDedupe - pois.length, uniqueCandidates: pois.length, }, pois, } } function isPathInside(candidatePath, parentPath) { const pathFromParent = relative(parentPath, candidatePath) return pathFromParent === '' || (!pathFromParent.startsWith(`..${sep}`) && pathFromParent !== '..') } export function assertSafeOutputPath(outputPath, projectRoot = process.cwd()) { const absoluteOutputPath = resolve(projectRoot, outputPath) for (const directory of PROTECTED_OUTPUT_DIRECTORIES) { const protectedPath = resolve(projectRoot, directory) if (isPathInside(absoluteOutputPath, protectedPath)) { throw new PublicError( `Refusing to write generated data inside the ${directory} source directory.`, { code: 'UNSAFE_OUTPUT_PATH' }, ) } } return absoluteOutputPath } function defaultOutputPath() { const timestamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-') return resolve(tmpdir(), `amap-guangming-pois-${timestamp}-${process.pid}.json`) } async function writeOutputFile(outputPath, payload, overwrite) { await mkdir(dirname(outputPath), { recursive: true }) await writeFile( outputPath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', flag: overwrite ? 'w' : 'wx', mode: 0o600, }, ) } function describeQuery(query) { return query.kind === 'types' ? `${query.id} (${query.types.join('|')})` : `${query.id} (${query.keyword})` } export async function main(argv = process.argv.slice(2), env = process.env) { const config = parseCliArgs(argv) if (config.help) { console.log(HELP_TEXT.trim()) return } const queries = buildQueryPlan(config.typeGroups, config.keywords) if (config.dryRun) { console.log(`Region: ${TARGET_ADCODE}; city_limit=true`) console.log(`Queries: ${queries.length}; up to ${config.maxPages} pages each`) queries.forEach(query => console.log(`- ${describeQuery(query)}`)) return } const apiKey = env.AMAP_WEB_SERVICE_KEY if (typeof apiKey !== 'string' || apiKey.trim() === '') { throw new PublicError('AMAP_WEB_SERVICE_KEY is required.', { code: 'MISSING_KEY', }) } const outputPath = config.output === null ? defaultOutputPath() : assertSafeOutputPath(config.output) const result = await collectAmapPois(config, { apiKey }) await writeOutputFile(outputPath, result, config.overwrite) console.log(`Saved ${result.stats.uniqueCandidates} unique candidates to ${outputPath}`) const cappedQueries = result.collection.queries .filter(query => query.stopReason !== 'last-page') if (cappedQueries.length > 0) { console.warn( `${cappedQueries.length} queries ended at a result/page cap; split them into narrower queries if fuller recall is required.`, ) } } function publicErrorMessage(error) { const fallback = 'Unexpected failure.' const message = error instanceof Error ? error.message : fallback const apiKey = process.env.AMAP_WEB_SERVICE_KEY if (typeof apiKey !== 'string' || apiKey === '') return message return message.replaceAll(apiKey, '[REDACTED]') } const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : null if (invokedPath === import.meta.url) { main().catch((error) => { console.error(`Error: ${publicErrorMessage(error)}`) process.exitCode = 1 }) }