@livemapleads/piece-live-map-leads 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +92 -0
- package/dist/generated/v1.d.ts +320 -0
- package/dist/generated/v1.js +6 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +27 -0
- package/dist/lib/actions/start-search.d.ts +31 -0
- package/dist/lib/actions/start-search.js +130 -0
- package/dist/lib/common/api.d.ts +36 -0
- package/dist/lib/common/api.js +107 -0
- package/dist/lib/common/auth.d.ts +1 -0
- package/dist/lib/common/auth.js +35 -0
- package/dist/lib/common/client.d.ts +17 -0
- package/dist/lib/common/client.js +51 -0
- package/dist/lib/common/constants.d.ts +9 -0
- package/dist/lib/common/constants.js +13 -0
- package/dist/lib/triggers/new-search-completed.d.ts +19 -0
- package/dist/lib/triggers/new-search-completed.js +100 -0
- package/package.json +54 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Live Map Leads
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# Live Map Leads — Activepieces piece
|
|
2
|
+
|
|
3
|
+
Pull filtered local-business lists from map data inside your [Activepieces](https://www.activepieces.com)
|
|
4
|
+
flows. Start a search and act on the results the moment they're ready.
|
|
5
|
+
|
|
6
|
+
- **Action — Start a Search** — kick off a local-business search (`POST /api/v1/jobs`).
|
|
7
|
+
- **Trigger — New Search Completed** — fires when one of your searches finishes, with its
|
|
8
|
+
record count, the credits charged, and ready-to-use download links (CSV, XLSX, JSON).
|
|
9
|
+
- **Connection — API Key** — paste an API key from your Live Map Leads account settings.
|
|
10
|
+
|
|
11
|
+
## How it talks to Live Map Leads
|
|
12
|
+
|
|
13
|
+
The piece is a thin, typed client of the public [Live Map Leads API](https://livemapleads.com/api/v1/openapi.yaml)
|
|
14
|
+
(`/api/v1`). It does **not** re-describe request or response shapes by hand:
|
|
15
|
+
|
|
16
|
+
- `openapi.yaml` is vendored from the live, unauthenticated `GET /api/v1/openapi.yaml`.
|
|
17
|
+
- `src/generated/v1.ts` holds the spec-derived **types** (build-time only — erased at runtime).
|
|
18
|
+
- [`openapi-fetch`](https://openapi-ts.dev/openapi-fetch/) (~6 KB) is the single runtime
|
|
19
|
+
dependency; a wrong path, field, or query param fails to **compile**.
|
|
20
|
+
|
|
21
|
+
## Trigger: polling now, instant later
|
|
22
|
+
|
|
23
|
+
"New Search Completed" is a **polling** trigger: Activepieces checks the read-only list
|
|
24
|
+
endpoint (`GET /api/v1/jobs?status=completed&since=…`) on its schedule and emits one item per
|
|
25
|
+
newly-finished search. Dedupe is time-based on each search's completion time — exactly the
|
|
26
|
+
`since` cursor the API exposes. Listing spends **no credits**.
|
|
27
|
+
|
|
28
|
+
Polling-first is deliberate: the connector ships on the API alone, with no dependency on push
|
|
29
|
+
delivery. An **instant** (webhook) variant is a drop-in upgrade when Live Map Leads adds
|
|
30
|
+
push: only [`src/lib/triggers/new-search-completed.ts`](src/lib/triggers/new-search-completed.ts)
|
|
31
|
+
changes its trigger strategy — the action, the API client, and the connection stay as they are.
|
|
32
|
+
|
|
33
|
+
### Known limit
|
|
34
|
+
|
|
35
|
+
Each poll fetches the newest 100 completed searches (the API maximum; the API has no forward
|
|
36
|
+
pagination). If **more than 100** of your searches were to complete inside a single polling
|
|
37
|
+
interval, the overflow beyond the newest 100 would not be emitted. This is the upstream API's
|
|
38
|
+
accepted polling residual and is effectively unreachable in practice — an account runs one
|
|
39
|
+
search at a time, so a poll interval can't accumulate 100+ completions. The instant (webhook)
|
|
40
|
+
trigger removes the ceiling entirely.
|
|
41
|
+
|
|
42
|
+
## Develop
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install
|
|
46
|
+
npm run typecheck # tsc (sources + tests)
|
|
47
|
+
npm run lint # eslint
|
|
48
|
+
npm run test # vitest — runs against a MOCKED API, never spends credits
|
|
49
|
+
npm run build # emits dist/
|
|
50
|
+
npm run check # all of the above
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
To run it inside Activepieces locally, copy/symlink this package into your Activepieces
|
|
54
|
+
`packages/pieces/community/live-map-leads` and use the standard piece hot-reload dev loop
|
|
55
|
+
(`npm start` in the Activepieces repo). See the Activepieces
|
|
56
|
+
[piece development docs](https://www.activepieces.com/docs/build-pieces/building-pieces/start-building).
|
|
57
|
+
|
|
58
|
+
### Updating the client when the API changes
|
|
59
|
+
|
|
60
|
+
The API contract is the OpenAPI spec. To pull in API changes:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
# 1. Refresh the vendored spec from production (byte-identical to docs/api/openapi.yaml).
|
|
64
|
+
curl -fsS https://livemapleads.com/api/v1/openapi.yaml -o openapi.yaml
|
|
65
|
+
|
|
66
|
+
# 2. Regenerate the typed client (pinned generator → reproducible output).
|
|
67
|
+
npm run gen:types # openapi-typescript 7.13.0 → src/generated/v1.ts
|
|
68
|
+
|
|
69
|
+
# 3. tsc will flag any action/trigger that no longer matches the new contract.
|
|
70
|
+
npm run check
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`--default-non-nullable false` (already in the `gen:types` script) makes request-body fields
|
|
74
|
+
that carry a server-side default optional, so callers don't have to send them.
|
|
75
|
+
|
|
76
|
+
## Publishing
|
|
77
|
+
|
|
78
|
+
Two sanctioned routes (operator-gated — needs the npm scope / marketplace account):
|
|
79
|
+
|
|
80
|
+
1. **Community / public npm (soft launch).** `npm publish --access public` under the
|
|
81
|
+
`@livemapleads` scope, then install it in Activepieces by package name. Fast; no SEO surface.
|
|
82
|
+
2. **Contribute (cloud marketplace).** Open a PR adding this package to the Activepieces
|
|
83
|
+
monorepo under `packages/pieces/community/live-map-leads`. This earns the indexed
|
|
84
|
+
`activepieces.com/pieces/live-map-leads` listing page — the SEO-bearing path — and is done
|
|
85
|
+
once the piece is proven.
|
|
86
|
+
|
|
87
|
+
Before publishing, confirm `minimumSupportedRelease` (in [`src/index.ts`](src/index.ts)) against
|
|
88
|
+
your target Activepieces version, and that the `logoUrl` resolves.
|
|
89
|
+
|
|
90
|
+
## License
|
|
91
|
+
|
|
92
|
+
[MIT](../../LICENSE).
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This file was auto-generated by openapi-typescript.
|
|
3
|
+
* Do not make direct changes to the file.
|
|
4
|
+
*/
|
|
5
|
+
export interface paths {
|
|
6
|
+
"/jobs": {
|
|
7
|
+
parameters: {
|
|
8
|
+
query?: never;
|
|
9
|
+
header?: never;
|
|
10
|
+
path?: never;
|
|
11
|
+
cookie?: never;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* List completed searches
|
|
15
|
+
* @description List the calling account's completed searches, newest first, each with
|
|
16
|
+
* its result count and download links. Only the calling account's own
|
|
17
|
+
* searches are ever returned — no cross-account identifiers are exposed.
|
|
18
|
+
* This is the polling source for a "New search completed" automation
|
|
19
|
+
* trigger.
|
|
20
|
+
*
|
|
21
|
+
* For incremental polling, pass `since` = the most recent `completedAt` you
|
|
22
|
+
* have already seen; the response returns only searches that finished
|
|
23
|
+
* strictly after it, ordered by `completedAt` descending. De-duplicate by
|
|
24
|
+
* the stable `jobId`. See `hasMore` for the rare case where a single page
|
|
25
|
+
* does not cover everything since `since`.
|
|
26
|
+
*/
|
|
27
|
+
get: operations["listSearches"];
|
|
28
|
+
put?: never;
|
|
29
|
+
/**
|
|
30
|
+
* Start a search
|
|
31
|
+
* @description Start a new local-business search. Returns immediately with a `jobId`
|
|
32
|
+
* and `status: "queued"`; the search runs in the background. Watch for it
|
|
33
|
+
* to finish via `GET /jobs?status=completed&since=…`.
|
|
34
|
+
*
|
|
35
|
+
* Provide a location as **exactly one** of `locationQuery` (text) or
|
|
36
|
+
* `customGeolocation` (a drawn area) — one is required, and supplying both
|
|
37
|
+
* is rejected.
|
|
38
|
+
*/
|
|
39
|
+
post: operations["startSearch"];
|
|
40
|
+
delete?: never;
|
|
41
|
+
options?: never;
|
|
42
|
+
head?: never;
|
|
43
|
+
patch?: never;
|
|
44
|
+
trace?: never;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export type webhooks = Record<string, never>;
|
|
48
|
+
export interface components {
|
|
49
|
+
schemas: {
|
|
50
|
+
/**
|
|
51
|
+
* @description Search definition. `additionalProperties: false` is a HARD part of the
|
|
52
|
+
* contract: the v1 route MUST validate against this restricted field set and
|
|
53
|
+
* reject any other field — it must NOT forward the raw body to the full
|
|
54
|
+
* internal search schema. That internal schema also accepts add-on /
|
|
55
|
+
* enrichment toggles (reviews, images, extra details — each billable),
|
|
56
|
+
* premium / personal-data add-ons, and webhook routing (`deliverTo`); all of
|
|
57
|
+
* those are DEFERRED from v1 (Phase 24.5) and MUST stay unreachable through
|
|
58
|
+
* this endpoint (no billable add-on or personal-data path via v1). The field
|
|
59
|
+
* names that DO appear here match the internal schema so the validated subset
|
|
60
|
+
* maps onto the existing job-start path with no new credit logic. Exactly one
|
|
61
|
+
* of `locationQuery` or `customGeolocation` is required.
|
|
62
|
+
*/
|
|
63
|
+
StartSearchRequest: {
|
|
64
|
+
/**
|
|
65
|
+
* @description One or more search terms, e.g. "dentist" or "italian restaurant". Each term runs against the location.
|
|
66
|
+
* @example [
|
|
67
|
+
* "dentist"
|
|
68
|
+
* ]
|
|
69
|
+
*/
|
|
70
|
+
searchStrings: string[];
|
|
71
|
+
/**
|
|
72
|
+
* @description Free-text location, e.g. "Austin, TX" or "Lyon, France". Mutually exclusive with `customGeolocation`.
|
|
73
|
+
* @example Austin, TX
|
|
74
|
+
*/
|
|
75
|
+
locationQuery?: string;
|
|
76
|
+
customGeolocation?: components["schemas"]["GeoArea"];
|
|
77
|
+
/**
|
|
78
|
+
* @description Result language as a BCP-47 code.
|
|
79
|
+
* @default en
|
|
80
|
+
*/
|
|
81
|
+
language?: string;
|
|
82
|
+
/** @description Optional cap on the number of places returned per search term. */
|
|
83
|
+
maxResults?: number;
|
|
84
|
+
/**
|
|
85
|
+
* @description How a business name must match your search term. `all` = no name filter; `contains` = name contains the term; `exact` = name equals the term.
|
|
86
|
+
* @default all
|
|
87
|
+
* @enum {string}
|
|
88
|
+
*/
|
|
89
|
+
nameMatch?: "all" | "contains" | "exact";
|
|
90
|
+
/**
|
|
91
|
+
* @description Filter by website presence. `any` = no filter; `with` = only places that have a website; `without` = only places that do not.
|
|
92
|
+
* @default any
|
|
93
|
+
* @enum {string}
|
|
94
|
+
*/
|
|
95
|
+
website?: "any" | "with" | "without";
|
|
96
|
+
/** @description Restrict results to these business categories, e.g. "dentist" or "art gallery". Values are lowercase and must each be a recognised category — an unknown value is rejected with 400. The recognised set is large (thousands of entries) and is versioned with the data provider, so it is not enumerated inline here; a future GET /api/v1/categories endpoint will expose the current list for connector dropdowns. Omit this field to apply no category filter. */
|
|
97
|
+
categories?: string[];
|
|
98
|
+
/**
|
|
99
|
+
* @description Exclude permanently or temporarily closed places.
|
|
100
|
+
* @default false
|
|
101
|
+
*/
|
|
102
|
+
skipClosed?: boolean;
|
|
103
|
+
/** @description Optional per-search spending ceiling, in credits. The search stops before exceeding it. Recommended for unattended automations. */
|
|
104
|
+
capCredits?: number;
|
|
105
|
+
};
|
|
106
|
+
/** @description A drawn search area as GeoJSON. Coordinates are [longitude, latitude] pairs. Mutually exclusive with `locationQuery`. */
|
|
107
|
+
GeoArea: {
|
|
108
|
+
/** @enum {string} */
|
|
109
|
+
type: "Polygon" | "MultiPolygon";
|
|
110
|
+
/** @description GeoJSON coordinate array. For `Polygon`: an array of linear rings (each an array of [lng, lat] positions). For `MultiPolygon`: an array of polygons. */
|
|
111
|
+
coordinates: unknown[];
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* @example {
|
|
115
|
+
* "jobId": "8f1d6c2a-3b4e-4f7a-9c1d-2e3f4a5b6c7d",
|
|
116
|
+
* "status": "queued"
|
|
117
|
+
* }
|
|
118
|
+
*/
|
|
119
|
+
StartSearchResponse: {
|
|
120
|
+
/**
|
|
121
|
+
* Format: uuid
|
|
122
|
+
* @description Unique identifier for the search. Use it to match results later.
|
|
123
|
+
* @example 8f1d6c2a-3b4e-4f7a-9c1d-2e3f4a5b6c7d
|
|
124
|
+
*/
|
|
125
|
+
jobId: string;
|
|
126
|
+
/**
|
|
127
|
+
* @description Always "queued" on a successful start.
|
|
128
|
+
* @enum {string}
|
|
129
|
+
*/
|
|
130
|
+
status: "queued";
|
|
131
|
+
};
|
|
132
|
+
SearchList: {
|
|
133
|
+
/** @description Completed searches, newest first. */
|
|
134
|
+
data: components["schemas"]["SearchSummary"][];
|
|
135
|
+
/** @description True when more completed searches matched than fit in `limit` — the page was truncated to the newest `limit`. Rare for a single account. To retrieve the remainder, repeat the call with a larger `limit` (max 100) and the SAME `since`; only advance `since` to the newest `completedAt` you have seen once `hasMore` is false. Advancing `since` while `hasMore` is true would skip the older, un-returned searches. Results are de-duplicated by `jobId` regardless. */
|
|
136
|
+
hasMore: boolean;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* @description A finished search. Mirrors the fields delivered to outbound webhooks (Phase 23), the existing "completed search" payload.
|
|
140
|
+
* @example {
|
|
141
|
+
* "jobId": "8f1d6c2a-3b4e-4f7a-9c1d-2e3f4a5b6c7d",
|
|
142
|
+
* "status": "completed",
|
|
143
|
+
* "recordCount": 142,
|
|
144
|
+
* "creditsCharged": 142,
|
|
145
|
+
* "completedAt": "2026-06-12T14:22:05Z",
|
|
146
|
+
* "downloads": {
|
|
147
|
+
* "csv": "https://livemapleads.com/api/exports/download?token=eyJhbGciOi...",
|
|
148
|
+
* "xlsx": "https://livemapleads.com/api/exports/download?token=eyJhbGciOi...",
|
|
149
|
+
* "json": "https://livemapleads.com/api/exports/download?token=eyJhbGciOi..."
|
|
150
|
+
* },
|
|
151
|
+
* "summary": "Your search finished with 142 records."
|
|
152
|
+
* }
|
|
153
|
+
*/
|
|
154
|
+
SearchSummary: {
|
|
155
|
+
/**
|
|
156
|
+
* Format: uuid
|
|
157
|
+
* @description Stable identifier — use it as the de-duplication key when polling.
|
|
158
|
+
*/
|
|
159
|
+
jobId: string;
|
|
160
|
+
/**
|
|
161
|
+
* @description Always `completed` for this endpoint (it lists completed searches); maps from the internal `complete` state. The full public lifecycle (`queued`/`running`/`completed`/`cancelled`/`failed`, mapping from the internal `queued`/`running`/`complete`/`aborted`/`failed`+`timed_out`) arrives with the single-search detail route in a later phase (24.5).
|
|
162
|
+
* @enum {string}
|
|
163
|
+
*/
|
|
164
|
+
status: "completed";
|
|
165
|
+
/** @description Number of business records the search returned. */
|
|
166
|
+
recordCount: number;
|
|
167
|
+
/** @description Credits charged for this search (charge-on-success). */
|
|
168
|
+
creditsCharged: number;
|
|
169
|
+
/**
|
|
170
|
+
* Format: date-time
|
|
171
|
+
* @description When the search finished (RFC 3339).
|
|
172
|
+
*/
|
|
173
|
+
completedAt: string;
|
|
174
|
+
/** @description Ready-to-use download links, one per format. Each is a signed, time-limited URL (about 7 days) that needs no API key. Null if download links are unavailable for this search. */
|
|
175
|
+
downloads: components["schemas"]["Downloads"] | null;
|
|
176
|
+
/**
|
|
177
|
+
* @description Short human-readable summary of the result.
|
|
178
|
+
* @example Your search finished with 142 records.
|
|
179
|
+
*/
|
|
180
|
+
summary?: string;
|
|
181
|
+
};
|
|
182
|
+
/** @description Signed, time-limited download URLs by format. No API key needed to fetch. */
|
|
183
|
+
Downloads: {
|
|
184
|
+
/** Format: uri */
|
|
185
|
+
csv?: string;
|
|
186
|
+
/** Format: uri */
|
|
187
|
+
xlsx?: string;
|
|
188
|
+
/** Format: uri */
|
|
189
|
+
json?: string;
|
|
190
|
+
};
|
|
191
|
+
Error: {
|
|
192
|
+
/**
|
|
193
|
+
* @description Stable machine-readable error code.
|
|
194
|
+
* @example invalid_request
|
|
195
|
+
*/
|
|
196
|
+
error: string;
|
|
197
|
+
/** @description Human-readable explanation. Do not parse — read `error`. */
|
|
198
|
+
message: string;
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
responses: {
|
|
202
|
+
/** @description The request body or query failed validation (e.g. unknown category, both location forms supplied, value out of range). */
|
|
203
|
+
BadRequest: {
|
|
204
|
+
headers: {
|
|
205
|
+
[name: string]: unknown;
|
|
206
|
+
};
|
|
207
|
+
content: {
|
|
208
|
+
/**
|
|
209
|
+
* @example {
|
|
210
|
+
* "error": "invalid_request",
|
|
211
|
+
* "message": "Provide exactly one of locationQuery or customGeolocation."
|
|
212
|
+
* }
|
|
213
|
+
*/
|
|
214
|
+
"application/json": components["schemas"]["Error"];
|
|
215
|
+
};
|
|
216
|
+
};
|
|
217
|
+
/** @description Missing, invalid, or revoked API key. */
|
|
218
|
+
Unauthorized: {
|
|
219
|
+
headers: {
|
|
220
|
+
[name: string]: unknown;
|
|
221
|
+
};
|
|
222
|
+
content: {
|
|
223
|
+
/**
|
|
224
|
+
* @example {
|
|
225
|
+
* "error": "unauthorized",
|
|
226
|
+
* "message": "Missing or invalid API key."
|
|
227
|
+
* }
|
|
228
|
+
*/
|
|
229
|
+
"application/json": components["schemas"]["Error"];
|
|
230
|
+
};
|
|
231
|
+
};
|
|
232
|
+
/** @description Rate limit exceeded for this key. */
|
|
233
|
+
TooManyRequests: {
|
|
234
|
+
headers: {
|
|
235
|
+
/** @description Seconds to wait before retrying. */
|
|
236
|
+
"Retry-After"?: number;
|
|
237
|
+
[name: string]: unknown;
|
|
238
|
+
};
|
|
239
|
+
content: {
|
|
240
|
+
/**
|
|
241
|
+
* @example {
|
|
242
|
+
* "error": "rate_limited",
|
|
243
|
+
* "message": "Too many requests. Retry after the indicated delay."
|
|
244
|
+
* }
|
|
245
|
+
*/
|
|
246
|
+
"application/json": components["schemas"]["Error"];
|
|
247
|
+
};
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
parameters: never;
|
|
251
|
+
requestBodies: never;
|
|
252
|
+
headers: never;
|
|
253
|
+
pathItems: never;
|
|
254
|
+
}
|
|
255
|
+
export type $defs = Record<string, never>;
|
|
256
|
+
export interface operations {
|
|
257
|
+
listSearches: {
|
|
258
|
+
parameters: {
|
|
259
|
+
query?: {
|
|
260
|
+
/**
|
|
261
|
+
* @description Lifecycle filter. v1 supports only `completed` (the value a polling
|
|
262
|
+
* trigger needs); it is also the default.
|
|
263
|
+
*/
|
|
264
|
+
status?: "completed";
|
|
265
|
+
/**
|
|
266
|
+
* @description Return only searches that completed strictly after this RFC 3339 timestamp. Omit on the first poll.
|
|
267
|
+
* @example 2026-06-12T00:00:00Z
|
|
268
|
+
*/
|
|
269
|
+
since?: string;
|
|
270
|
+
/** @description Maximum number of searches to return. */
|
|
271
|
+
limit?: number;
|
|
272
|
+
};
|
|
273
|
+
header?: never;
|
|
274
|
+
path?: never;
|
|
275
|
+
cookie?: never;
|
|
276
|
+
};
|
|
277
|
+
requestBody?: never;
|
|
278
|
+
responses: {
|
|
279
|
+
/** @description A page of completed searches, newest first. */
|
|
280
|
+
200: {
|
|
281
|
+
headers: {
|
|
282
|
+
[name: string]: unknown;
|
|
283
|
+
};
|
|
284
|
+
content: {
|
|
285
|
+
"application/json": components["schemas"]["SearchList"];
|
|
286
|
+
};
|
|
287
|
+
};
|
|
288
|
+
400: components["responses"]["BadRequest"];
|
|
289
|
+
401: components["responses"]["Unauthorized"];
|
|
290
|
+
429: components["responses"]["TooManyRequests"];
|
|
291
|
+
};
|
|
292
|
+
};
|
|
293
|
+
startSearch: {
|
|
294
|
+
parameters: {
|
|
295
|
+
query?: never;
|
|
296
|
+
header?: never;
|
|
297
|
+
path?: never;
|
|
298
|
+
cookie?: never;
|
|
299
|
+
};
|
|
300
|
+
requestBody: {
|
|
301
|
+
content: {
|
|
302
|
+
"application/json": components["schemas"]["StartSearchRequest"];
|
|
303
|
+
};
|
|
304
|
+
};
|
|
305
|
+
responses: {
|
|
306
|
+
/** @description Search accepted and queued. */
|
|
307
|
+
202: {
|
|
308
|
+
headers: {
|
|
309
|
+
[name: string]: unknown;
|
|
310
|
+
};
|
|
311
|
+
content: {
|
|
312
|
+
"application/json": components["schemas"]["StartSearchResponse"];
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
400: components["responses"]["BadRequest"];
|
|
316
|
+
401: components["responses"]["Unauthorized"];
|
|
317
|
+
429: components["responses"]["TooManyRequests"];
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.liveMapLeads = exports.liveMapLeadsAuth = void 0;
|
|
4
|
+
// Live Map Leads — Activepieces piece.
|
|
5
|
+
//
|
|
6
|
+
// Pull filtered local-business lists from map data: start a search, then act on the results
|
|
7
|
+
// when the search completes. A thin, typed client of the public Live Map Leads API
|
|
8
|
+
// (https://livemapleads.com/api/v1) — the OpenAPI spec is the contract.
|
|
9
|
+
const shared_1 = require("@activepieces/shared");
|
|
10
|
+
const pieces_framework_1 = require("@activepieces/pieces-framework");
|
|
11
|
+
const start_search_1 = require("./lib/actions/start-search");
|
|
12
|
+
const auth_1 = require("./lib/common/auth");
|
|
13
|
+
Object.defineProperty(exports, "liveMapLeadsAuth", { enumerable: true, get: function () { return auth_1.liveMapLeadsAuth; } });
|
|
14
|
+
const new_search_completed_1 = require("./lib/triggers/new-search-completed");
|
|
15
|
+
exports.liveMapLeads = (0, pieces_framework_1.createPiece)({
|
|
16
|
+
displayName: 'Live Map Leads',
|
|
17
|
+
description: 'Pull filtered local-business lists from map data. Start a search and get a notification when your results are ready to download.',
|
|
18
|
+
auth: auth_1.liveMapLeadsAuth,
|
|
19
|
+
// The lowest Activepieces release this piece is known to run on. Confirm/raise against
|
|
20
|
+
// your target Activepieces version before publishing (see README § Publishing).
|
|
21
|
+
minimumSupportedRelease: '0.36.1',
|
|
22
|
+
logoUrl: 'https://livemapleads.com/logo-icon.png',
|
|
23
|
+
categories: [shared_1.PieceCategory.MARKETING, shared_1.PieceCategory.SALES_AND_CRM],
|
|
24
|
+
authors: ['livemapleads'],
|
|
25
|
+
actions: [start_search_1.startSearchAction],
|
|
26
|
+
triggers: [new_search_completed_1.newSearchCompleted],
|
|
27
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type StartSearchBody } from '../common/api';
|
|
2
|
+
/** Shape of this action's prop values (kept permissive so the framework's inferred type assigns). */
|
|
3
|
+
export interface StartSearchProps {
|
|
4
|
+
searchTerms: unknown;
|
|
5
|
+
location: string;
|
|
6
|
+
maxResults?: number;
|
|
7
|
+
nameMatch?: 'all' | 'contains' | 'exact';
|
|
8
|
+
website?: 'any' | 'with' | 'without';
|
|
9
|
+
categories?: unknown;
|
|
10
|
+
skipClosed?: boolean;
|
|
11
|
+
capCredits?: number;
|
|
12
|
+
language?: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Map the action's prop values to the API request body. Pure + exported so it is unit-
|
|
16
|
+
* tested without the Activepieces runtime. Optional fields are omitted entirely when unset
|
|
17
|
+
* so the API applies its own defaults. Throws when no search term survives trimming (the
|
|
18
|
+
* field is required, but an array of blanks would otherwise send an empty search).
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildStartSearchBody(props: StartSearchProps): StartSearchBody;
|
|
21
|
+
export declare const startSearchAction: import("@activepieces/pieces-framework").IAction<import("@activepieces/pieces-framework").SecretTextProperty<true>, {
|
|
22
|
+
searchTerms: import("@activepieces/pieces-framework").ArrayProperty<true>;
|
|
23
|
+
location: import("@activepieces/pieces-framework").ShortTextProperty<true>;
|
|
24
|
+
maxResults: import("@activepieces/pieces-framework").NumberProperty<false>;
|
|
25
|
+
nameMatch: import("@activepieces/pieces-framework").StaticDropdownProperty<"all" | "contains" | "exact", false> | import("@activepieces/pieces-framework").StaticDropdownProperty<"all" | "contains" | "exact", true>;
|
|
26
|
+
website: import("@activepieces/pieces-framework").StaticDropdownProperty<"any" | "with" | "without", false> | import("@activepieces/pieces-framework").StaticDropdownProperty<"any" | "with" | "without", true>;
|
|
27
|
+
categories: import("@activepieces/pieces-framework").ArrayProperty<false>;
|
|
28
|
+
skipClosed: import("@activepieces/pieces-framework").CheckboxProperty<false>;
|
|
29
|
+
capCredits: import("@activepieces/pieces-framework").NumberProperty<false>;
|
|
30
|
+
language: import("@activepieces/pieces-framework").ShortTextProperty<false>;
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.startSearchAction = void 0;
|
|
4
|
+
exports.buildStartSearchBody = buildStartSearchBody;
|
|
5
|
+
// Action: "Start a Search" — POST /api/v1/jobs.
|
|
6
|
+
//
|
|
7
|
+
// Exposes the restricted public search fields (openapi.yaml StartSearchRequest). The
|
|
8
|
+
// location is a plain text query (the natural input for a no-code user); the drawn-area
|
|
9
|
+
// (GeoJSON) form the API also accepts is intentionally not surfaced here. Billable add-ons
|
|
10
|
+
// and personal-data options are not part of the v1 surface at all, so they can't be
|
|
11
|
+
// reached from this action.
|
|
12
|
+
const pieces_framework_1 = require("@activepieces/pieces-framework");
|
|
13
|
+
const api_1 = require("../common/api");
|
|
14
|
+
const auth_1 = require("../common/auth");
|
|
15
|
+
const client_1 = require("../common/client");
|
|
16
|
+
/** Coerce an Activepieces array prop (its items are `unknown`) to clean, non-empty strings. */
|
|
17
|
+
function toStringList(value) {
|
|
18
|
+
if (!Array.isArray(value))
|
|
19
|
+
return [];
|
|
20
|
+
return value
|
|
21
|
+
.map((item) => (typeof item === 'string' ? item.trim() : String(item ?? '').trim()))
|
|
22
|
+
.filter((item) => item.length > 0);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Map the action's prop values to the API request body. Pure + exported so it is unit-
|
|
26
|
+
* tested without the Activepieces runtime. Optional fields are omitted entirely when unset
|
|
27
|
+
* so the API applies its own defaults. Throws when no search term survives trimming (the
|
|
28
|
+
* field is required, but an array of blanks would otherwise send an empty search).
|
|
29
|
+
*/
|
|
30
|
+
function buildStartSearchBody(props) {
|
|
31
|
+
const searchStrings = toStringList(props.searchTerms);
|
|
32
|
+
if (searchStrings.length === 0) {
|
|
33
|
+
throw new Error('Enter at least one search term, e.g. "dentist".');
|
|
34
|
+
}
|
|
35
|
+
const location = (props.location ?? '').trim();
|
|
36
|
+
if (location.length === 0) {
|
|
37
|
+
throw new Error('Enter a location, e.g. "Austin, TX".');
|
|
38
|
+
}
|
|
39
|
+
const body = {
|
|
40
|
+
searchStrings,
|
|
41
|
+
locationQuery: location,
|
|
42
|
+
};
|
|
43
|
+
if (props.maxResults != null)
|
|
44
|
+
body.maxResults = props.maxResults;
|
|
45
|
+
if (props.nameMatch)
|
|
46
|
+
body.nameMatch = props.nameMatch;
|
|
47
|
+
if (props.website)
|
|
48
|
+
body.website = props.website;
|
|
49
|
+
if (props.skipClosed != null)
|
|
50
|
+
body.skipClosed = props.skipClosed;
|
|
51
|
+
if (props.capCredits != null)
|
|
52
|
+
body.capCredits = props.capCredits;
|
|
53
|
+
if (props.language && props.language.trim().length > 0)
|
|
54
|
+
body.language = props.language.trim();
|
|
55
|
+
const categories = toStringList(props.categories);
|
|
56
|
+
if (categories.length > 0)
|
|
57
|
+
body.categories = categories;
|
|
58
|
+
return body;
|
|
59
|
+
}
|
|
60
|
+
exports.startSearchAction = (0, pieces_framework_1.createAction)({
|
|
61
|
+
auth: auth_1.liveMapLeadsAuth,
|
|
62
|
+
name: 'start_search',
|
|
63
|
+
displayName: 'Start a Search',
|
|
64
|
+
description: 'Start a new local-business search. Returns right away with a search ID while the search runs in the background — use the "New Search Completed" trigger to pick up the results when they are ready.',
|
|
65
|
+
props: {
|
|
66
|
+
searchTerms: pieces_framework_1.Property.Array({
|
|
67
|
+
displayName: 'Search Terms',
|
|
68
|
+
description: 'What to look for, e.g. "dentist" or "italian restaurant". Add one term per line; each runs against your location.',
|
|
69
|
+
required: true,
|
|
70
|
+
}),
|
|
71
|
+
location: pieces_framework_1.Property.ShortText({
|
|
72
|
+
displayName: 'Location',
|
|
73
|
+
description: 'Where to search, e.g. "Austin, TX" or "Lyon, France".',
|
|
74
|
+
required: true,
|
|
75
|
+
}),
|
|
76
|
+
maxResults: pieces_framework_1.Property.Number({
|
|
77
|
+
displayName: 'Max Results per Term',
|
|
78
|
+
description: 'Optional cap on how many businesses to return for each search term. Leave empty for no cap.',
|
|
79
|
+
required: false,
|
|
80
|
+
}),
|
|
81
|
+
nameMatch: pieces_framework_1.Property.StaticDropdown({
|
|
82
|
+
displayName: 'Business Name Match',
|
|
83
|
+
description: 'How strictly a business name must match your search term.',
|
|
84
|
+
required: false,
|
|
85
|
+
options: {
|
|
86
|
+
options: [
|
|
87
|
+
{ label: 'Any name (no filter)', value: 'all' },
|
|
88
|
+
{ label: 'Name contains the term', value: 'contains' },
|
|
89
|
+
{ label: 'Name is exactly the term', value: 'exact' },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
website: pieces_framework_1.Property.StaticDropdown({
|
|
94
|
+
displayName: 'Website Filter',
|
|
95
|
+
description: 'Keep only businesses with or without a website.',
|
|
96
|
+
required: false,
|
|
97
|
+
options: {
|
|
98
|
+
options: [
|
|
99
|
+
{ label: 'Any (no filter)', value: 'any' },
|
|
100
|
+
{ label: 'Only with a website', value: 'with' },
|
|
101
|
+
{ label: 'Only without a website', value: 'without' },
|
|
102
|
+
],
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
categories: pieces_framework_1.Property.Array({
|
|
106
|
+
displayName: 'Categories',
|
|
107
|
+
description: 'Optional. Restrict results to specific business categories (e.g. "dentist", "art gallery"). Each must be a recognised category — an unknown value is rejected. Leave empty to apply no category filter.',
|
|
108
|
+
required: false,
|
|
109
|
+
}),
|
|
110
|
+
skipClosed: pieces_framework_1.Property.Checkbox({
|
|
111
|
+
displayName: 'Skip Closed Businesses',
|
|
112
|
+
description: 'Exclude permanently or temporarily closed businesses.',
|
|
113
|
+
required: false,
|
|
114
|
+
}),
|
|
115
|
+
capCredits: pieces_framework_1.Property.Number({
|
|
116
|
+
displayName: 'Credit Limit',
|
|
117
|
+
description: 'Optional ceiling on how many credits this search may spend. Recommended for unattended automations.',
|
|
118
|
+
required: false,
|
|
119
|
+
}),
|
|
120
|
+
language: pieces_framework_1.Property.ShortText({
|
|
121
|
+
displayName: 'Result Language',
|
|
122
|
+
description: 'Optional language code for results (e.g. "en", "fr", "pt-br"). Defaults to English.',
|
|
123
|
+
required: false,
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
async run(context) {
|
|
127
|
+
const body = buildStartSearchBody(context.propsValue);
|
|
128
|
+
return await (0, api_1.startSearch)((0, client_1.resolveApiKey)(context.auth), body);
|
|
129
|
+
},
|
|
130
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { components } from '../../generated/v1';
|
|
2
|
+
/** The restricted public search request body (openapi.yaml StartSearchRequest). */
|
|
3
|
+
export type StartSearchBody = components['schemas']['StartSearchRequest'];
|
|
4
|
+
/** 202 response from starting a search. */
|
|
5
|
+
export type StartSearchResult = components['schemas']['StartSearchResponse'];
|
|
6
|
+
/** One completed search in the polling list. */
|
|
7
|
+
export type SearchSummary = components['schemas']['SearchSummary'];
|
|
8
|
+
/** A page of completed searches. */
|
|
9
|
+
export type SearchList = components['schemas']['SearchList'];
|
|
10
|
+
/**
|
|
11
|
+
* A failed Live Map Leads API call. `status` is the HTTP status (0 if the request never
|
|
12
|
+
* reached the server); `code` is the stable machine code from the API (`unauthorized`,
|
|
13
|
+
* `rate_limited`, `invalid_request`, …) or a synthetic one for transport failures.
|
|
14
|
+
* `message` is already ICP-friendly — safe to surface to the user.
|
|
15
|
+
*/
|
|
16
|
+
export declare class LiveMapLeadsApiError extends Error {
|
|
17
|
+
readonly status: number;
|
|
18
|
+
readonly code: string;
|
|
19
|
+
constructor(status: number, code: string, message: string);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Start a search (POST /jobs). Returns the `{ jobId, status: "queued" }` accept response.
|
|
23
|
+
* The search runs in the background; credits are charged only on success, and never for a
|
|
24
|
+
* search that fails. Throws `LiveMapLeadsApiError` on any non-2xx / transport failure.
|
|
25
|
+
*/
|
|
26
|
+
export declare function startSearch(apiKey: string, body: StartSearchBody, baseUrl?: string): Promise<StartSearchResult>;
|
|
27
|
+
/**
|
|
28
|
+
* List the account's completed searches, newest first (GET /jobs). `since` (RFC 3339)
|
|
29
|
+
* returns only searches that finished strictly after it — pass the newest `completedAt`
|
|
30
|
+
* you have already seen for incremental polling. Read-only: spends no credits. Throws
|
|
31
|
+
* `LiveMapLeadsApiError` on any non-2xx / transport failure.
|
|
32
|
+
*/
|
|
33
|
+
export declare function listCompletedSearches(apiKey: string, params: {
|
|
34
|
+
since?: string;
|
|
35
|
+
limit?: number;
|
|
36
|
+
}, baseUrl?: string): Promise<SearchList>;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LiveMapLeadsApiError = void 0;
|
|
4
|
+
exports.startSearch = startSearch;
|
|
5
|
+
exports.listCompletedSearches = listCompletedSearches;
|
|
6
|
+
const client_1 = require("./client");
|
|
7
|
+
const constants_1 = require("./constants");
|
|
8
|
+
/**
|
|
9
|
+
* A failed Live Map Leads API call. `status` is the HTTP status (0 if the request never
|
|
10
|
+
* reached the server); `code` is the stable machine code from the API (`unauthorized`,
|
|
11
|
+
* `rate_limited`, `invalid_request`, …) or a synthetic one for transport failures.
|
|
12
|
+
* `message` is already ICP-friendly — safe to surface to the user.
|
|
13
|
+
*/
|
|
14
|
+
class LiveMapLeadsApiError extends Error {
|
|
15
|
+
status;
|
|
16
|
+
code;
|
|
17
|
+
constructor(status, code, message) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'LiveMapLeadsApiError';
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
exports.LiveMapLeadsApiError = LiveMapLeadsApiError;
|
|
25
|
+
/** Narrow an unknown error body to the API's `{ error, message }` Error schema. */
|
|
26
|
+
function readErrorBody(body) {
|
|
27
|
+
if (body && typeof body === 'object') {
|
|
28
|
+
const record = body;
|
|
29
|
+
return {
|
|
30
|
+
code: typeof record.error === 'string' ? record.error : undefined,
|
|
31
|
+
message: typeof record.message === 'string' ? record.message : undefined,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Turn an `openapi-fetch` failure into a `LiveMapLeadsApiError` with copy a non-technical
|
|
38
|
+
* user can act on. Known statuses get tailored guidance; the API's own human `message` is
|
|
39
|
+
* preferred for validation (400) errors, which are already specific and readable.
|
|
40
|
+
*/
|
|
41
|
+
function toApiError(errorBody, response) {
|
|
42
|
+
const status = response?.status ?? 0;
|
|
43
|
+
const { code: bodyCode, message: bodyMessage } = readErrorBody(errorBody);
|
|
44
|
+
if (status === 401 || status === 403) {
|
|
45
|
+
return new LiveMapLeadsApiError(status, bodyCode ?? 'unauthorized', 'Your API key was not accepted. Open your Live Map Leads account settings, check the key is correct and still active, then reconnect.');
|
|
46
|
+
}
|
|
47
|
+
if (status === 429) {
|
|
48
|
+
const retryAfter = response?.headers.get('retry-after');
|
|
49
|
+
const wait = retryAfter ? ` Wait ${retryAfter} seconds and try again.` : ' Please slow down and try again shortly.';
|
|
50
|
+
return new LiveMapLeadsApiError(status, bodyCode ?? 'rate_limited', `Too many requests.${wait}`);
|
|
51
|
+
}
|
|
52
|
+
if (status === 400 && bodyMessage) {
|
|
53
|
+
// The API's 400 message is human-readable and specific (e.g. an unrecognised category).
|
|
54
|
+
return new LiveMapLeadsApiError(status, bodyCode ?? 'invalid_request', bodyMessage);
|
|
55
|
+
}
|
|
56
|
+
if (status === 0) {
|
|
57
|
+
return new LiveMapLeadsApiError(0, 'network_error', "Couldn't reach Live Map Leads. Check your connection and try again.");
|
|
58
|
+
}
|
|
59
|
+
return new LiveMapLeadsApiError(status, bodyCode ?? 'request_failed', bodyMessage ?? 'Something went wrong talking to Live Map Leads. Please try again in a moment.');
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Start a search (POST /jobs). Returns the `{ jobId, status: "queued" }` accept response.
|
|
63
|
+
* The search runs in the background; credits are charged only on success, and never for a
|
|
64
|
+
* search that fails. Throws `LiveMapLeadsApiError` on any non-2xx / transport failure.
|
|
65
|
+
*/
|
|
66
|
+
async function startSearch(apiKey, body, baseUrl = constants_1.PROD_BASE_URL) {
|
|
67
|
+
let result;
|
|
68
|
+
try {
|
|
69
|
+
result = await (0, client_1.makeClient)(apiKey, baseUrl).POST('/jobs', { body });
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// fetch rejected (DNS / connection / abort) — openapi-fetch surfaces HTTP errors via
|
|
73
|
+
// `error`, but transport failures throw, so convert them to the friendly network error.
|
|
74
|
+
throw toApiError(undefined, undefined);
|
|
75
|
+
}
|
|
76
|
+
if (result.data === undefined) {
|
|
77
|
+
throw toApiError(result.error, result.response);
|
|
78
|
+
}
|
|
79
|
+
return result.data;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* List the account's completed searches, newest first (GET /jobs). `since` (RFC 3339)
|
|
83
|
+
* returns only searches that finished strictly after it — pass the newest `completedAt`
|
|
84
|
+
* you have already seen for incremental polling. Read-only: spends no credits. Throws
|
|
85
|
+
* `LiveMapLeadsApiError` on any non-2xx / transport failure.
|
|
86
|
+
*/
|
|
87
|
+
async function listCompletedSearches(apiKey, params, baseUrl = constants_1.PROD_BASE_URL) {
|
|
88
|
+
let result;
|
|
89
|
+
try {
|
|
90
|
+
result = await (0, client_1.makeClient)(apiKey, baseUrl).GET('/jobs', {
|
|
91
|
+
params: {
|
|
92
|
+
query: {
|
|
93
|
+
status: 'completed',
|
|
94
|
+
limit: params.limit ?? constants_1.MAX_LIST_LIMIT,
|
|
95
|
+
...(params.since ? { since: params.since } : {}),
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
throw toApiError(undefined, undefined);
|
|
102
|
+
}
|
|
103
|
+
if (result.data === undefined) {
|
|
104
|
+
throw toApiError(result.error, result.response);
|
|
105
|
+
}
|
|
106
|
+
return result.data;
|
|
107
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const liveMapLeadsAuth: import("@activepieces/pieces-framework").SecretTextProperty<true>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.liveMapLeadsAuth = void 0;
|
|
4
|
+
// The connection users set up once: their Live Map Leads API key.
|
|
5
|
+
const pieces_framework_1 = require("@activepieces/pieces-framework");
|
|
6
|
+
const api_1 = require("./api");
|
|
7
|
+
const client_1 = require("./client");
|
|
8
|
+
const AUTH_DESCRIPTION = `Connect your Live Map Leads account with an API key.
|
|
9
|
+
|
|
10
|
+
1. Sign in at https://livemapleads.com and open **Account settings → API keys**.
|
|
11
|
+
2. Create a key and copy it — it is shown only once.
|
|
12
|
+
3. Paste it below.
|
|
13
|
+
|
|
14
|
+
The key acts on behalf of your account: it can start searches (which spend credits) and read your results. Keep it secret.`;
|
|
15
|
+
exports.liveMapLeadsAuth = pieces_framework_1.PieceAuth.SecretText({
|
|
16
|
+
displayName: 'API Key',
|
|
17
|
+
description: AUTH_DESCRIPTION,
|
|
18
|
+
required: true,
|
|
19
|
+
// Confirm the key works the moment it is entered, using the read-only "list searches"
|
|
20
|
+
// endpoint (spends no credits). Only an explicit key rejection blocks the connection; a
|
|
21
|
+
// transient outage is not treated as a bad key.
|
|
22
|
+
validate: async ({ auth }) => {
|
|
23
|
+
try {
|
|
24
|
+
await (0, api_1.listCompletedSearches)((0, client_1.resolveApiKey)(auth), { limit: 1 });
|
|
25
|
+
return { valid: true };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
if (error instanceof api_1.LiveMapLeadsApiError && (error.status === 401 || error.status === 403)) {
|
|
29
|
+
return { valid: false, error: 'That API key was not accepted. Check it is correct and still active in your account settings.' };
|
|
30
|
+
}
|
|
31
|
+
// Network/server hiccup — don't reject a possibly-good key over a transient error.
|
|
32
|
+
return { valid: true };
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Client } from 'openapi-fetch';
|
|
2
|
+
import type { paths } from '../../generated/v1';
|
|
3
|
+
export type V1Client = Client<paths>;
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the API key string from an Activepieces auth value. A `SecretText` connection is
|
|
6
|
+
* typed as `{ type, secret_text }` by the SDK but has historically been delivered to piece
|
|
7
|
+
* code as the bare string — this accepts BOTH shapes so the key resolves correctly across
|
|
8
|
+
* Activepieces versions. Throws if neither form yields a non-empty key.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveApiKey(auth: unknown): string;
|
|
11
|
+
/**
|
|
12
|
+
* Build an authenticated client. `apiKey` is the account API key from Live Map Leads
|
|
13
|
+
* account settings; it is sent on the `x-api-key` header of every request. `baseUrl` is
|
|
14
|
+
* overridable only so tests can point at a mock server — published actions/triggers always
|
|
15
|
+
* use the production base.
|
|
16
|
+
*/
|
|
17
|
+
export declare function makeClient(apiKey: string, baseUrl?: string): V1Client;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.resolveApiKey = resolveApiKey;
|
|
7
|
+
exports.makeClient = makeClient;
|
|
8
|
+
// The typed HTTP client for the Live Map Leads API.
|
|
9
|
+
//
|
|
10
|
+
// The client is generated from the OpenAPI spec (`openapi.yaml`, vendored from the live
|
|
11
|
+
// `GET /api/v1/openapi.yaml`): `src/generated/v1.ts` holds the spec-derived TYPES
|
|
12
|
+
// (build-time only, erased at runtime) and `openapi-fetch` is the single ~6 KB runtime
|
|
13
|
+
// dependency. A wrong path, field, or query param fails to COMPILE — the spec is the
|
|
14
|
+
// contract, nothing here re-describes request or response shapes by hand. To refresh after
|
|
15
|
+
// the API changes: `npm run gen:types` (see README § Updating the client).
|
|
16
|
+
const openapi_fetch_1 = __importDefault(require("openapi-fetch"));
|
|
17
|
+
const constants_1 = require("./constants");
|
|
18
|
+
/**
|
|
19
|
+
* Resolve the API key string from an Activepieces auth value. A `SecretText` connection is
|
|
20
|
+
* typed as `{ type, secret_text }` by the SDK but has historically been delivered to piece
|
|
21
|
+
* code as the bare string — this accepts BOTH shapes so the key resolves correctly across
|
|
22
|
+
* Activepieces versions. Throws if neither form yields a non-empty key.
|
|
23
|
+
*/
|
|
24
|
+
function resolveApiKey(auth) {
|
|
25
|
+
if (typeof auth === 'string') {
|
|
26
|
+
const key = auth.trim();
|
|
27
|
+
if (key.length > 0)
|
|
28
|
+
return key;
|
|
29
|
+
}
|
|
30
|
+
if (auth && typeof auth === 'object' && 'secret_text' in auth) {
|
|
31
|
+
const raw = auth.secret_text;
|
|
32
|
+
if (typeof raw === 'string') {
|
|
33
|
+
const key = raw.trim();
|
|
34
|
+
if (key.length > 0)
|
|
35
|
+
return key;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
throw new Error('Missing Live Map Leads API key. Reconnect your account in the connection settings.');
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build an authenticated client. `apiKey` is the account API key from Live Map Leads
|
|
42
|
+
* account settings; it is sent on the `x-api-key` header of every request. `baseUrl` is
|
|
43
|
+
* overridable only so tests can point at a mock server — published actions/triggers always
|
|
44
|
+
* use the production base.
|
|
45
|
+
*/
|
|
46
|
+
function makeClient(apiKey, baseUrl = constants_1.PROD_BASE_URL) {
|
|
47
|
+
return (0, openapi_fetch_1.default)({
|
|
48
|
+
baseUrl,
|
|
49
|
+
headers: { 'x-api-key': apiKey },
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The public Live Map Leads API base. Every action and trigger talks to this. */
|
|
2
|
+
export declare const PROD_BASE_URL = "https://livemapleads.com/api/v1";
|
|
3
|
+
/**
|
|
4
|
+
* Page size for listing completed searches. The API caps `limit` at 100; we ask for the
|
|
5
|
+
* max so the "New Search Completed" trigger picks up everything finished since the last
|
|
6
|
+
* check in a single call (one account rarely finishes more than 100 searches between
|
|
7
|
+
* checks).
|
|
8
|
+
*/
|
|
9
|
+
export declare const MAX_LIST_LIMIT = 100;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Shared constants for the Live Map Leads piece.
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.MAX_LIST_LIMIT = exports.PROD_BASE_URL = void 0;
|
|
5
|
+
/** The public Live Map Leads API base. Every action and trigger talks to this. */
|
|
6
|
+
exports.PROD_BASE_URL = 'https://livemapleads.com/api/v1';
|
|
7
|
+
/**
|
|
8
|
+
* Page size for listing completed searches. The API caps `limit` at 100; we ask for the
|
|
9
|
+
* max so the "New Search Completed" trigger picks up everything finished since the last
|
|
10
|
+
* check in a single call (one account rarely finishes more than 100 searches between
|
|
11
|
+
* checks).
|
|
12
|
+
*/
|
|
13
|
+
exports.MAX_LIST_LIMIT = 100;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type Polling } from '@activepieces/pieces-common';
|
|
2
|
+
import { type AppConnectionValueForAuthProperty, TriggerStrategy } from '@activepieces/pieces-framework';
|
|
3
|
+
import { liveMapLeadsAuth } from '../common/auth';
|
|
4
|
+
type AuthValue = AppConnectionValueForAuthProperty<typeof liveMapLeadsAuth>;
|
|
5
|
+
/**
|
|
6
|
+
* Fetch completed searches and map them to the helper's poll-item shape (exported for unit
|
|
7
|
+
* tests). `lastFetchEpochMS` is supplied by the helper: `0` on the manual "test trigger"
|
|
8
|
+
* run (return the most recent searches), or the timestamp of the newest search seen so far
|
|
9
|
+
* on a live poll. We translate it into the API's `since` filter so the server only returns
|
|
10
|
+
* newer searches; each item carries its `completedAt` as `epochMilliSeconds` for dedupe.
|
|
11
|
+
*/
|
|
12
|
+
export declare function pollCompletedSearches(auth: AuthValue, lastFetchEpochMS: number): Promise<{
|
|
13
|
+
epochMilliSeconds: number;
|
|
14
|
+
data: unknown;
|
|
15
|
+
}[]>;
|
|
16
|
+
/** The polling definition wired into the trigger hooks (exported for integration tests). */
|
|
17
|
+
export declare const polling: Polling<AuthValue, Record<string, never>>;
|
|
18
|
+
export declare const newSearchCompleted: import("@activepieces/pieces-framework").ITrigger<TriggerStrategy.WEBHOOK, import("@activepieces/pieces-framework").SecretTextProperty<true>, {}> | import("@activepieces/pieces-framework").ITrigger<TriggerStrategy.POLLING, import("@activepieces/pieces-framework").SecretTextProperty<true>, {}> | import("@activepieces/pieces-framework").ITrigger<TriggerStrategy.MANUAL, import("@activepieces/pieces-framework").SecretTextProperty<true>, {}> | import("@activepieces/pieces-framework").ITrigger<TriggerStrategy.APP_WEBHOOK, import("@activepieces/pieces-framework").SecretTextProperty<true>, {}>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.newSearchCompleted = exports.polling = void 0;
|
|
4
|
+
exports.pollCompletedSearches = pollCompletedSearches;
|
|
5
|
+
// Trigger: "New Search Completed" — fires once per search as it finishes.
|
|
6
|
+
//
|
|
7
|
+
// Polling-first (decoupled from any push/webhook delivery): Activepieces checks the
|
|
8
|
+
// read-only list endpoint (GET /api/v1/jobs?status=completed&since=…) on its schedule and
|
|
9
|
+
// emits one item per newly-completed search. Dedupe is TIME-BASED on `completedAt`: the
|
|
10
|
+
// helper stores the newest timestamp it has seen and only emits searches that finished
|
|
11
|
+
// strictly after it — which is exactly the `since` cursor the API exposes. Listing spends
|
|
12
|
+
// no credits.
|
|
13
|
+
//
|
|
14
|
+
// An instant (webhook) variant is a future, drop-in upgrade: only this file's strategy
|
|
15
|
+
// changes — the action and the API client stay as-is (see README § Polling vs. instant).
|
|
16
|
+
const pieces_common_1 = require("@activepieces/pieces-common");
|
|
17
|
+
const pieces_framework_1 = require("@activepieces/pieces-framework");
|
|
18
|
+
const api_1 = require("../common/api");
|
|
19
|
+
const auth_1 = require("../common/auth");
|
|
20
|
+
const client_1 = require("../common/client");
|
|
21
|
+
const constants_1 = require("../common/constants");
|
|
22
|
+
/**
|
|
23
|
+
* Fetch completed searches and map them to the helper's poll-item shape (exported for unit
|
|
24
|
+
* tests). `lastFetchEpochMS` is supplied by the helper: `0` on the manual "test trigger"
|
|
25
|
+
* run (return the most recent searches), or the timestamp of the newest search seen so far
|
|
26
|
+
* on a live poll. We translate it into the API's `since` filter so the server only returns
|
|
27
|
+
* newer searches; each item carries its `completedAt` as `epochMilliSeconds` for dedupe.
|
|
28
|
+
*/
|
|
29
|
+
async function pollCompletedSearches(auth, lastFetchEpochMS) {
|
|
30
|
+
const since = lastFetchEpochMS > 0 ? new Date(lastFetchEpochMS).toISOString() : undefined;
|
|
31
|
+
const page = await (0, api_1.listCompletedSearches)((0, client_1.resolveApiKey)(auth), { since, limit: constants_1.MAX_LIST_LIMIT });
|
|
32
|
+
// One page (the newest MAX_LIST_LIMIT, the API maximum) per poll. The API exposes no
|
|
33
|
+
// forward pagination — only `since` + `limit` (≤100) + `hasMore` — so if MORE than 100
|
|
34
|
+
// searches completed since the last poll, the page is truncated to the newest 100 and the
|
|
35
|
+
// older overflow is not emitted (the time cursor advances past them). This is the upstream
|
|
36
|
+
// API's documented, accepted polling residual ("rare for a single account") and is bounded
|
|
37
|
+
// to near-impossible here: an account runs one search at a time (each takes seconds to
|
|
38
|
+
// minutes), so >100 completing inside one poll interval cannot realistically occur. The
|
|
39
|
+
// instant (webhook) trigger upgrade removes the ceiling entirely. See README § Known limits.
|
|
40
|
+
return page.data.map((search) => ({
|
|
41
|
+
epochMilliSeconds: Date.parse(search.completedAt),
|
|
42
|
+
data: search,
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
/** The polling definition wired into the trigger hooks (exported for integration tests). */
|
|
46
|
+
exports.polling = {
|
|
47
|
+
strategy: pieces_common_1.DedupeStrategy.TIMEBASED,
|
|
48
|
+
items: ({ auth, lastFetchEpochMS }) => pollCompletedSearches(auth, lastFetchEpochMS),
|
|
49
|
+
};
|
|
50
|
+
exports.newSearchCompleted = (0, pieces_framework_1.createTrigger)({
|
|
51
|
+
auth: auth_1.liveMapLeadsAuth,
|
|
52
|
+
name: 'new_search_completed',
|
|
53
|
+
displayName: 'New Search Completed',
|
|
54
|
+
description: 'Fires when one of your searches finishes, with its record count, the credits charged, and ready-to-use download links (CSV, XLSX, JSON).',
|
|
55
|
+
type: pieces_framework_1.TriggerStrategy.POLLING,
|
|
56
|
+
props: {},
|
|
57
|
+
sampleData: {
|
|
58
|
+
jobId: '8f1d6c2a-3b4e-4f7a-9c1d-2e3f4a5b6c7d',
|
|
59
|
+
status: 'completed',
|
|
60
|
+
recordCount: 142,
|
|
61
|
+
creditsCharged: 142,
|
|
62
|
+
completedAt: '2026-06-12T14:22:05Z',
|
|
63
|
+
downloads: {
|
|
64
|
+
csv: 'https://livemapleads.com/api/exports/download?token=eyJhbGciOi...',
|
|
65
|
+
xlsx: 'https://livemapleads.com/api/exports/download?token=eyJhbGciOi...',
|
|
66
|
+
json: 'https://livemapleads.com/api/exports/download?token=eyJhbGciOi...',
|
|
67
|
+
},
|
|
68
|
+
summary: 'Your search finished with 142 records.',
|
|
69
|
+
},
|
|
70
|
+
async onEnable(context) {
|
|
71
|
+
await pieces_common_1.pollingHelper.onEnable(exports.polling, {
|
|
72
|
+
store: context.store,
|
|
73
|
+
auth: context.auth,
|
|
74
|
+
propsValue: context.propsValue,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
async onDisable(context) {
|
|
78
|
+
await pieces_common_1.pollingHelper.onDisable(exports.polling, {
|
|
79
|
+
store: context.store,
|
|
80
|
+
auth: context.auth,
|
|
81
|
+
propsValue: context.propsValue,
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
async run(context) {
|
|
85
|
+
return await pieces_common_1.pollingHelper.poll(exports.polling, {
|
|
86
|
+
store: context.store,
|
|
87
|
+
auth: context.auth,
|
|
88
|
+
propsValue: context.propsValue,
|
|
89
|
+
files: context.files,
|
|
90
|
+
});
|
|
91
|
+
},
|
|
92
|
+
async test(context) {
|
|
93
|
+
return await pieces_common_1.pollingHelper.test(exports.polling, {
|
|
94
|
+
store: context.store,
|
|
95
|
+
auth: context.auth,
|
|
96
|
+
propsValue: context.propsValue,
|
|
97
|
+
files: context.files,
|
|
98
|
+
});
|
|
99
|
+
},
|
|
100
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@livemapleads/piece-live-map-leads",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Activepieces piece for Live Map Leads — start local-business searches and trigger when a search completes.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"activepieces",
|
|
7
|
+
"activepieces-piece",
|
|
8
|
+
"live-map-leads",
|
|
9
|
+
"leads",
|
|
10
|
+
"local-business",
|
|
11
|
+
"automation"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://livemapleads.com",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/LiveMapLeads/connectors/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/LiveMapLeads/connectors.git",
|
|
20
|
+
"directory": "activepieces"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"type": "commonjs",
|
|
24
|
+
"main": "dist/index.js",
|
|
25
|
+
"types": "dist/index.d.ts",
|
|
26
|
+
"files": [
|
|
27
|
+
"dist"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"gen:types": "openapi-typescript openapi.yaml --default-non-nullable false -o src/generated/v1.ts",
|
|
31
|
+
"build": "tsc -p tsconfig.build.json",
|
|
32
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
33
|
+
"lint": "eslint .",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"check": "npm run typecheck && npm run lint && npm run test && npm run build"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@activepieces/pieces-common": "0.12.4",
|
|
39
|
+
"@activepieces/pieces-framework": "0.30.0",
|
|
40
|
+
"openapi-fetch": "0.17.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@activepieces/shared": "^0.92.0",
|
|
44
|
+
"@types/node": "^22.10.2",
|
|
45
|
+
"eslint": "^9.17.0",
|
|
46
|
+
"openapi-typescript": "7.13.0",
|
|
47
|
+
"typescript": "^5.7.2",
|
|
48
|
+
"typescript-eslint": "^8.18.1",
|
|
49
|
+
"vitest": "^2.1.8"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=18"
|
|
53
|
+
}
|
|
54
|
+
}
|