scrapeloop-mcp 0.37.5 → 0.38.1
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/package.json +1 -1
- package/src/index.js +62 -4
- package/src/mutation-policies.js +9 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "scrapeloop-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.1",
|
|
4
4
|
"description": "Scrapeloop MCP server - set up and run Scrapeloop end-to-end (integrations, scraping, lead database, verification, cleaners, enrichment, strategies, Instantly campaigns) from any MCP client (Claude Desktop, Cursor, ChatGPT, …).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
package/src/index.js
CHANGED
|
@@ -638,7 +638,7 @@ const TOOLS = {
|
|
|
638
638
|
run: ({ plan_id, ...body }) => api('PATCH', `/plans/${enc(plan_id)}`, body),
|
|
639
639
|
},
|
|
640
640
|
check_campaign_setup: {
|
|
641
|
-
def: { description: 'One free read that walks a whole self-filling campaign and says whether its parts fit each other: the search plan, the Tables and rules on the way, the checks, the email answer (Table rule, plan goal and campaign must agree), the campaign, the credit limits and the approval. Pass plan_id for a search plan, or campaign_id to start from a campaign (works with no plan too). Returns fits (true only when nothing is fail and nothing is unknown), counts, and checks in the order a lead travels; each has step, key, status (ok, warn, fail or unknown), a plain title and detail, and often fixes: the exact tool and args that end it. Run it after every build step and before asking for a start. Apply a fix only as its note says, ask the person before any fix that changes who gets an email or what may be spent, and run this again afterwards. It changes nothing, starts nothing and approves nothing. deep=false skips the sender read.', inputSchema: obj({
|
|
641
|
+
def: { description: 'One free read that walks a whole self-filling campaign and says whether its parts fit each other: the search plan, the Tables and rules on the way, the checks, the email answer (Table rule, plan goal and campaign must agree), the campaign, the credit limits and the approval. Pass plan_id for a search plan, or campaign_id to start from a campaign (works with no plan too). Returns fits (true only when nothing is fail and nothing is unknown), counts, and checks in the order a lead travels; each has step, key, status (ok, warn, fail or unknown), a plain title and detail, and often fixes: the exact tool and args that end it. Run it after every build step and before asking for a start. Apply a fix only as its note says, ask the person before any fix that changes who gets an email or what may be spent, and run this again afterwards. It changes nothing, starts nothing and approves nothing. deep=false is the quick check: it skips the sender read, the Start check of the campaign and the count of leads that meet a rule and have not moved, so it can say fits while one of those three would not. Use the full check before a start.', inputSchema: obj({
|
|
642
642
|
plan_id: { ...S, format: 'uuid' }, campaign_id: { ...S, format: 'uuid' }, deep: B,
|
|
643
643
|
}) },
|
|
644
644
|
run: (a) => {
|
|
@@ -1863,7 +1863,7 @@ const TOOLS = {
|
|
|
1863
1863
|
import_leads: {
|
|
1864
1864
|
def: {
|
|
1865
1865
|
description:
|
|
1866
|
-
'Bring your own leads: atomically import externally-sourced records into a campaign-bindable Scrapeloop Table without scraping or lead credits. A generic record needs at least one of: email, linkedin_url, external_id (your own id for the record) or an absolute source_url (the page the record came from), so Scrapeloop can tell which lead it is. Email may be null. Records from a public source list cannot be imported here, only previewed. The rules for such records (sourcing_contract) switch on when you pass sourcing_contract:true, and also by themselves when one record carries source, source_state and niche together. A real import is then refused with 409 and nothing is imported: no lead, no Table. The 409 has an error sentence written for the user (it says what was seen, such as how many records carry all three, and what to change: show it as it is written), destination "agency_local_businesses" and dry_run_available:true. Use preview_import to see what such records would do. That preview has a warning that says the same. A reason of a skipped record there ends with: fixing this will not get it imported. Follow the warning, do not try to make such records pass. In that preview each record needs a controlled source, source_state and niche and a valid phone or email, a local business with only a phone gets an id made from its source and its phone, and source and niche tags are worked out. If these are the user\'s own leads, say what happened and ask before you change them: without source_state the same records import as ordinary leads. Matching is workspace-scoped and deterministic: normalized email first, then local-business phone, canonical LinkedIn, then external_id or source_url. The legacy dedupe_by="external_id" mode matches by external_id or source_url first. A later record with a real email merges into the earlier lead that only had a LinkedIn link, an external_id or a source_url, instead of duplicating it. No placeholder emails are created. verification_status and custom_fields are preserved, and custom_fields flow to Instantly as custom variables. Target list_id or pass list_name to create-or-get a Table. Batch up to 1000 records. One operation_id is reused across HTTP retries, so an uncertain response can be recovered without duplicating leads. You may pass a prior operation_id; reusing it with different inputs returns 409. Each record: {source?, source_state?, email?, linkedin_url?, source_url?, external_id?, first_name, last_name, business_name, phone, website, address, city, state_region, country, niche, rating, reviews_count, gbp_link?, facebook?, instagram?, verification_status ("verified"|"catchall_verified"|"unverified"|"risky"|"invalid"), strategy_tags:[], custom_fields:{}}. Returns {operation_id, list_id, inserted, updated, deduped, invalid, invalid_reasons}. Each entry of invalid_reasons has a reason written for the user: show it as it is written.',
|
|
1866
|
+
'Bring your own leads: atomically import externally-sourced records into a campaign-bindable Scrapeloop Table without scraping or lead credits. A generic record needs at least one of: email, linkedin_url, external_id (your own id for the record) or an absolute source_url (the page the record came from), so Scrapeloop can tell which lead it is. Email may be null. Records from a public source list cannot be imported here, only previewed. The rules for such records (sourcing_contract) switch on when you pass sourcing_contract:true, and also by themselves when one record carries source, source_state and niche together. A real import is then refused with 409 and nothing is imported: no lead, no Table. The 409 has an error sentence written for the user (it says what was seen, such as how many records carry all three, and what to change: show it as it is written), destination "agency_local_businesses" and dry_run_available:true. Use preview_import to see what such records would do. That preview has a warning that says the same. A reason of a skipped record there ends with: fixing this will not get it imported. Follow the warning, do not try to make such records pass. In that preview each record needs a controlled source, source_state and niche and a valid phone or email, a local business with only a phone gets an id made from its source and its phone, and source and niche tags are worked out. If these are the user\'s own leads, say what happened and ask before you change them: without source_state the same records import as ordinary leads. Matching is workspace-scoped and deterministic: normalized email first, then local-business phone, canonical LinkedIn, then external_id or source_url. The legacy dedupe_by="external_id" mode matches by external_id or source_url first. A later record with a real email merges into the earlier lead that only had a LinkedIn link, an external_id or a source_url, instead of duplicating it. No placeholder emails are created. verification_status and custom_fields are preserved, and custom_fields flow to Instantly as custom variables. Target list_id or pass list_name to create-or-get a Table. Batch up to 1000 records. One operation_id is reused across HTTP retries, so an uncertain response can be recovered without duplicating leads. You may pass a prior operation_id; reusing it with different inputs returns 409. Each record: {source?, source_state?, email?, linkedin_url?, source_url?, external_id?, first_name, last_name, business_name, phone, website, address, city, state_region, country, niche, rating, reviews_count, gbp_link?, facebook?, instagram?, verification_status ("verified"|"catchall_verified"|"unverified"|"risky"|"invalid"), strategy_tags:[], custom_fields:{}}. Returns {operation_id, list_id, inserted, updated, deduped, invalid, invalid_reasons}. Each entry of invalid_reasons has a reason written for the user: show it as it is written. A lead keeps the marks no_resale, workspace_only and legal_basis gdpr_li (extra fields that say the lead needs more care) once it has them: a later import cannot turn them off. When the answer has marks_kept_note, show it as it is written.',
|
|
1867
1867
|
inputSchema: obj(
|
|
1868
1868
|
{
|
|
1869
1869
|
list_id: S,
|
|
@@ -2061,7 +2061,7 @@ const TOOLS = {
|
|
|
2061
2061
|
import_leads_csv: {
|
|
2062
2062
|
def: {
|
|
2063
2063
|
description:
|
|
2064
|
-
"Bring your own leads from CSV/TSV into a campaign-bindable Table. Each row needs at least one of: email, LinkedIn URL, external ID or an absolute source URL, so Scrapeloop can tell which lead it is. A row with no email needs no placeholder email. The parser auto-detects the delimiter, maps common headers (including linkedin_url and source_url), preserves unmapped columns as custom_fields, and normalizes verifier verdicts. A CSV from a public source list cannot be imported here, only previewed. A file with source, source_state and niche columns switches on the rules for such lists (sourcing_contract) by itself, and so does sourcing_contract:true. A real import of it is refused with 409 and nothing is imported: no lead, no Table. The 409 has an error sentence written for the user (it says what was seen, such as the names of the three columns, and what to change: show it as it is written), destination \"agency_local_businesses\" and dry_run_available:true. With dry_run:true the same file is previewed (the answer then has a warning that says the real import will be refused, and what to change): every loaded row must also have a valid phone or email, source IDs are namespaced, a local business with only a phone gets an id made from its source and its phone, and source/niche tags are worked out. If this is the user's own file, say what happened and ask before you change it: without the source_state column the same rows import as ordinary leads. With a name of its own, such as Lead state, that column is kept as an extra field. Matching is workspace-scoped: email first, then local-business phone, LinkedIn, then external ID or source URL. Set dry_run:true first to preview writes and column mapping. Mutating CSV imports are atomic and reuse one operation_id across HTTP retries. You may pass a prior operation_id; reusing it with changed CSV inputs returns 409. No scraping, lead credits, or re-verification. Target list_id or pass list_name. Up to 1000 rows per call. Skipped rows come back in invalid_reasons. Each entry has a reason written for the user: show it as it is written.",
|
|
2064
|
+
"Bring your own leads from CSV/TSV into a campaign-bindable Table. Each row needs at least one of: email, LinkedIn URL, external ID or an absolute source URL, so Scrapeloop can tell which lead it is. A row with no email needs no placeholder email. The parser auto-detects the delimiter, maps common headers (including linkedin_url and source_url), preserves unmapped columns as custom_fields, and normalizes verifier verdicts. A CSV from a public source list cannot be imported here, only previewed. A file with source, source_state and niche columns switches on the rules for such lists (sourcing_contract) by itself, and so does sourcing_contract:true. A real import of it is refused with 409 and nothing is imported: no lead, no Table. The 409 has an error sentence written for the user (it says what was seen, such as the names of the three columns, and what to change: show it as it is written), destination \"agency_local_businesses\" and dry_run_available:true. With dry_run:true the same file is previewed (the answer then has a warning that says the real import will be refused, and what to change): every loaded row must also have a valid phone or email, source IDs are namespaced, a local business with only a phone gets an id made from its source and its phone, and source/niche tags are worked out. If this is the user's own file, say what happened and ask before you change it: without the source_state column the same rows import as ordinary leads. With a name of its own, such as Lead state, that column is kept as an extra field. Matching is workspace-scoped: email first, then local-business phone, LinkedIn, then external ID or source URL. Set dry_run:true first to preview writes and column mapping. Mutating CSV imports are atomic and reuse one operation_id across HTTP retries. You may pass a prior operation_id; reusing it with changed CSV inputs returns 409. No scraping, lead credits, or re-verification. Target list_id or pass list_name. Up to 1000 rows per call. Skipped rows come back in invalid_reasons. Each entry has a reason written for the user: show it as it is written. A lead keeps the marks no_resale, workspace_only and legal_basis gdpr_li (extra fields that say the lead needs more care) once it has them: a later import cannot turn them off. When the answer has marks_kept_note, show it as it is written.",
|
|
2065
2065
|
inputSchema: obj(
|
|
2066
2066
|
{
|
|
2067
2067
|
csv: S,
|
|
@@ -2117,7 +2117,7 @@ const TOOLS = {
|
|
|
2117
2117
|
preview_import: {
|
|
2118
2118
|
def: {
|
|
2119
2119
|
description:
|
|
2120
|
-
'Dry-run for import_leads: report exactly what an import WOULD do ({would_insert, would_update, deduped, invalid, invalid_reasons, list_exists}) WITHOUT writing anything (no leads, no list, no tags created). Same inputs as import_leads. Call this first for a bring-your-own batch, show the user the plan (e.g. "adds 340 new, updates 12, skips 3 bad rows"), then confirm before import_leads. One kind of batch can only be previewed: records under the rules for public source lists (sourcing_contract:true, or a record with source, source_state and niche together). The preview works for them, and a real import is refused with 409. The preview then has a warning that says so and what to change: show it as it is written, and follow it. Fixing the skipped records of such a batch does not get them imported. Each entry of invalid_reasons has a reason written for the user: show it as it is written.',
|
|
2120
|
+
'Dry-run for import_leads: report exactly what an import WOULD do ({would_insert, would_update, deduped, invalid, invalid_reasons, list_exists}) WITHOUT writing anything (no leads, no list, no tags created). Same inputs as import_leads. Call this first for a bring-your-own batch, show the user the plan (e.g. "adds 340 new, updates 12, skips 3 bad rows"), then confirm before import_leads. One kind of batch can only be previewed: records under the rules for public source lists (sourcing_contract:true, or a record with source, source_state and niche together). The preview works for them, and a real import is refused with 409. The preview then has a warning that says so and what to change: show it as it is written, and follow it. Fixing the skipped records of such a batch does not get them imported. Each entry of invalid_reasons has a reason written for the user: show it as it is written. A lead keeps the marks no_resale, workspace_only and legal_basis gdpr_li (extra fields that say the lead needs more care) once it has them: a later import cannot turn them off. When the answer has marks_kept_note, show it as it is written.',
|
|
2121
2121
|
inputSchema: obj(
|
|
2122
2122
|
{
|
|
2123
2123
|
list_id: S,
|
|
@@ -2419,6 +2419,64 @@ const TOOLS = {
|
|
|
2419
2419
|
});
|
|
2420
2420
|
},
|
|
2421
2421
|
},
|
|
2422
|
+
run_table_flow_rule_for_waiting_rows: {
|
|
2423
|
+
def: {
|
|
2424
|
+
description:
|
|
2425
|
+
'Run one saved Table rule for the rows that meet it right now and have not moved. A rule only runs when something happens to a lead, so leads can meet a rule and stay where they are: the rule was changed after they arrived, or a check finished and no rule looked again. check_campaign_setup names this tool under "Leads meet a rule and have not moved". It only works for a rule that sends leads to another Table, that is switched on and not paused, on a normal Table of 5,000 rows or fewer. A rule that asks for a saved review count only on a Table of 100 rows or fewer. With dry_run true (the default) it only reads. It returns count (the leads that meet the rule now, have not gone on and have nothing waiting to handle them), would_queue, sends_to (where the rule sends them, with the paid checks that Table runs on new leads by itself and the credits a lead), plan (the search plan these Tables belong to), and a title and lines ready to show the person. To run it, call it again with dry_run false and expected_count set to that count. If the count changed in between, nothing is put in line and the new count comes back: show a fresh dry run. A real run moves nothing itself. It puts the leads in line for this one rule, at most 1,000 a call (left_waiting says how many are left, so call again when those are handled). The rule then looks at each lead again when its turn comes, about 2 seconds a lead, so a lead that no longer meets the rule stays where it is. The loop guard and the daily limit of the rule apply as always, and a rule with a wait keeps its wait. Always show the dry run lines first and run only after the person says yes: a lead that moves on can start a paid check in the next Table. On a Table of a running search plan the plan starts no new search until these leads are handled, and this tool never pauses or starts a plan. Under a hard monthly limit the campaign only takes leads that the searches of that plan found, so never promise that a moved lead goes into the campaign. One operation_id is reused across retries, so a lost reply never puts a lead in line twice.',
|
|
2426
|
+
inputSchema: obj(
|
|
2427
|
+
{
|
|
2428
|
+
list_id: { ...S, format: 'uuid', description: 'The Table the rule is on.' },
|
|
2429
|
+
rule_id: { ...S, format: 'uuid' },
|
|
2430
|
+
dry_run: { ...B, description: 'True (the default) only reads. Only a real false runs the rule.' },
|
|
2431
|
+
expected_count: {
|
|
2432
|
+
type: 'integer',
|
|
2433
|
+
minimum: 0,
|
|
2434
|
+
description: 'The count of the dry run the person was shown. Needed with dry_run false.',
|
|
2435
|
+
},
|
|
2436
|
+
operation_id: { ...S, format: 'uuid', description: 'Optional retry key from a prior ambiguous call.' },
|
|
2437
|
+
},
|
|
2438
|
+
['list_id', 'rule_id'],
|
|
2439
|
+
),
|
|
2440
|
+
},
|
|
2441
|
+
run: (a) => {
|
|
2442
|
+
if (!a.list_id || !a.rule_id) {
|
|
2443
|
+
return { ok: false, status: 400, error: { detail: 'list_id and rule_id are required.' } };
|
|
2444
|
+
}
|
|
2445
|
+
const dryRun = a.dry_run !== false;
|
|
2446
|
+
if (!dryRun && !Number.isInteger(a.expected_count)) {
|
|
2447
|
+
return {
|
|
2448
|
+
ok: false,
|
|
2449
|
+
status: 400,
|
|
2450
|
+
error: { detail: 'Run a dry run first, then pass its count as expected_count with dry_run false.' },
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
const requestBody = dryRun
|
|
2454
|
+
? { dry_run: true }
|
|
2455
|
+
: { dry_run: false, expected_count: a.expected_count, operation_id: a.operation_id || randomUUID() };
|
|
2456
|
+
// The read behind it is as heavy as one count of the setup check. One
|
|
2457
|
+
// more try is enough: three would only load the database three times.
|
|
2458
|
+
return api('POST', `/lists/${enc(a.list_id)}/flow-rules/${enc(a.rule_id)}/run-waiting`, requestBody, {
|
|
2459
|
+
maxRetries: 1,
|
|
2460
|
+
...(dryRun
|
|
2461
|
+
? {}
|
|
2462
|
+
: {
|
|
2463
|
+
reconciliation: {
|
|
2464
|
+
inspect_with: {
|
|
2465
|
+
tool: 'run_table_flow_rule_for_waiting_rows',
|
|
2466
|
+
arguments: { list_id: a.list_id, rule_id: a.rule_id, dry_run: true },
|
|
2467
|
+
},
|
|
2468
|
+
operation_id: requestBody.operation_id,
|
|
2469
|
+
retry_with: {
|
|
2470
|
+
tool: 'run_table_flow_rule_for_waiting_rows',
|
|
2471
|
+
arguments: { ...a, operation_id: requestBody.operation_id },
|
|
2472
|
+
},
|
|
2473
|
+
retry_guidance:
|
|
2474
|
+
`Retry run_table_flow_rule_for_waiting_rows with the same operation_id ${requestBody.operation_id}. Never use a new one for this run. A dry run shows what still waits: a lead that is already in line is no longer counted.`,
|
|
2475
|
+
},
|
|
2476
|
+
}),
|
|
2477
|
+
});
|
|
2478
|
+
},
|
|
2479
|
+
},
|
|
2422
2480
|
get_flow_rule_stats: {
|
|
2423
2481
|
def: {
|
|
2424
2482
|
description:
|
package/src/mutation-policies.js
CHANGED
|
@@ -206,6 +206,15 @@ export const MUTATION_POLICIES = Object.freeze({
|
|
|
206
206
|
['get_flow_rule_stats', 'list_table_flow_rules', 'list_campaign_flow_rules'],
|
|
207
207
|
['list_id', 'campaign_id', 'rule_id', 'lead_id', 'execute'],
|
|
208
208
|
),
|
|
209
|
+
// A dry run reads. A real run carries one operation_id: sent again it
|
|
210
|
+
// answers with the first result and puts no lead in line a second time
|
|
211
|
+
// (tests/test_flow_rule_run_waiting_db.py), so a repeat after a lost reply
|
|
212
|
+
// is safe.
|
|
213
|
+
run_table_flow_rule_for_waiting_rows: idempotent(
|
|
214
|
+
'POST',
|
|
215
|
+
['list_table_flow_rules', 'get_flow_rule_stats'],
|
|
216
|
+
['list_id', 'rule_id', 'dry_run', 'expected_count', 'operation_id'],
|
|
217
|
+
),
|
|
209
218
|
preview_send_to_table: read(),
|
|
210
219
|
send_to_table: idempotent('POST', ['get_list_rows'], ['list_id', 'destination_list_id', 'new_table_name']),
|
|
211
220
|
preview_table_write_back: read(),
|