enrichment-scraper
Physical name: morpheus-sam-enrichment-scraper. Source:
lambdas/enrichment_scraper/.
The only EventBridge-only function — it has no SQS trigger. It fetches data from external sources and either publishes it to a queue or writes it to the database.
| Aspect | Value |
|---|---|
Handler |
|
Timeout |
300 s |
Memory |
256 MB |
Layers |
common, data-layer, bigquery |
Reserved concurrency |
none |
Registered events
detail-type |
Handler | Model | Status |
|---|---|---|---|
|
|
|
live (rule commented out) |
|
|
|
stub — raises |
|
|
|
stub — raises |
|
|
|
live, |
Only BigQueryContactSync has an enabled schedule. See
EventBridge schedules.
SellerDiscovery.Amazon
Full business rules: Seller discovery.
flowchart LR A["check configs\nenrichment.amazon.disabled"] -->|disabled| A1["log + return"] A --> B["read next_start_row"] B --> C["SmartScoutProvider.fetch_sellers()"] C --> D["save next_start_row"] D --> E["QueueService.publish()\nbatches of 10"]
The cursor is saved before publishing, so a publish failure does not cause the same page to be re-fetched forever.
Event payload — every field defaults, so the EventBridge Input can be minimal:
{
"detail-type": "SellerDiscovery.Amazon",
"source": "enrichment.scraper",
"detail": { "marketplace": "amazon", "provider": "smartscout", "max_pages": 5 }
}
SellerDiscoveryEvent is populate_by_name=True with detail_type aliased to
detail-type, and id / version / account / region optional — an Input
override does not include the standard EventBridge envelope fields.
Scraper.BigQueryContactSync
Incrementally mirrors HubSpot contacts from
bigqueryexport-183608.hubspot4.contacts into three local tables. This is the main
source of contacts, target_emails, and target_phone_numbers rows, and therefore
of the phone numbers phone-agent later classifies.
Watermark: configs.key = 'bq_contact_sync_watermark', default 2026-01-01T00:00:00Z.
Batch size: BIGQUERY_CONTACT_SYNC_BATCH_SIZE, default 1000.
Per row, best-effort — an exception is logged and the row counted as failed:
-
Map the BigQuery row to contact fields (
map_row_to_contact_data). -
Look up the existing contact by
hubspotContactId. -
If the lifecycle stage is changing on an existing contact, insert an
ActivityNOTE recording the transition before updating. -
Upsert the contact — find-or-create, updating only non-null incoming fields.
-
Ensure a
target_emailsrow exists forproperty_email, propagatingproperty_hs_email_optoutto all three opt-out flags. -
Ensure a
target_phone_numbersrow exists forphoneandmobilePhone.
Unique-constraint guard
contacts.email and contacts.phone are unique per subscription. When an update would
move a value onto a contact that already holds it, the field is silently dropped from
the update rather than raising:
for field, lookup in (("phone", _contact_repo.get_by_phone),
("email", _contact_repo.get_by_email)):
incoming = updates.get(field)
if incoming and getattr(existing, field) != incoming:
conflict = lookup(session, incoming)
if conflict is not None and conflict.id != existing.id:
updates.pop(field)
Watermark advance and the stall guard
The new watermark is the maximum updatedat across the processed rows, written only
when it advanced. If a full batch is returned but the maximum did not advance — a
cluster of identical timestamps larger than the batch size — the watermark is bumped by
one second and a warning is logged, so the sync cannot loop forever on the same rows.
Each row is processed in its own get_session() block, so one failure does not roll
back the others.
SmartScout provider
providers/smartscout/. Extends the generic BaseProvider, which owns retry and
rate-limit handling; the subclass supplies three hooks:
|
The full provider reference — |
| Hook | Implementation |
|---|---|
|
Browser fingerprint headers, |
|
|
|
Maps |
_post_with_retry() handles: timeouts (exponential backoff, then skip the page), 401
(one retry after invalidating the token), 429 (sleep on Retry-After or the parsed
X-Rate-Limit-Reset, then retry without incrementing the attempt count), 5xx (log a
truncated body and skip), and proactive throttling when X-Rate-Limit-Remaining
reaches 1 or 0.
fetch_sellers(filters, start_from_row) returns (records, next_start_row), where a
returned 0 means the result set was fully consumed. map_seller() wraps
_to_seller_record() and turns a ValidationError into a logged skip, so one bad row
never aborts the page.
Token caching
SmartScoutAuth caches its session token in the shared Secrets Manager secret
(SMARTSCOUT_TOKEN, SMARTSCOUT_TOKEN_EXPIRES_AT). This is why this function alone
holds secretsmanager:PutSecretValue, and why a manual put-secret-value that omits
those keys simply forces one extra authentication.
Configuration
| Setting | Default | Purpose |
|---|---|---|
|
(template) |
|
|
(template) |
Shared secret |
|
(template) |
Owner of the |
|
||
|
|
Marketplace header |
|
|
Annual, divided by 12 for the API |
|
|
Annual, divided by 12 |
|
|
Comma-separated; a |
|
|
Overridable per event |
|
|
|
|
|
|
|
|
A model_validator rejects ICP_REVENUE_MIN >= ICP_REVENUE_MAX at import, so a bad
value fails the cold start.
Secrets read (ScraperSecrets): DATABASE_URL, SMARTSCOUT_USERNAME,
SMARTSCOUT_PASSWORD (all validated non-empty), plus optional SMARTSCOUT_TOKEN,
SMARTSCOUT_TOKEN_EXPIRES_AT, and BIGQUERY_CREDENTIALS.
IAM
-
SQSSendMessagePolicyonEnrichmentPipelineQueue -
secretsmanager:GetSecretValueandPutSecretValueonAgentcoreSecret
No Bedrock access — this function runs no agents.
Tests
cd lambdas/enrichment_scraper && uv run pytest tests -v
tests/providers/smartscout/test_provider.py (456 lines) and test_auth.py (336
lines) cover mapping, pagination, retry, and token caching.
tests/integration/test_smartscout.py hits the real API and needs credentials.
lambdas/tests/unit/test_bigquery_contact_sync.py covers the contact sync.
Invoking locally
cd lambdas
sam local invoke EnrichmentScraperFunction \
--event events/event.json \
--env-vars local-env.json
local-env.json is not checked in — create it, keyed by the function’s logical id:
{
"EnrichmentScraperFunction": {
"SQS_QUEUE_URL": "https://sqs.us-east-1.amazonaws.com/625663093496/morpheus-sam-enrichment-pipeline.fifo",
"AGENTCORE_SECRET_ARN": "arn:aws:secretsmanager:us-east-1:625663093496:secret:agentcore-configs-XXXXX",
"LOG_LEVEL": "DEBUG"
}
}
The invocation uses your local AWS credentials, so it publishes to the real queue and reads
the real secret. Point SQS_QUEUE_URL at a scratch queue unless you intend to feed the
pipeline.
Two event payloads worth keeping around, both of which exercise routing rather than discovery:
{ "detail-type": "SellerDiscovery.RunCategory", "source": "enrichment.scraper",
"detail": { "category": "health-beauty", "marketplace": "amazon" } }
routes through the dispatcher and raises NotImplementedError — the expected stub behaviour.
An unknown detail-type returns successfully with a warning and no error, which is how the
function tolerates a bus that routes it events it does not own.
Monitoring
Log output is structured JSON. handler.py injects lambda_request_id and event_type at
startup and the _JsonFormatter in utils/logging.py carries them into every subsequent
record, so a single invocation can be reconstructed from any log line:
{
"timestamp": "2026-03-31T07:00:01.234Z",
"level": "INFO",
"lambda_request_id": "abc-123-def",
"event_type": "SellerDiscovery.Amazon",
"logger": "enrichment_scraper.providers.smartscout.provider",
"message": "Fetched 847 valid seller records total"
}
CloudWatch Logs Insights:
fields timestamp, level, message, logger
| filter level = "ERROR"
| sort timestamp desc
fields timestamp, message, lambda_request_id
| filter message like "Published"
| sort timestamp desc
fields timestamp, message
| filter message like "Rate limit"
| sort timestamp desc
| Signal | What it means |
|---|---|
|
Pagination is too aggressive for the timeout — lower |
Frequent |
The SmartScout window is too narrow for the current page count. The provider is handling it, but throughput is being spent on waiting. |
|
The API response shape changed. Check |
|
Something is routing unexpected events at this function. |
Operating notes
-
Discovery is off by default today — both
SellerDiscovery.*rules are commented out. Invoke manually; see current schedule state. -
The downstream SQS trigger on
enrichment-scoreris also commented out, so published messages accumulate on the queue. -
enrichment.amazon.disabledinconfigsis the kill switch and is checked before any network call. -
Empty
BIGQUERY_CREDENTIALSmakes the contact sync log a warning and return. -
To force a fresh SmartScout login, delete the two session keys from the shared secret — see SmartScout authentication.