← back to delfinparis__hc-agent-renewal-project

Function bodies 37 total

All specs Real LLM only Function bodies
getSpreadsheet function · javascript · L22-L26 (5 LOC)
apps_script_drip.js
function getSpreadsheet() {
  return CONFIG.SPREADSHEET_ID
    ? SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID)
    : SpreadsheetApp.getActiveSpreadsheet();
}
getColumnMap function · javascript · L29-L37 (9 LOC)
apps_script_drip.js
function getColumnMap(sheet) {
  const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  const map = {};
  headers.forEach((h, i) => {
    const key = h.toString().trim().toLowerCase();
    if (key) map[key] = i;
  });
  return map;
}
sendNextEmail function · javascript · L40-L149 (110 LOC)
apps_script_drip.js
function sendNextEmail() {
  const ss = getSpreadsheet();
  const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  const props = PropertiesService.getScriptProperties();

  // Check daily send count
  const today = new Date().toDateString();
  const dailyKey = "sent_" + today;
  const sentToday = parseInt(props.getProperty(dailyKey) || "0");
  if (sentToday >= CONFIG.DAILY_LIMIT) {
    Logger.log("Daily limit reached: " + sentToday);
    return;
  }

  // Get template
  const template = getEmailTemplate();
  if (!template) {
    Logger.log("ERROR: Could not find template file: " + CONFIG.TEMPLATE_FILE);
    return;
  }

  // Get column positions from headers
  const colMap = getColumnMap(sheet);
  const firstNameCol = colMap["first name"];
  const emailCol = colMap["email"];
  const statusCol = colMap["status"];
  const timestampCol = colMap["timestamp"];

  if (firstNameCol === undefined || emailCol === undefined || statusCol === undefined || timestampCol === undefined) {
    Logger.lo
getEmailTemplate function · javascript · L152-L175 (24 LOC)
apps_script_drip.js
function getEmailTemplate() {
  const files = DriveApp.getFilesByName(CONFIG.TEMPLATE_FILE);
  if (!files.hasNext()) return null;

  const content = files.next().getBlob().getDataAsString();

  const parts = content.split("---");
  let subject = "Hello";
  let senderName = CONFIG.SENDER_NAME;
  let body = content;

  if (parts.length >= 2) {
    const header = parts[0].trim();
    body = parts.slice(1).join("---").trim();

    const subjectMatch = header.match(/subject:\s*(.+)/i);
    if (subjectMatch) subject = subjectMatch[1].trim();

    const nameMatch = header.match(/from_name:\s*(.+)/i);
    if (nameMatch) senderName = nameMatch[1].trim();
  }

  return { subject, senderName, body };
}
markdownToHtml function · javascript · L178-L277 (100 LOC)
apps_script_drip.js
function markdownToHtml(md) {
  const lines = md.split("\n");
  let html = "";
  let inTable = false;
  let inList = false;
  let inOrderedList = false;
  let tableHeaderDone = false;

  for (let i = 0; i < lines.length; i++) {
    let line = lines[i];

    if (line.startsWith("######")) {
      html += "<h6>" + inlineFormat(line.slice(6).trim()) + "</h6>";
      continue;
    } else if (line.startsWith("#####")) {
      html += "<h5>" + inlineFormat(line.slice(5).trim()) + "</h5>";
      continue;
    } else if (line.startsWith("####")) {
      html += "<h4>" + inlineFormat(line.slice(4).trim()) + "</h4>";
      continue;
    } else if (line.startsWith("###")) {
      html += "<h3>" + inlineFormat(line.slice(3).trim()) + "</h3>";
      continue;
    } else if (line.startsWith("##")) {
      html += "<h2>" + inlineFormat(line.slice(2).trim()) + "</h2>";
      continue;
    } else if (line.startsWith("#")) {
      html += "<h1>" + inlineFormat(line.slice(1).trim()) + "</h1>";
      cont
inlineFormat function · javascript · L280-L285 (6 LOC)
apps_script_drip.js
function inlineFormat(text) {
  return text
    .replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
    .replace(/\*(.+?)\*/g, "<i>$1</i>")
    .replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
}
deleteExistingTriggers function · javascript · L288-L294 (7 LOC)
apps_script_drip.js
function deleteExistingTriggers(functionName) {
  ScriptApp.getProjectTriggers().forEach(trigger => {
    if (trigger.getHandlerFunction() === functionName) {
      ScriptApp.deleteTrigger(trigger);
    }
  });
}
Repobility · open methodology · https://repobility.com/research/
sendTextViaWebhook function · javascript · L297-L337 (41 LOC)
apps_script_drip.js
function sendTextViaWebhook(firstName, email, phone) {
  if (!phone || phone.toString().trim() === "") {
    Logger.log("No phone number for " + email + ", skipping text.");
    return;
  }

  if (!CONFIG.MAKE_WEBHOOK_URL) {
    Logger.log("No webhook URL configured, skipping text for " + email);
    return;
  }

  // Strip to digits only, then normalize to 10-digit US number
  var digits = phone.toString().replace(/\D/g, "");
  if (digits.length === 11 && digits.charAt(0) === "1") {
    digits = digits.substring(1); // remove leading country code
  }
  if (digits.length !== 10) {
    Logger.log("Invalid phone number for " + email + ": " + phone + " (" + digits.length + " digits), skipping text.");
    return;
  }

  const payload = {
    firstName: firstName,
    email: email,
    phone: digits,
    message: firstName + ", pls remember to complete CE & renewal by 4/30 - info here -> https://www.kalehuddle.com/post/illinois-real-estate-license-renewal-2026-complete-guide",
    sentAt: 
startCampaign function · javascript · L341-L345 (5 LOC)
apps_script_drip.js
function startCampaign() {
  deleteExistingTriggers("sendNextEmail");
  Logger.log("Campaign started.");
  sendNextEmail();
}
pauseCampaign function · javascript · L347-L350 (4 LOC)
apps_script_drip.js
function pauseCampaign() {
  deleteExistingTriggers("sendNextEmail");
  Logger.log("Campaign paused. Run startCampaign() to resume.");
}
checkStatus function · javascript · L352-L377 (26 LOC)
apps_script_drip.js
function checkStatus() {
  const ss = getSpreadsheet();
  const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  const data = sheet.getDataRange().getValues();
  const colMap = getColumnMap(sheet);
  const props = PropertiesService.getScriptProperties();

  const statusCol = colMap["status"];
  const today = new Date().toDateString();
  const sentToday = parseInt(props.getProperty("sent_" + today) || "0");

  let sent = 0, unsent = 0, errors = 0;
  for (let i = 1; i < data.length; i++) {
    const s = data[i][statusCol];
    if (s === "SENT") sent++;
    else if (s === "ERROR" || s === "INVALID") errors++;
    else unsent++;
  }

  Logger.log("=== CAMPAIGN STATUS ===");
  Logger.log("Sent: " + sent + " | Unsent: " + unsent + " | Errors: " + errors);
  Logger.log("Sent today: " + sentToday + " / " + CONFIG.DAILY_LIMIT);
  Logger.log("Send interval: every " + CONFIG.SEND_INTERVAL_DAYS + " days per person");
  Logger.log("Active triggers: " + ScriptApp.getProjectTriggers().length);
  Logge
fullReset function · javascript · L379-L399 (21 LOC)
apps_script_drip.js
function fullReset() {
  const props = PropertiesService.getScriptProperties();
  props.deleteAllProperties();
  deleteExistingTriggers("sendNextEmail");

  const ss = getSpreadsheet();
  const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  const colMap = getColumnMap(sheet);
  const statusCol = colMap["status"];
  const timestampCol = colMap["timestamp"];
  const totalRows = sheet.getLastRow();

  if (totalRows > 1 && statusCol !== undefined) {
    sheet.getRange(2, statusCol + 1, totalRows - 1, 1).clearContent();
  }
  if (totalRows > 1 && timestampCol !== undefined) {
    sheet.getRange(2, timestampCol + 1, totalRows - 1, 1).clearContent();
  }

  Logger.log("Full reset complete.");
}
onOpen function · javascript · L402-L413 (12 LOC)
apps_script_drip.js
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu("Email Campaign")
    .addItem("Start Campaign", "startCampaign")
    .addItem("Pause Campaign", "pauseCampaign")
    .addItem("Check Status", "checkStatus")
    .addItem("Full Reset", "fullReset")
    .addSeparator()
    .addItem("Sync Active Roster (Monday.com)", "syncActiveRoster")
    .addItem("Email Renewal Report", "emailRenewalReport")
    .addToUi();
}
testWebhookOnly function · javascript · L415-L418 (4 LOC)
apps_script_drip.js
function testWebhookOnly() {
  sendTextViaWebhook("Test", "[email protected]", "1234567890");
  Logger.log("Test webhook fired.");
}
mondayQuery function · javascript · L21-L36 (16 LOC)
apps_script_sync.js
function mondayQuery(query, variables) {
  const token = PropertiesService.getScriptProperties().getProperty("MONDAY_API_TOKEN");
  if (!token) throw new Error("Set MONDAY_API_TOKEN in Script Properties (Project Settings > Script Properties)");

  const resp = UrlFetchApp.fetch("https://api.monday.com/v2", {
    method: "post",
    contentType: "application/json",
    headers: { Authorization: token, "API-Version": "2024-10" },
    payload: JSON.stringify({ query: query, variables: variables || {} }),
    muteHttpExceptions: true,
  });

  const data = JSON.parse(resp.getContentText());
  if (data.errors) throw new Error("Monday.com API error: " + JSON.stringify(data.errors));
  return data.data;
}
Powered by Repobility — scan your code at https://repobility.com
getActiveAgents function · javascript · L38-L85 (48 LOC)
apps_script_sync.js
function getActiveAgents() {
  const agents = [];
  let cursor = null;
  let isFirstPage = true;

  while (true) {
    let data;

    if (isFirstPage) {
      data = mondayQuery(
        `query ($boardId: [ID!]!) {
          boards(ids: $boardId) {
            items_page(limit: 500) {
              cursor
              items { name, group { id }, column_values { id text } }
            }
          }
        }`,
        { boardId: [String(MONDAY_BOARD_ID)] }
      );
      const page = data.boards[0].items_page;
      cursor = page.cursor;
      page.items.forEach(item => {
        if (item.group.id === MONDAY_ACTIVE_GROUP_ID) agents.push(parseMonday(item));
      });
      isFirstPage = false;
    } else {
      data = mondayQuery(
        `query ($cursor: String!) {
          next_items_page(limit: 500, cursor: $cursor) {
            cursor
            items { name, group { id }, column_values { id text } }
          }
        }`,
        { cursor: cursor }
      );
      const page =
parseMonday function · javascript · L87-L125 (39 LOC)
apps_script_sync.js
function parseMonday(item) {
  const cols = {};
  item.column_values.forEach(c => { cols[c.id.toLowerCase()] = c.text; });

  // Log column IDs on first item to help discover HC's column mapping
  if (!parseMonday._logged) {
    var colIds = item.column_values.map(function(c) { return c.id + "=" + (c.text || "").substring(0, 30); });
    Logger.log("Column IDs: " + colIds.join(", "));
    parseMonday._logged = true;
  }

  // First name / Last name: try known column IDs
  let first = cols["text95"] || cols["first_name"] || cols["first name"] || cols["firstname"] || "";
  let last = cols["text_19"] || cols["last_name"] || cols["last name"] || cols["lastname"] || "";

  if (!first && !last) {
    const parts = item.name.trim().split(/\s+/);
    first = parts[0] || "";
    last = parts.slice(1).join(" ") || "";
  }

  // License number: license_number3 for HC, license_number for KHA
  var licenseNum = cols["license_number3"] || cols["license_number"] || "";

  // Email: try common column 
dfprLookup function · javascript · L129-L158 (30 LOC)
apps_script_sync.js
function dfprLookup(licenseNumbers) {
  // Query DFPR by license numbers in batches of 100
  var results = {};
  var BATCH = 100;

  for (var i = 0; i < licenseNumbers.length; i += BATCH) {
    var batch = licenseNumbers.slice(i, i + BATCH);
    var inList = batch.map(function(n) { return "'" + n + "'"; }).join(", ");
    var where = "license_number IN (" + inList + ")";
    var url = "https://data.illinois.gov/resource/pzzh-kp68.json"
      + "?$where=" + encodeURIComponent(where)
      + "&$select=" + encodeURIComponent("license_number,expiration_date,license_status")
      + "&$limit=50000";

    var resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
    if (resp.getResponseCode() === 200) {
      var records = JSON.parse(resp.getContentText());
      records.forEach(function(r) {
        // Keep the latest expiration per license number
        var num = r.license_number;
        var exp = r.expiration_date || "";
        if (!results[num] || exp > (results[num].expiration
syncActiveRoster function · javascript · L162-L321 (160 LOC)
apps_script_sync.js
function syncActiveRoster() {
  Logger.log("[" + MONDAY_ENTITY + "] Active roster sync starting...");

  var activeAgents = getActiveAgents();
  Logger.log("Active agents on Monday.com: " + activeAgents.length);

  if (activeAgents.length === 0) {
    Logger.log("WARNING: No active agents found. Skipping sync to prevent accidental wipe.");
    return;
  }

  // Build lookup of active agents by email
  var activeByEmail = {};
  activeAgents.forEach(function(agent) {
    if (agent.email) {
      activeByEmail[agent.email.toLowerCase()] = agent;
    }
  });
  Logger.log("Active agents with email: " + Object.keys(activeByEmail).length);

  // Open spreadsheet
  var ss = CONFIG.SPREADSHEET_ID
    ? SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID)
    : SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  var headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
  var colMap = {};
  headers.forEach(function(h, i) { colMap[h.toString().
emailRenewalReport function · javascript · L325-L362 (38 LOC)
apps_script_sync.js
function emailRenewalReport() {
  Logger.log("[" + MONDAY_ENTITY + "] Fetching renewal report from GitHub...");

  var url = "https://raw.githubusercontent.com/" + GITHUB_REPO_OWNER + "/" + GITHUB_REPO_NAME + "/main/latest_report.txt";

  try {
    var resp = UrlFetchApp.fetch(url, { muteHttpExceptions: true });

    if (resp.getResponseCode() !== 200) {
      Logger.log("No report found at " + url + " (HTTP " + resp.getResponseCode() + "). Skipping email.");
      return;
    }

    var reportText = resp.getContentText();
    if (!reportText || reportText.trim().length === 0) {
      Logger.log("Report is empty. Skipping email.");
      return;
    }

    var today = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd");
    var subject = "[" + MONDAY_ENTITY + "] License Renewal Report — " + today;

    var htmlBody = "<h2>" + subject + "</h2>"
      + "<pre style='font-family: Consolas, monospace; font-size: 13px; line-height: 1.4;'>"
      + reportText.replace(
setupSyncTrigger function · javascript · L367-L391 (25 LOC)
apps_script_sync.js
function setupSyncTrigger() {
  // Remove any existing sync triggers
  ScriptApp.getProjectTriggers().forEach(trigger => {
    var fn = trigger.getHandlerFunction();
    if (fn === "syncActiveRoster" || fn === "syncTerminatedAgents" || fn === "emailRenewalReport") {
      ScriptApp.deleteTrigger(trigger);
    }
  });

  // Daily roster sync at 5am (runs before the email campaign)
  ScriptApp.newTrigger("syncActiveRoster")
    .timeBased()
    .everyDays(1)
    .atHour(5)
    .create();

  // Daily report email at 10am (after GitHub Actions runs at ~2am CT)
  ScriptApp.newTrigger("emailRenewalReport")
    .timeBased()
    .everyDays(1)
    .atHour(10)
    .create();

  Logger.log("Daily triggers created: syncActiveRoster (5am), emailRenewalReport (10am).");
}
fetch_dfpr_records function · python · L23-L75 (53 LOC)
fetch_dfpr_data.py
def fetch_dfpr_records():
    """
    Query DFPR data filtered by business DBA and real estate license type.
    Returns a DataFrame with columns matching the existing script's expectations.
    """
    where_clause = (
        f"businessdba LIKE '%{DFPR_BUSINESS_DBA}%' "
        f"AND description LIKE '%Real Estate%'"
    )

    all_rows = []
    offset = 0

    while True:
        params = {
            "$where": where_clause,
            "$select": "first_name,middle,last_name,license_number,license_status,"
                       "expiration_date,business_name,businessdba,description",
            "$limit": PAGE_SIZE,
            "$offset": offset,
            "$order": "last_name,first_name",
        }

        print(f"  Querying data.illinois.gov (offset={offset})...")
        resp = requests.get(SODA_BASE_URL, params=params, timeout=60)
        resp.raise_for_status()

        chunk = pd.read_csv(io.StringIO(resp.text))

        if chunk.empty:
            break

        all_row
_get_headers function · python · L22-L31 (10 LOC)
fetch_monday_agents.py
def _get_headers():
    token = os.environ.get("MONDAY_API_TOKEN")
    if not token:
        print("ERROR: Set MONDAY_API_TOKEN environment variable")
        sys.exit(1)
    return {
        "Authorization": token,
        "Content-Type": "application/json",
        "API-Version": "2024-10",
    }
Repobility · severity-and-effort ranking · https://repobility.com
_run_query function · python · L34-L47 (14 LOC)
fetch_monday_agents.py
def _run_query(query, variables=None):
    payload = {"query": query}
    if variables:
        payload["variables"] = variables

    resp = requests.post(MONDAY_API_URL, json=payload, headers=_get_headers(), timeout=120)
    resp.raise_for_status()
    data = resp.json()

    if "errors" in data:
        print(f"Monday.com API errors: {json.dumps(data['errors'], indent=2)}")
        sys.exit(1)

    return data["data"]
fetch_board_items function · python · L50-L100 (51 LOC)
fetch_monday_agents.py
def fetch_board_items(board_id):
    """Fetch all items from a board using cursor-based pagination."""
    first_query = """
    query ($boardId: [ID!]!, $limit: Int!) {
      boards(ids: $boardId) {
        name
        items_page(limit: $limit) {
          cursor
          items {
            id
            name
            group { id title }
            column_values { id text }
          }
        }
      }
    }
    """

    data = _run_query(first_query, {"boardId": [str(board_id)], "limit": PAGE_LIMIT})
    board = data["boards"][0]
    print(f"Board: {board['name']}")

    page = board["items_page"]
    all_items = list(page["items"])
    cursor = page["cursor"]

    next_query = """
    query ($limit: Int!, $cursor: String!) {
      next_items_page(limit: $limit, cursor: $cursor) {
        cursor
        items {
          id
          name
          group { id title }
          column_values { id text }
        }
      }
    }
    """

    page_num = 1
    while cursor is not 
extract_agent_info function · python · L103-L141 (39 LOC)
fetch_monday_agents.py
def extract_agent_info(item):
    """Extract agent first/last name and license number from a Monday.com item."""
    col_lookup = {}
    for col in item["column_values"]:
        col_lookup[col["id"].lower()] = col["text"]

    first_name = None
    last_name = None

    for key in ["text95", "first name", "first_name", "firstname", "first"]:
        if key in col_lookup and col_lookup[key]:
            first_name = col_lookup[key].strip()
            break

    for key in ["text_19", "last name", "last_name", "lastname", "last"]:
        if key in col_lookup and col_lookup[key]:
            last_name = col_lookup[key].strip()
            break

    # Fallback: parse item name as "First Last"
    if not first_name and not last_name:
        parts = item["name"].strip().split(None, 1)
        first_name = parts[0] if len(parts) >= 1 else ""
        last_name = parts[1] if len(parts) >= 2 else ""

    # License number: "license_number3" for HC, "license_number" for KHA
    license_num = 
fetch_active_agents function · python · L144-L154 (11 LOC)
fetch_monday_agents.py
def fetch_active_agents():
    """Fetch active (non-terminated) agents and return as a DataFrame."""
    items = fetch_board_items(MONDAY_BOARD_ID)

    active = [i for i in items if i["group"]["id"] != MONDAY_TERMINATED_GROUP_ID]
    terminated = len(items) - len(active)
    print(f"  Active: {len(active)}, Terminated (filtered out): {terminated}")

    agents = [extract_agent_info(item) for item in active]
    df = pd.DataFrame(agents)
    return df
normalize_name function · python · L42-L51 (10 LOC)
update_license_renewals.py
def normalize_name(name):
    """Normalize a name for comparison."""
    name = name.lower().strip()
    # Remove common suffixes
    for suffix in [' jr', ' sr', ' ii', ' iii', ' iv', ' md']:
        name = name.rstrip('.').replace(suffix, '')
    # Normalize punctuation and whitespace
    name = name.replace("'", "").replace("-", " ").replace(".", "").replace(",", " ")
    name = ' '.join(name.split())
    return name
build_agents_csv_name_variants function · python · L54-L87 (34 LOC)
update_license_renewals.py
def build_agents_csv_name_variants(row):
    """Generate multiple name variants from the agents CSV row for matching."""
    first = str(row.get('First Name', '')).strip()
    lasts = []
    for col in ['Last Name', 'Last Name 2', 'Last Name 3']:
        val = str(row.get(col, '')).strip()
        if val and val.lower() != 'nan' and val != '':
            lasts.append(val)

    variants = set()

    # Full name: "First Last Last2 Last3"
    full = ' '.join([first] + lasts)
    variants.add(normalize_name(full))

    if lasts:
        # "Last First" format
        variants.add(normalize_name(f"{' '.join(lasts)} {first}"))
        variants.add(normalize_name(f"{lasts[0]} {first}"))
        variants.add(normalize_name(f"{first} {lasts[0]}"))

    # With hyphenated last names
    if len(lasts) >= 2:
        variants.add(normalize_name(f"{first} {lasts[0]}-{lasts[1]}"))
        variants.add(normalize_name(f"{first} {lasts[0]} {lasts[1]}"))
        variants.add(normalize_name(f"{lasts[0]}-{l
match_dfpr_to_agents function · python · L90-L159 (70 LOC)
update_license_renewals.py
def match_dfpr_to_agents(dfpr_df, agents_df, threshold=DEFAULT_THRESHOLD):
    """Match DFPR records to agents CSV using fuzzy name matching."""
    from rapidfuzz import fuzz, process

    # Build agents CSV lookup: {normalized_variant: csv_index}
    agents_name_map = {}
    for idx, row in agents_df.iterrows():
        variants = build_agents_csv_name_variants(row)
        for variant in variants:
            if variant:
                agents_name_map[variant] = idx

    agents_variant_list = list(agents_name_map.keys())

    matches = []
    unmatched_dfpr = []
    matched_agents_indices = set()

    for _, dfpr_row in dfpr_df.iterrows():
        dfpr_name_raw = str(dfpr_row.get('Supverisee', '')).strip()
        if not dfpr_name_raw or dfpr_name_raw.lower() == 'nan':
            continue

        dfpr_name = normalize_name(dfpr_name_raw)
        if not dfpr_name:
            continue

        exp_text = str(dfpr_row.get('Expiration Date', '')).strip()

        # Pass 1: Exact mat
parse_expiration_date function · python · L166-L178 (13 LOC)
update_license_renewals.py
def parse_expiration_date(raw_text):
    """Parse expiration date from DFPR CSV (M/D/YY or M/D/YYYY format)."""
    text = str(raw_text).strip()
    if not text or text.lower() == 'nan':
        return None

    for fmt in ['%m/%d/%y', '%m/%d/%Y', '%m-%d-%y', '%m-%d-%Y']:
        try:
            return datetime.strptime(text, fmt).date()
        except ValueError:
            continue

    return None
Repobility · code-quality intelligence platform · https://repobility.com
determine_action function · python · L185-L195 (11 LOC)
update_license_renewals.py
def determine_action(match):
    """Determine whether to keep or remove an agent."""
    exp_date = parse_expiration_date(match['exp_date'])

    if exp_date is None:
        return 'keep', f'unparseable date: "{match["exp_date"]}"'

    if exp_date <= RENEWAL_CUTOFF:
        return 'keep', f'expires {exp_date.strftime("%m/%d/%Y")} (needs renewal)'
    else:
        return 'remove', f'expires {exp_date.strftime("%m/%d/%Y")} (already renewed)'
load_csv function · python · L202-L212 (11 LOC)
update_license_renewals.py
def load_csv(csv_path):
    """Load a CSV with encoding detection."""
    import pandas as pd
    for encoding in ['utf-8', 'latin-1', 'cp1252']:
        try:
            df = pd.read_csv(csv_path, encoding=encoding)
            return df
        except (UnicodeDecodeError, Exception):
            continue
    print(f"ERROR: Could not read CSV: {csv_path}")
    sys.exit(1)
backup_csv function · python · L215-L220 (6 LOC)
update_license_renewals.py
def backup_csv(csv_path):
    """Create timestamped backup of original CSV."""
    timestamp = datetime.now().strftime('%Y-%m-%d_%H%M%S')
    backup_path = f"{csv_path}.bak.{timestamp}"
    shutil.copy2(csv_path, backup_path)
    return backup_path
run_pipeline function · python · L227-L375 (149 LOC)
update_license_renewals.py
def run_pipeline(dfpr_path=None, agents_path=None, output_path=None,
                  dry_run=False, threshold=DEFAULT_THRESHOLD,
                  dfpr_df=None, agents_df=None, source_label="manual"):
    """Main processing pipeline. Accepts CSVs paths or pre-loaded DataFrames."""
    import pandas as pd
    from collections import Counter

    print("=" * 60)
    print(f"[{ENTITY_NAME}] License Renewal Update — {datetime.now().strftime('%Y-%m-%d')}")
    print("=" * 60)
    print(f"Source:     {source_label}")
    print(f"Cutoff:     {RENEWAL_CUTOFF.strftime('%m/%d/%Y')}")
    print(f"Threshold:  {threshold}")
    print(f"Dry run:    {dry_run}")
    print()

    # --- Step 1: Load data ---
    if dfpr_df is None:
        print(f"Loading DFPR CSV: {dfpr_path}")
        dfpr_df = load_csv(dfpr_path)
    if agents_df is None:
        print(f"Loading Agents CSV: {agents_path}")
        agents_df = load_csv(agents_path)
    print(f"  DFPR records:  {len(dfpr_df)}")
    print(f"  Agents i
run_auto function · python · L381-L416 (36 LOC)
update_license_renewals.py
def run_auto(dry_run=False, threshold=DEFAULT_THRESHOLD):
    """Automated pipeline: pull from Monday.com + data.illinois.gov."""
    from fetch_monday_agents import fetch_active_agents
    from fetch_dfpr_data import fetch_dfpr_records

    print(f"[{ENTITY_NAME}] Automated renewal check starting...\n")

    # Step 1: Get active agents from Monday.com
    print("--- Monday.com ---")
    agents_df = fetch_active_agents()

    # Step 2: Get DFPR license data
    print("\n--- DFPR (data.illinois.gov) ---")
    dfpr_df = fetch_dfpr_records()

    if dfpr_df.empty:
        print("No DFPR records found. Exiting.")
        return

    # Step 3: Run the matching pipeline
    report_text = run_pipeline(
        dfpr_df=dfpr_df,
        agents_df=agents_df,
        output_path=AUTO_OUTPUT_CSV,
        dry_run=dry_run,
        threshold=threshold,
        source_label="Monday.com + data.illinois.gov",
    )

    print("\n" + report_text)

    # Step 4: Save report for Apps Script to email
    if
main function · python · L419-L470 (52 LOC)
update_license_renewals.py
def main():
    parser = argparse.ArgumentParser(
        description='License renewal tracker — automated or manual',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
  # Automated (Monday.com + DFPR API):
  python update_license_renewals.py --auto --dry-run
  python update_license_renewals.py --auto --dry-run
  python update_license_renewals.py --auto

  # Manual (local CSVs):
  python update_license_renewals.py --dfpr dfpr_export.csv --agents agents.csv --dry-run
  python update_license_renewals.py --dfpr dfpr_export.csv --agents agents.csv
        """
    )

    parser.add_argument('--auto', action='store_true',
                        help='Automated mode: pull agents from Monday.com, DFPR from data.illinois.gov')
    parser.add_argument('--dfpr', default=None, help='Path to DFPR eLicense CSV export (manual mode)')
    parser.add_argument('--agents', default=None, help='Path to agents CSV (manual mode)')
    parser.add_argument('--output',