Add new carrier startup campaign targeting
This commit is contained in:
parent
2232570c9f
commit
4f4edb5f00
3 changed files with 520 additions and 1 deletions
135
docs/trucking-marketing-send-plan.md
Normal file
135
docs/trucking-marketing-send-plan.md
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# Trucking Marketing Send Plan
|
||||||
|
|
||||||
|
Date: 2026-06-02
|
||||||
|
|
||||||
|
This plan ranks trucking campaign sends by fulfillment friction, data quality, and ability to deliver without asking customers for portal passwords.
|
||||||
|
|
||||||
|
## Prioritized sends
|
||||||
|
|
||||||
|
### 1. NY HUT setup
|
||||||
|
|
||||||
|
**Why first:** New York supports a clean tax-professional authorization workflow through E-ZRep/TR-2000. This is one of the lowest-friction state filings.
|
||||||
|
|
||||||
|
**Target data:** carriers based in NY or operating in NY, especially heavy vehicles likely over 18,000 lbs.
|
||||||
|
|
||||||
|
**Message:**
|
||||||
|
|
||||||
|
> Operating in New York? You may need HUT credentials before operating taxable vehicles on NY public highways. Performance West can prepare and file with your signed authorization.
|
||||||
|
|
||||||
|
**Offer:** NY HUT registration, certificate/decal assistance, quarterly filing support.
|
||||||
|
|
||||||
|
**Required fulfillment:** signed NY authorization/TR-2000, vehicle list, weights, USDOT, tax/business details, government fees.
|
||||||
|
|
||||||
|
### 2. Connecticut Highway Use Fee
|
||||||
|
|
||||||
|
**Why second:** CT requires electronic filing through myconneCT and supports practitioner/third-party/bulk-filer concepts. CSV vehicle import makes this operationally scalable.
|
||||||
|
|
||||||
|
**Target data:** CT base/operating carriers, especially vehicles over 26,000 lbs.
|
||||||
|
|
||||||
|
**Message:**
|
||||||
|
|
||||||
|
> Connecticut HUF is electronic and recurring. We can help register, import vehicles, and file returns with your authorization.
|
||||||
|
|
||||||
|
**Offer:** CT HUF setup and monthly filing support.
|
||||||
|
|
||||||
|
**Required fulfillment:** signed authorization, myconneCT delegate/practitioner access or customer approval, vehicle/mileage records, government tax payment.
|
||||||
|
|
||||||
|
### 3. DOT Drug & Alcohol Program
|
||||||
|
|
||||||
|
**Why third:** Lowest filing friction. No state tax portal. Strong recurring/compliance service.
|
||||||
|
|
||||||
|
**Target data:** carriers with drivers and likely CDL operations. FMCSA fields: `driver_total > 0`, `nbr_power_unit > 0`, for-hire/private property indicators.
|
||||||
|
|
||||||
|
**Message:**
|
||||||
|
|
||||||
|
> If you operate CDL vehicles, you need a DOT-compliant drug and alcohol testing program.
|
||||||
|
|
||||||
|
**Offer:** annual D&A consortium enrollment, driver file review, clearinghouse support.
|
||||||
|
|
||||||
|
**Required fulfillment:** driver/company intake, signed service authorization, provider/consortium enrollment.
|
||||||
|
|
||||||
|
### 4. New Carrier Startup Bundle
|
||||||
|
|
||||||
|
**Why fourth:** Broad appeal and strong cross-sell. Use data to avoid sending to carriers that already purchased or completed startup services.
|
||||||
|
|
||||||
|
**Target data:** recently added DOT carriers with email, small fleet, no evidence in our orders/records of completed startup items.
|
||||||
|
|
||||||
|
**Missing-item signals to infer from data:**
|
||||||
|
|
||||||
|
- no Performance West order for new carrier bundle;
|
||||||
|
- no Performance West order for BOC-3;
|
||||||
|
- no Performance West order for UCR;
|
||||||
|
- no Performance West order for DOT Drug & Alcohol;
|
||||||
|
- no Performance West order for IRP/IFTA;
|
||||||
|
- no entity/LLC order where applicable;
|
||||||
|
- recent FMCSA `add_date` or `created_at`;
|
||||||
|
- small fleet: 1-3 power units or 1-5 drivers;
|
||||||
|
- active/usable email.
|
||||||
|
|
||||||
|
**Message:**
|
||||||
|
|
||||||
|
> New DOT number? Most new carriers miss at least one required filing: BOC-3, UCR, D&A, IRP/IFTA, insurance, or state permits. We can review your startup checklist and file missing items.
|
||||||
|
|
||||||
|
**Offer:** New Carrier Starter Bundle, BOC-3, UCR, D&A, IRP/IFTA advisory, LLC/entity setup.
|
||||||
|
|
||||||
|
**Required fulfillment:** startup intake, signed authorization, government fee pass-throughs, state-specific follow-up where needed.
|
||||||
|
|
||||||
|
### 5. California MCP
|
||||||
|
|
||||||
|
**Why fifth:** Good market, but medium friction due to CA portal/forms and insurance coordination.
|
||||||
|
|
||||||
|
**Target data:** CA base/operating carriers.
|
||||||
|
|
||||||
|
**Message:**
|
||||||
|
|
||||||
|
> California carriers may need MCP, CA number, insurance filings, and emissions compliance. We can prepare and file with your authorization.
|
||||||
|
|
||||||
|
**Offer:** CA MCP assistance, CA number/CHP guidance, insurance coordination, CARB/TRUCRS review.
|
||||||
|
|
||||||
|
**Required fulfillment:** signed authorization, vehicle/fleet details, insurance agent, portal or form workflow.
|
||||||
|
|
||||||
|
## Cautious / later sends
|
||||||
|
|
||||||
|
These are valuable but should use conservative language until portal delegation is fully verified:
|
||||||
|
|
||||||
|
- Oregon Weight-Mile
|
||||||
|
- Kentucky KYU
|
||||||
|
- New Mexico Weight-Distance
|
||||||
|
- Texas TxDMV / intrastate authority
|
||||||
|
- CA CARB/TRUCRS recovery
|
||||||
|
- IRP/IFTA across all base states
|
||||||
|
|
||||||
|
Use wording:
|
||||||
|
|
||||||
|
- “We prepare and file with your authorization.”
|
||||||
|
- “Some state portal approval may be required.”
|
||||||
|
- “Government fees and taxes are billed separately.”
|
||||||
|
- “Insurance filings may require your insurance agent.”
|
||||||
|
|
||||||
|
## Implementation notes for New Carrier Startup targeting
|
||||||
|
|
||||||
|
Create a Listmonk list named:
|
||||||
|
|
||||||
|
`FMCSA Trucking - New Carrier Missing Startup Items`
|
||||||
|
|
||||||
|
Populate with carriers matching:
|
||||||
|
|
||||||
|
1. usable email;
|
||||||
|
2. not already sent the new-carrier campaign;
|
||||||
|
3. recent-ish FMCSA add date or recent DB creation;
|
||||||
|
4. small/new operator profile;
|
||||||
|
5. no internal order evidence for new carrier startup items.
|
||||||
|
|
||||||
|
Subscriber attributes should include:
|
||||||
|
|
||||||
|
- `company`
|
||||||
|
- `dot_number`
|
||||||
|
- `state`
|
||||||
|
- `city`
|
||||||
|
- `trucks`
|
||||||
|
- `drivers`
|
||||||
|
- `add_date`
|
||||||
|
- `missing_items_html`
|
||||||
|
- `startup_score`
|
||||||
|
|
||||||
|
Do not mark globally `listmonk_sent_at` until the real campaign is sent. Use campaign-type-specific tracking where possible to avoid blocking unrelated campaigns.
|
||||||
383
scripts/populate_new_carrier_startup_campaign.py
Normal file
383
scripts/populate_new_carrier_startup_campaign.py
Normal file
|
|
@ -0,0 +1,383 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Populate a Listmonk audience for new carriers likely missing startup items.
|
||||||
|
|
||||||
|
Targets FMCSA carriers that look new/small and have no Performance West paid
|
||||||
|
order evidence for core startup services such as BOC-3, UCR, D&A, IRP/IFTA,
|
||||||
|
DOT full compliance, or new carrier bundle-like services.
|
||||||
|
|
||||||
|
This script is intentionally read-only against the application database. It does
|
||||||
|
not mark fmcsa_carriers.listmonk_sent_at because adding a carrier to an audience
|
||||||
|
is not the same as sending the campaign.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
DATABASE_URL=postgres://... python3 scripts/populate_new_carrier_startup_campaign.py --dry-run
|
||||||
|
DATABASE_URL=postgres://... python3 scripts/populate_new_carrier_startup_campaign.py --limit 500
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import html
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg2
|
||||||
|
import psycopg2.extras
|
||||||
|
|
||||||
|
LOG = logging.getLogger("populate_new_carrier_startup_campaign")
|
||||||
|
|
||||||
|
LISTMONK_URL = os.getenv("LISTMONK_URL", "https://lists.performancewest.net").rstrip("/")
|
||||||
|
LISTMONK_USER = os.getenv("LISTMONK_USER", "api")
|
||||||
|
LISTMONK_PASS = os.getenv("LISTMONK_PASS", "6X1rKPea61N4rZ1S65Hx5zvqzbCj30F6nvEe9oVGH_Y")
|
||||||
|
LIST_NAME = os.getenv("NEW_CARRIER_LIST_NAME", "FMCSA Trucking - New Carrier Missing Startup Items")
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", "")
|
||||||
|
|
||||||
|
BLOCKED_EMAIL_DOMAINS = {"aol.com", "yahoo.com", "ymail.com", "rocketmail.com"}
|
||||||
|
STARTUP_SERVICE_SLUGS = {
|
||||||
|
"boc3-filing",
|
||||||
|
"ucr-registration",
|
||||||
|
"dot-registration",
|
||||||
|
"mc-authority",
|
||||||
|
"dot-drug-alcohol",
|
||||||
|
"dot-audit-prep",
|
||||||
|
"dot-full-compliance",
|
||||||
|
"irp-registration",
|
||||||
|
"ifta-application",
|
||||||
|
"ifta-quarterly",
|
||||||
|
"state-trucking-bundle",
|
||||||
|
"ein-application",
|
||||||
|
"entity-upgrade-bundle",
|
||||||
|
"registered-agent",
|
||||||
|
"virtual-mailbox",
|
||||||
|
}
|
||||||
|
|
||||||
|
AUTH = base64.b64encode(f"{LISTMONK_USER}:{LISTMONK_PASS}".encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CarrierCandidate:
|
||||||
|
dot_number: str
|
||||||
|
email: str
|
||||||
|
legal_name: str
|
||||||
|
phone: str | None
|
||||||
|
city: str | None
|
||||||
|
state: str | None
|
||||||
|
add_date: str | None
|
||||||
|
trucks: int | None
|
||||||
|
drivers: int | None
|
||||||
|
authorized_for_hire: bool | None
|
||||||
|
missing_items: list[str]
|
||||||
|
startup_score: int
|
||||||
|
|
||||||
|
|
||||||
|
def lm_api(path: str, data: dict | None = None, method: str | None = None) -> dict:
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Basic {AUTH}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
req = urllib.request.Request(f"{LISTMONK_URL}/api{path}", headers=headers)
|
||||||
|
if data is not None:
|
||||||
|
req.data = json.dumps(data).encode()
|
||||||
|
if method:
|
||||||
|
req.method = method
|
||||||
|
try:
|
||||||
|
raw = urllib.request.urlopen(req, timeout=45).read()
|
||||||
|
return json.loads(raw or b"{}")
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode(errors="replace")[:1000]
|
||||||
|
raise RuntimeError(f"Listmonk {path} HTTP {exc.code}: {body}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_list(name: str) -> int:
|
||||||
|
resp = lm_api("/lists?per_page=all")
|
||||||
|
for row in resp.get("data", {}).get("results", []):
|
||||||
|
if row.get("name") == name:
|
||||||
|
return int(row["id"])
|
||||||
|
resp = lm_api(
|
||||||
|
"/lists",
|
||||||
|
{"name": name, "type": "private", "optin": "single", "tags": ["trucking", "new-carrier", "auto"]},
|
||||||
|
"POST",
|
||||||
|
)
|
||||||
|
return int(resp["data"]["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def add_subscriber(list_id: int, candidate: CarrierCandidate) -> bool:
|
||||||
|
issues_html = "<ul style='margin:0;padding:0 0 0 16px'>" + "".join(
|
||||||
|
f"<li>{html.escape(item)}</li>" for item in candidate.missing_items
|
||||||
|
) + "</ul>"
|
||||||
|
attribs = {
|
||||||
|
"company": candidate.legal_name,
|
||||||
|
"dot_number": candidate.dot_number,
|
||||||
|
"phone": candidate.phone or "",
|
||||||
|
"city": candidate.city or "",
|
||||||
|
"state": candidate.state or "",
|
||||||
|
"add_date": candidate.add_date or "",
|
||||||
|
"trucks": candidate.trucks or 0,
|
||||||
|
"drivers": candidate.drivers or 0,
|
||||||
|
"for_hire": bool(candidate.authorized_for_hire),
|
||||||
|
"missing_items": candidate.missing_items,
|
||||||
|
"missing_items_html": issues_html,
|
||||||
|
"startup_score": candidate.startup_score,
|
||||||
|
}
|
||||||
|
payload = {
|
||||||
|
"email": candidate.email.lower().strip(),
|
||||||
|
"name": candidate.legal_name or candidate.email,
|
||||||
|
"status": "enabled",
|
||||||
|
"lists": [list_id],
|
||||||
|
"preconfirm_subscriptions": True,
|
||||||
|
"attribs": attribs,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
lm_api("/subscribers", payload, "POST")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
msg = str(exc)
|
||||||
|
if "HTTP 409" not in msg and "already exists" not in msg.lower() and "conflict" not in msg.lower():
|
||||||
|
LOG.warning("subscriber add failed for %s: %s", candidate.email, exc)
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
query = urllib.parse.quote(f"subscribers.email='{candidate.email.lower().strip()}'")
|
||||||
|
results = lm_api(f"/subscribers?query={query}").get("data", {}).get("results", [])
|
||||||
|
if not results:
|
||||||
|
return False
|
||||||
|
sub_id = int(results[0]["id"])
|
||||||
|
lm_api(
|
||||||
|
"/subscribers/lists",
|
||||||
|
{"ids": [sub_id], "action": "add", "target_list_ids": [list_id], "status": "confirmed"},
|
||||||
|
"PUT",
|
||||||
|
)
|
||||||
|
# Preserve fresh campaign attributes where the API supports subscriber update.
|
||||||
|
try:
|
||||||
|
lm_api(f"/subscribers/{sub_id}", {"attribs": attribs}, "PUT")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return True
|
||||||
|
except Exception as inner:
|
||||||
|
LOG.warning("subscriber attach failed for %s: %s", candidate.email, inner)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def table_exists(conn, table: str) -> bool:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
(table,),
|
||||||
|
)
|
||||||
|
return bool(cur.fetchone()[0])
|
||||||
|
|
||||||
|
|
||||||
|
def table_columns(conn, table: str) -> set[str]:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT column_name
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_schema = 'public' AND table_name = %s
|
||||||
|
""",
|
||||||
|
(table,),
|
||||||
|
)
|
||||||
|
return {r[0] for r in cur.fetchall()}
|
||||||
|
|
||||||
|
|
||||||
|
def usable_email_filter(cols: set[str]) -> str:
|
||||||
|
base = "email_address IS NOT NULL AND email_address <> '' AND position('@' in email_address) > 1"
|
||||||
|
domain_filter = "lower(split_part(email_address, '@', 2)) <> ALL(%(blocked_domains)s)"
|
||||||
|
if "email_verified" in cols and "email_verify_result" in cols:
|
||||||
|
return f"{base} AND {domain_filter} AND (email_verified IS TRUE OR email_verify_result IN ('smtp_valid','catch_all_domain','catch_all_detected'))"
|
||||||
|
if "email_verify_result" in cols:
|
||||||
|
return f"{base} AND {domain_filter} AND (email_verify_result IS NULL OR email_verify_result NOT IN ('invalid','smtp_invalid','bad_mailbox','disposable'))"
|
||||||
|
return f"{base} AND {domain_filter}"
|
||||||
|
|
||||||
|
|
||||||
|
def startup_date_filter(cols: set[str], days: int) -> str:
|
||||||
|
# FMCSA add_date is text in the seed migration. It is commonly YYYY-MM-DD or
|
||||||
|
# MM/DD/YYYY depending on import source, so handle both and fall back to row
|
||||||
|
# creation date if parsing fails.
|
||||||
|
parts = []
|
||||||
|
if "add_date" in cols:
|
||||||
|
parts.append(
|
||||||
|
"""
|
||||||
|
CASE
|
||||||
|
WHEN add_date ~ '^\\d{4}-\\d{2}-\\d{2}' THEN add_date::date
|
||||||
|
WHEN add_date ~ '^\\d{1,2}/\\d{1,2}/\\d{4}$' THEN to_date(add_date, 'MM/DD/YYYY')
|
||||||
|
ELSE NULL
|
||||||
|
END >= CURRENT_DATE - (%(recent_days)s::int * INTERVAL '1 day')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
if "created_at" in cols:
|
||||||
|
parts.append("created_at >= NOW() - (%(recent_days)s::int * INTERVAL '1 day')")
|
||||||
|
if not parts:
|
||||||
|
return "TRUE"
|
||||||
|
return "(" + " OR ".join(parts) + ")"
|
||||||
|
|
||||||
|
|
||||||
|
def has_order_exclusion_clause(order_cols: set[str]) -> str:
|
||||||
|
if not order_cols:
|
||||||
|
return "TRUE"
|
||||||
|
# Match by customer email and, when present in intake JSON, by DOT number.
|
||||||
|
# The JSON text fallback catches varying intake field names without assuming
|
||||||
|
# every historical order used the same key.
|
||||||
|
return """
|
||||||
|
NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM compliance_orders co
|
||||||
|
WHERE co.payment_status = 'paid'
|
||||||
|
AND co.service_slug = ANY(%(startup_slugs)s)
|
||||||
|
AND (
|
||||||
|
lower(co.customer_email) = lower(c.email_address)
|
||||||
|
OR co.intake_data::text ILIKE '%%' || c.dot_number || '%%'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def infer_missing_items(row: dict[str, Any]) -> tuple[list[str], int]:
|
||||||
|
missing = ["BOC-3 process agent filing", "UCR annual registration", "DOT drug & alcohol program"]
|
||||||
|
trucks = row.get("nbr_power_unit") or 0
|
||||||
|
drivers = row.get("driver_total") or 0
|
||||||
|
for_hire = bool(row.get("authorized_for_hire"))
|
||||||
|
if trucks > 0:
|
||||||
|
missing.append("IRP/IFTA review if operating interstate")
|
||||||
|
if for_hire:
|
||||||
|
missing.append("MC operating authority / insurance filing review")
|
||||||
|
if drivers > 0:
|
||||||
|
missing.append("New entrant safety audit preparation")
|
||||||
|
score = 50
|
||||||
|
if trucks <= 3:
|
||||||
|
score += 15
|
||||||
|
if drivers <= 5:
|
||||||
|
score += 10
|
||||||
|
if for_hire:
|
||||||
|
score += 10
|
||||||
|
if row.get("add_date"):
|
||||||
|
score += 10
|
||||||
|
return missing, min(score, 100)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_candidates(conn, limit: int, recent_days: int) -> list[CarrierCandidate]:
|
||||||
|
if not table_exists(conn, "fmcsa_carriers"):
|
||||||
|
raise RuntimeError("required table fmcsa_carriers does not exist; run migrations/import FMCSA data first")
|
||||||
|
fmcsa_cols = table_columns(conn, "fmcsa_carriers")
|
||||||
|
order_cols = table_columns(conn, "compliance_orders") if table_exists(conn, "compliance_orders") else set()
|
||||||
|
filters = [
|
||||||
|
usable_email_filter(fmcsa_cols),
|
||||||
|
startup_date_filter(fmcsa_cols, recent_days),
|
||||||
|
"COALESCE(nbr_power_unit, 0) BETWEEN 0 AND 3",
|
||||||
|
"COALESCE(driver_total, 0) BETWEEN 0 AND 5",
|
||||||
|
has_order_exclusion_clause(order_cols),
|
||||||
|
]
|
||||||
|
if "listmonk_campaign_type" in fmcsa_cols:
|
||||||
|
filters.append("COALESCE(listmonk_campaign_type, '') <> 'new_carrier_startup'")
|
||||||
|
|
||||||
|
sql = f"""
|
||||||
|
SELECT dot_number, email_address, legal_name, telephone, phy_city, phy_state,
|
||||||
|
add_date, nbr_power_unit, driver_total, authorized_for_hire
|
||||||
|
FROM fmcsa_carriers c
|
||||||
|
WHERE {' AND '.join(filters)}
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN add_date ~ '^\\d{{4}}-\\d{{2}}-\\d{{2}}' THEN add_date::date
|
||||||
|
WHEN add_date ~ '^\\d{{1,2}}/\\d{{1,2}}/\\d{{4}}$' THEN to_date(add_date, 'MM/DD/YYYY')
|
||||||
|
ELSE NULL
|
||||||
|
END DESC NULLS LAST,
|
||||||
|
created_at DESC NULLS LAST
|
||||||
|
LIMIT %(limit)s
|
||||||
|
"""
|
||||||
|
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
sql,
|
||||||
|
{
|
||||||
|
"blocked_domains": list(BLOCKED_EMAIL_DOMAINS),
|
||||||
|
"recent_days": recent_days,
|
||||||
|
"startup_slugs": list(STARTUP_SERVICE_SLUGS),
|
||||||
|
"limit": limit,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for row in rows:
|
||||||
|
missing, score = infer_missing_items(row)
|
||||||
|
candidates.append(
|
||||||
|
CarrierCandidate(
|
||||||
|
dot_number=str(row["dot_number"]),
|
||||||
|
email=row["email_address"],
|
||||||
|
legal_name=row["legal_name"] or row["email_address"],
|
||||||
|
phone=row.get("telephone"),
|
||||||
|
city=row.get("phy_city"),
|
||||||
|
state=row.get("phy_state"),
|
||||||
|
add_date=str(row.get("add_date") or ""),
|
||||||
|
trucks=row.get("nbr_power_unit"),
|
||||||
|
drivers=row.get("driver_total"),
|
||||||
|
authorized_for_hire=row.get("authorized_for_hire"),
|
||||||
|
missing_items=missing,
|
||||||
|
startup_score=score,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Populate New Carrier Startup Listmonk audience")
|
||||||
|
parser.add_argument("--dry-run", action="store_true", help="Only count/show sample candidates")
|
||||||
|
parser.add_argument("--limit", type=int, default=500, help="Max candidates to import")
|
||||||
|
parser.add_argument("--recent-days", type=int, default=180, help="FMCSA add/create recency window")
|
||||||
|
parser.add_argument("--list-name", default=LIST_NAME)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||||
|
if not DATABASE_URL:
|
||||||
|
raise SystemExit("DATABASE_URL is required")
|
||||||
|
|
||||||
|
conn = psycopg2.connect(DATABASE_URL)
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
candidates = fetch_candidates(conn, args.limit, args.recent_days)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise SystemExit(str(exc)) from exc
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
LOG.info("Found %d new-carrier startup candidates", len(candidates))
|
||||||
|
for candidate in candidates[:10]:
|
||||||
|
LOG.info(
|
||||||
|
"sample DOT#%s %s <%s> %s trucks=%s drivers=%s missing=%s",
|
||||||
|
candidate.dot_number,
|
||||||
|
candidate.legal_name,
|
||||||
|
candidate.email,
|
||||||
|
candidate.state,
|
||||||
|
candidate.trucks,
|
||||||
|
candidate.drivers,
|
||||||
|
"; ".join(candidate.missing_items),
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
return
|
||||||
|
|
||||||
|
list_id = get_or_create_list(args.list_name)
|
||||||
|
LOG.info("Using Listmonk list %s (ID %d)", args.list_name, list_id)
|
||||||
|
imported = 0
|
||||||
|
for candidate in candidates:
|
||||||
|
if add_subscriber(list_id, candidate):
|
||||||
|
imported += 1
|
||||||
|
if imported and imported % 100 == 0:
|
||||||
|
LOG.info("Imported/attached %d/%d", imported, len(candidates))
|
||||||
|
LOG.info("Done. Imported/attached %d subscribers to %s", imported, args.list_name)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -223,10 +223,11 @@ CAMPAIGNS = [
|
||||||
"cta_url": "https://performancewest.net/services/trucking/hazmat/", "cta_text": "Review Hazmat Compliance",
|
"cta_url": "https://performancewest.net/services/trucking/hazmat/", "cta_text": "Review Hazmat Compliance",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "new-carrier-startup", "list": "FMCSA Trucking - New Carrier Prospects",
|
"slug": "new-carrier-startup", "list": "FMCSA Trucking - New Carrier Missing Startup Items",
|
||||||
"name": "New Carrier Startup Checklist Campaign", "headline": "New Carrier Startup Checklist",
|
"name": "New Carrier Startup Checklist Campaign", "headline": "New Carrier Startup Checklist",
|
||||||
"subject": "New DOT number? Startup checklist for {{ .Subscriber.Attribs.company }}",
|
"subject": "New DOT number? Startup checklist for {{ .Subscriber.Attribs.company }}",
|
||||||
"requirements": [
|
"requirements": [
|
||||||
|
"<strong>Our startup checklist flagged these likely missing items for your carrier:</strong><br>{{ Safe .Subscriber.Attribs.missing_items_html }}",
|
||||||
"<strong>Startup filing sequence</strong> — LLC, EIN, USDOT, operating authority, BOC-3, UCR, D&A, insurance, IRP and IFTA can all stack up quickly.",
|
"<strong>Startup filing sequence</strong> — LLC, EIN, USDOT, operating authority, BOC-3, UCR, D&A, insurance, IRP and IFTA can all stack up quickly.",
|
||||||
"<strong>Missed renewals</strong> — new carriers often miss UCR, MCS-150 timing, state permits, or quarterly fuel tax obligations.",
|
"<strong>Missed renewals</strong> — new carriers often miss UCR, MCS-150 timing, state permits, or quarterly fuel tax obligations.",
|
||||||
"<strong>Cash-flow friendly setup</strong> — bundling startup compliance prevents piecemeal filings and duplicate work.",
|
"<strong>Cash-flow friendly setup</strong> — bundling startup compliance prevents piecemeal filings and duplicate work.",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue