<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Loomwise]]></title><description><![CDATA[Loomwise]]></description><link>https://loomwise.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Wed, 28 Oct 2026 13:23:30 GMT</lastBuildDate><atom:link href="https://loomwise.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[California Solar Permit Leads: Pull Every New Solar Install from County Open Data (Python)]]></title><description><![CDATA[Every rooftop solar system in California needs a building permit, and several California counties and cities publish those permits as free open data…]]></description><link>https://loomwise.hashnode.dev/california-solar-permit-leads-pull-every-new-solar-install-from-county-open-data-python</link><guid isPermaLink="true">https://loomwise.hashnode.dev/california-solar-permit-leads-pull-every-new-solar-install-from-county-open-data-python</guid><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Tue, 27 Oct 2026 14:09:57 GMT</pubDate><content:encoded><![CDATA[<p>Every rooftop solar system in California needs a building permit, and several California counties and cities publish those permits as free open data, updated daily. That makes permit data the most reliable signal of who just went solar: useful for battery and EV-charger installers, roofers, solar cleaning and monitoring services, and anyone selling to solar contractors.</p>
<p>This post shows how to pull <strong>every solar permit issued in the last 30 days</strong> from official open-data APIs, with one short Python script. No login, no scraping, no API key.</p>
<p>Run on Oct 22, 2026, the script found:</p>
<ul>
<li><strong>San Diego County (unincorporated):</strong> 260 solar permits in 30 days, mostly residential roof-mount PV through the county's online "HRA" express permit, plus ground-mount, commercial and battery (energy storage) permits.</li>
<li><strong>Corona, CA:</strong> 32 solar permits, almost all labeled "SOLAR WITH ESS" (solar plus a home battery).</li>
</ul>
<h2>The data sources</h2>
<table>
<thead>
<tr>
<th>Area</th>
<th>Publisher</th>
<th>Dataset</th>
<th>What's in it</th>
</tr>
</thead>
<tbody><tr>
<td>San Diego County (unincorporated)</td>
<td>Planning &amp; Development Services</td>
<td><a href="https://data.sandiegocounty.gov/d/red2-qccu">Building permits</a></td>
<td>Record type, scope code, work description, address, valuation</td>
</tr>
<tr>
<td>Corona, CA</td>
<td>City of Corona</td>
<td><a href="https://corstat.coronaca.gov/d/2agx-camz">Permits</a></td>
<td>Permit type and subtype, description, job value, address</td>
</tr>
</tbody></table>
<p>Both run on Socrata, so you query them with plain HTTP and SoQL (a SQL-like query language passed as URL parameters).</p>
<h2>The script</h2>
<pre><code class="language-python">import requests
from datetime import date, timedelta
since = (date.today() - timedelta(days=30)).isoformat()
SOURCES = [  # (label, domain, dataset, date field, text fields to search)
    ("San Diego County", "data.sandiegocounty.gov", "red2-qccu", "issued_date", ["use", "primary_scope_code", "record_category"]),
    ("Corona", "corstat.coronaca.gov", "2agx-camz", "issued", ["description", "permitsubtype"]),
]
def solar(label, domain, ds, dfield, fields):
    like = " OR ".join(f"upper({f}) like '%SOLAR%' OR upper({f}) like '%PHOTOVOLTAIC%' OR upper({f}) like '%PV%'" for f in fields)
    r = requests.get(f"https://{domain}/resource/{ds}.json", params={
        "$where": f"{dfield} &gt;= '{since}' AND ({like})", "$order": f"{dfield} DESC", "$limit": 5000}, timeout=120)
    r.raise_for_status(); return r.json()
for s in SOURCES:
    rows = solar(*s)
    print(f"{s[0]}: {len(rows)} solar permits since {since}")
    for r in rows[:3]:
        print("  ", r.get("issued_date") or r.get("issued"), r.get("full_address") or r.get("site_addr"), "|", (r.get("use") or r.get("description") or "")[:70].replace("\n", " "))
</code></pre>
<h2>Three things that will trip you up</h2>
<ol>
<li><strong>"Solar" isn't always in the same column.</strong> San Diego County puts the clearest signal in <code>primary_scope_code</code> (for example <code>8002 - REN - Solar Photovoltaic Roof Mount Residential - Online</code>), while the free-text <code>use</code> column holds the work description. Search both, as the script does.</li>
<li><strong>Search for PV, not just "solar".</strong> Many permits say "PV" or "photovoltaic" and never use the word solar. The script matches <code>%PV%</code> too. Spot-check the results: in a few datasets PV can show up inside unrelated words.</li>
<li><strong>Batteries are their own leads.</strong> San Diego County issues separate "Advanced Energy Storage System" permits. If you sell batteries or backup power, search for <code>ENERGY STORAGE</code> or <code>ESS</code> on their own.</li>
</ol>
<h2>Turning it into a lead list</h2>
<ul>
<li>Run it weekly with <code>timedelta(days=7)</code> and de-duplicate on the permit number.</li>
<li>New solar owners are a fit for battery add-ons, critter guards, panel cleaning, monitoring and home insurance reviews. Mail or door-hang by address. Don't try to look up the homeowners: the address and permit are enough.</li>
<li>To find the busiest installers, group by the contractor column where a city publishes one.</li>
<li>The same pattern works anywhere a city publishes permits on Socrata: change the domain, dataset ID and field names.</li>
</ul>
<hr />
<p><em>Optional: if you'd rather not maintain this, I run it as a hosted, scheduled search covering California and other states: <a href="https://apify.com/loomwise/city-building-permits/examples/california-solar-permits">California Solar Permits (ready-made search)</a> (paid per result). Disclosure: it's my tool. The script above does the same job for free.</em></p>
]]></content:encoded></item><item><title><![CDATA[Find SAM.gov Set-Aside Contracts (WOSB, 8(a), HUBZone, SDVOSB) Without an API Key, in Python]]></title><description><![CDATA[If your small business is certified as women-owned (WOSB), 8(a), HUBZone or service-disabled veteran-owned (SDVOSB), set-aside contracts are where your…]]></description><link>https://loomwise.hashnode.dev/find-sam-gov-set-aside-contracts-wosb-8-a-hubzone-sdvosb-without-an-api-key-in-python</link><guid isPermaLink="true">https://loomwise.hashnode.dev/find-sam-gov-set-aside-contracts-wosb-8-a-hubzone-sdvosb-without-an-api-key-in-python</guid><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Mon, 26 Oct 2026 14:11:24 GMT</pubDate><content:encoded><![CDATA[<p>If your small business is certified as women-owned (WOSB), 8(a), HUBZone or service-disabled veteran-owned (SDVOSB), set-aside contracts are where your odds are best, because only certified firms can bid. SAM.gov's search page works, but it's slow to filter, and the official API needs an API key tied to a SAM.gov account with low daily limits.</p>
<p>There's an easier route: <strong>SAM.gov publishes every contract opportunity as a free daily CSV file</strong>, with no key and no login. This post shows how to stream it in Python and pull just the set-asides you qualify for.</p>
<p>Run on Oct 22, 2026, for notices posted in the last 7 days:</p>
<ul>
<li><strong>6,156 notices</strong> were posted in total.</li>
<li><strong>1,228</strong> were total small-business set-asides and <strong>240</strong> were SDVOSB set-asides.</li>
<li><strong>57</strong> were HUBZone set-asides, <strong>38</strong> were 8(a) set-asides plus 13 8(a) sole-source notices, and <strong>34</strong> were WOSB or EDWOSB set-asides.</li>
<li><strong>3,880</strong> had no set-aside at all.</li>
</ul>
<h2>The data source</h2>
<p><a href="https://s3.amazonaws.com/falextracts/Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv">Contract Opportunities full extract</a>, published by GSA through data.gov and refreshed daily. It's about 200 MB, Windows-1252 encoded, with one row per notice, and it's sorted newest first. That last part is the trick: you only need to read the first few thousand rows.</p>
<h2>The script</h2>
<pre><code class="language-python">import csv, io, sys, requests
from collections import Counter
from datetime import date, timedelta
csv.field_size_limit(sys.maxsize)
URL = "https://s3.amazonaws.com/falextracts/Contract%20Opportunities/datagov/ContractOpportunitiesFullCSV.csv"
DAYS = 7
since = (date.today() - timedelta(days=DAYS)).isoformat()

def recent_opportunities():
    """Stream SAM.gov's public daily extract (about 200 MB, newest first) and stop once rows get older than the window."""
    with requests.get(URL, stream=True, timeout=300) as r:
        r.raise_for_status()
        r.raw.decode_content = True
        reader = csv.DictReader(io.TextIOWrapper(r.raw, encoding="cp1252", errors="replace", newline=""))
        older = 0
        for row in reader:
            if row["PostedDate"][:10] &gt;= since:
                older = 0
                yield row
            else:
                older += 1
                if older &gt; 300:  # small safety margin; the file is sorted newest first
                    return

KEEP = ["NoticeId", "Title", "Type", "PostedDate", "ResponseDeadLine", "Department/Ind.Agency", "SetASideCode",
        "SetASide", "NaicsCode", "PopState", "Link"]   # contact name/email/phone columns are deliberately skipped
rows = [{k: r.get(k, "") for k in KEEP} for r in recent_opportunities()]
print(len(rows), "notices posted since", since)
print(Counter(r["SetASide"] or "(no set-aside)" for r in rows).most_common(12))
wosb = [r for r in rows if r["SetASideCode"] in ("WOSB", "WOSBSS", "EDWOSB", "EDWOSBSS")]
print(len(wosb), "WOSB/EDWOSB set-asides, e.g.:")
for r in wosb[:3]:
    print(" -", r["PostedDate"][:10], r["NaicsCode"], r["Title"][:70], r["Link"])
</code></pre>
<p>Because it streams and stops as soon as rows get older than the window, a 7-day pull reads only a small slice of the 200 MB file.</p>
<h2>Three things that will trip you up</h2>
<ol>
<li><strong>Encoding.</strong> The file is Windows-1252, not UTF-8. Read it as UTF-8 and you'll crash on the first curly quote. Use <code>encoding="cp1252", errors="replace"</code>.</li>
<li><strong>Huge text fields.</strong> Some description cells are bigger than Python's default CSV field limit, hence <code>csv.field_size_limit(sys.maxsize)</code>.</li>
<li><strong>Set-aside codes vs labels.</strong> Filter on <code>SetASideCode</code>, which is short and stable (<code>SBA</code>, <code>SDVOSBC</code>, <code>HZC</code>, <code>8A</code>, <code>WOSB</code>, <code>EDWOSB</code>, plus sole-source codes like <code>8AN</code>, <code>WOSBSS</code> and <code>SDVOSBS</code>). The <code>SetASide</code> label text changes wording over time.</li>
</ol>
<h2>Make it yours</h2>
<ul>
<li>Add <code>and r["NaicsCode"].startswith("2382")</code> (or your NAICS codes) to the filter.</li>
<li>Filter on <code>PopState</code> for place of performance near you.</li>
<li>Run it every morning with cron or a scheduled GitHub Action, and email yourself the new rows (de-duplicate on <code>NoticeId</code>).</li>
<li>The script leaves out the contact-person columns on purpose. Use the notice <code>Link</code> to reach the contracting office the official way.</li>
</ul>
<hr />
<p><em>Optional: I run this as a hosted, scheduled search with set-aside, NAICS and state filters: <a href="https://apify.com/loomwise/federal-opportunities/examples/wosb-federal-contracts">WOSB Federal Contracts (ready-made search)</a> (paid per result). Disclosure: it's my tool. The script above does the same job for free.</em></p>
]]></content:encoded></item><item><title><![CDATA[Building a "default field values" app on Forge UI modifications: what the Create dialog lets you do (and doesn't)]]></title><description><![CDATA[In company-managed Jira Cloud you can't give Priority, Description, Labels or Assignee a default per work type. The request, JRACLOUD-4812, has about 2,800…]]></description><link>https://loomwise.hashnode.dev/building-a-default-field-values-app-on-forge-ui-modifications-what-the-create-dialog-lets-you-do-and-doesn-t</link><guid isPermaLink="true">https://loomwise.hashnode.dev/building-a-default-field-values-app-on-forge-ui-modifications-what-the-create-dialog-lets-you-do-and-doesn-t</guid><category><![CDATA[JIRA]]></category><category><![CDATA[atlassian]]></category><category><![CDATA[Forge]]></category><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Mon, 26 Oct 2026 13:14:49 GMT</pubDate><content:encoded><![CDATA[<h3>The gap</h3>
<p>In company-managed Jira Cloud you can't give Priority, Description, Labels or Assignee a default per work type. The request, JRACLOUD-4812, has about 2,800 votes. Team-managed projects have per-type defaults built in; company-managed projects don't. We built a small Forge app, Default Field Values for Jira, to close that gap. Here's how it works and what broke along the way.</p>
<h3>UI modifications 101</h3>
<p>Forge's <code>jira:uiModifications</code> module lets an app change fields inside Jira's Create dialog. You register a modification for a set of contexts (project + issue type), and Jira loads your resource when the dialog opens. In the <code>onInit</code> hook you read your stored config and call things like <code>getFieldById('priority').setValue(...)</code> or <code>getFieldById('description').setValue(...)</code>. The user sees the values before clicking Create and can still change them.</p>
<h3>Registering contexts when an admin saves</h3>
<p>UI modifications only run where they're registered. So when an admin saves defaults for a project and issue type, the app's backend creates or updates the UI modification contexts via the REST API. If you forget this step, the dialog simply ignores you.</p>
<h3>Why the dialog isn't enough</h3>
<p>Board and backlog inline create never open the Create dialog, and neither do issues created through the REST API. For those, the app listens to the <code>avi:jira:created:issue</code> trigger and fills only the fields that are still empty, with notifications turned off. In our tests this usually applies within seconds, occasionally 1–2 minutes, because Forge triggers are asynchronous.</p>
<h3>Bugs we hit</h3>
<p><strong>Priority wasn't applied to board-created issues.</strong> Jira always sets a priority, so the app only replaces it when the issue still has the default. We read "the default" from the global priority list's <code>isDefault</code> flag. On modern sites that's wrong: the priority scheme holds the default (<code>defaultPriorityId</code>). So our check never matched and the priority stayed Medium. The fix reads the default from <code>GET /rest/api/3/priorityscheme?projectId=&lt;id&gt;</code> and falls back to the global flag only when no scheme is returned.</p>
<p><strong>The admin page needed two saves.</strong> Two causes. The form didn't reload from the stored config after saving, so it showed stale state. And the Assignee picker only took its value on first render, so later updates to it were ignored. Reloading the form from storage after each save, and making the picker follow its value, fixed it.</p>
<h3>Limits to be honest about</h3>
<ul>
<li>Fields can look empty for a split second before the defaults appear, because Jira loads app changes after the dialog opens.</li>
<li>Jira allows up to 5 UI-modification apps per project and issue type.</li>
<li>The Jira Service Management portal isn't supported.</li>
<li>After-creation defaults aren't instant: usually within seconds, occasionally 1–2 minutes.</li>
</ul>
<p><em>Disclosure: we're Loomwise and we built Default Field Values for Jira. It's free for up to 10 users and is in Atlassian Marketplace review now. Details: <a href="https://loomwise.app/jira-default-values/">https://loomwise.app/jira-default-values/</a></em></p>
]]></content:encoded></item><item><title><![CDATA[New Business Leads in New York and Connecticut: Pull This Week's New LLCs (with NAICS Codes) from State Open Data]]></title><description><![CDATA[If you sell to brand-new companies (business banking, bookkeeping, payroll, insurance, signage, POS, web design), the best leads are businesses that…]]></description><link>https://loomwise.hashnode.dev/new-business-leads-in-new-york-and-connecticut-pull-this-week-s-new-llcs-with-naics-codes-from-state-open-data</link><guid isPermaLink="true">https://loomwise.hashnode.dev/new-business-leads-in-new-york-and-connecticut-pull-this-week-s-new-llcs-with-naics-codes-from-state-open-data</guid><category><![CDATA[Python]]></category><category><![CDATA[open data]]></category><category><![CDATA[lead generation]]></category><category><![CDATA[Tutorial]]></category><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Fri, 23 Oct 2026 18:40:08 GMT</pubDate><content:encoded><![CDATA[<p>If you sell to brand-new companies (business banking, bookkeeping, payroll, insurance, signage, POS, web design), the best leads are businesses that registered with the state in the last few days. New York and Connecticut both publish their business registries as free, daily-updated open data. You don't need an API key or a login, and you don't have to scrape anything.</p>
<p>When I ran the script below on Oct 23, 2026, the last 7 days returned <strong>5,249 new New York LLCs and corporations</strong> and <strong>1,100 new active Connecticut businesses</strong>. Connecticut's file also carries the <strong>NAICS industry code</strong> the owner picked at registration, so you can ask for "new construction companies" (95 that week) or "new restaurants" directly.</p>
<h2>The sources</h2>
<table>
<thead>
<tr>
<th>State</th>
<th>Dataset</th>
<th>Updated</th>
<th>Key fields</th>
</tr>
</thead>
<tbody><tr>
<td>New York</td>
<td>Department of State "Active Corporations: Beginning 1800" (<a href="https://data.ny.gov/d/n9v6-gdp6">data.ny.gov n9v6-gdp6</a>)</td>
<td>Daily</td>
<td>name, DOS ID, filing date, county, entity type</td>
</tr>
<tr>
<td>Connecticut</td>
<td>Secretary of the State "Business Registry - Business Master" (<a href="https://data.ct.gov/d/n7gp-d28j">data.ct.gov n7gp-d28j</a>), public domain</td>
<td>Daily</td>
<td>name, type, registration date, NAICS code, city/ZIP</td>
</tr>
</tbody></table>
<p>Both are Socrata portals, so they share one query language (SoQL) over plain HTTP.</p>
<h2>The script</h2>
<pre><code class="language-python">import requests, collections
from datetime import date, timedelta
since = (date.today() - timedelta(days=7)).isoformat()

# New York: Department of State "Active Corporations" (updated daily)
ny = requests.get("https://data.ny.gov/resource/n9v6-gdp6.json", params={
    "$select": "dos_id,current_entity_name,initial_dos_filing_date,county,entity_type",
    "$where": f"initial_dos_filing_date &gt;= '{since}'",
    "$order": "initial_dos_filing_date DESC", "$limit": 50000}, timeout=60).json()
print("NY new entities:", len(ny))
print(collections.Counter(r.get("county") for r in ny).most_common(5))

# Connecticut: Secretary of the State Business Registry (updated daily, has NAICS)
ct = requests.get("https://data.ct.gov/resource/n7gp-d28j.json", params={
    "$select": "accountnumber,name,business_type,date_registration,naics_code,billingcity",
    "$where": f"date_registration &gt;= '{since}' AND starts_with(status, 'Active')",
    "$order": "date_registration DESC", "$limit": 50000}, timeout=60).json()
print("CT new businesses:", len(ct))
sectors = collections.Counter((r.get("naics_code") or "(none)").rsplit("(", 1)[-1][:2] for r in ct)
print(sectors.most_common(6))
build = [r for r in ct if "(23" in (r.get("naics_code") or "")]
print("CT construction:", len(build), [r["name"] for r in build[:3]])
</code></pre>
<p>Output on Oct 23, 2026:</p>
<pre><code>NY new entities: 5249
[('Kings', 754), ('Albany', 695), ('New York', 585), ('Queens', 550), ('Nassau', 487)]
CT new businesses: 1100
[('53', 173), ('54', 159), ('23', 95), ('45', 92), ('81', 85), ('56', 64)]
CT construction: 95 ['DD Ventures LLC', 'Elite Contracting LLC', 'Keystone Property Investments LLC']
</code></pre>
<p>In Connecticut, real estate (NAICS 53) and professional services (54) lead, with construction (23) close behind.</p>
<h2>Gotchas I hit</h2>
<ol>
<li><strong>Albany County is inflated in New York.</strong> Many filers list a registered-agent service in Albany, so "county" there often means the agent's county, not where the business operates. For local targeting, filter on ZIP (<code>dos_process_zip</code>) or on the <code>location_*</code> fields when they're filled in.</li>
<li><strong>Connecticut NAICS is one text field</strong>, e.g. <code>"Coin-Operated Laundries and Drycleaners (812310)"</code>. Match the prefix with <code>contains(naics_code, '(23')</code> in SoQL, or parse the digits in Python.</li>
<li><strong>Filter out rejected filings.</strong> Connecticut includes <code>Rejected</code> registrations. Use <code>starts_with(status, 'Active')</code>.</li>
<li><strong>Treat person-related fields carefully.</strong> New York has CEO and registered-agent name fields, and Connecticut has a business email field that is often a personal address. For B2B outreach I leave those out and keep to the company name, industry, city and ZIP.</li>
<li><strong>Use <code>$limit</code> explicitly.</strong> Socrata returns 1,000 rows by default. For larger pulls, page with <code>$offset</code>.</li>
</ol>
<h2>Turning it into a lead list</h2>
<p>Run it daily or weekly, write the rows to CSV, and de-duplicate on <code>dos_id</code> / <code>accountnumber</code>. If you sell to one industry, the Connecticut NAICS filter does most of the qualifying for you. For New York, keyword filters on the name (<code>contains(lower(current_entity_name), 'construction')</code>) work surprisingly well.</p>
<hr />
<p><em>Optional: if you'd rather not maintain the script, I publish these as hosted, scheduled tools: <a href="https://apify.com/loomwise/new-york-new-business-filings">New York New Business Filings</a> and <a href="https://apify.com/loomwise/connecticut-new-business-registrations">Connecticut New Business Leads</a> (paid per result, with personal fields stripped). Disclosure: they're my tools. The code above does the same job for free.</em></p>
]]></content:encoded></item><item><title><![CDATA[New Business Leads for Free: Pull Every New Company in Colorado and Texas This Week (Python + State Open Data)]]></title><description><![CDATA[New businesses buy things: insurance, payroll, websites, signage, accounting, POS systems, office space. The trick is finding them in their first weeks…]]></description><link>https://loomwise.hashnode.dev/new-business-leads-for-free-pull-every-new-company-in-colorado-and-texas-this-week-python-state-open-data</link><guid isPermaLink="true">https://loomwise.hashnode.dev/new-business-leads-for-free-pull-every-new-company-in-colorado-and-texas-this-week-python-state-open-data</guid><category><![CDATA[Python]]></category><category><![CDATA[Data Science]]></category><category><![CDATA[lead generation]]></category><category><![CDATA[Small business]]></category><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Fri, 23 Oct 2026 14:37:25 GMT</pubDate><content:encoded><![CDATA[<p>New businesses buy things: insurance, payroll, websites, signage, accounting, POS systems, office space. The trick is finding them in their first weeks, before everyone else does. Two states publish this for free, updated daily, with no API key:</p>
<ul>
<li><strong>Colorado</strong>: the Secretary of State's business entity register on data.colorado.gov (every LLC, corporation and nonprofit, with its formation date).</li>
<li><strong>Texas</strong>: the Comptroller's active sales tax permit list on data.texas.gov. A new permit means a business that's about to start selling.</li>
</ul>
<p>When I ran this on Oct 23, 2026, the last 7 days held <strong>3,588 new Colorado companies</strong> (Denver 963, Colorado Springs 440, Aurora 227) and <strong>889 new Texas sales tax outlets</strong> (Houston 76, Austin 62, San Antonio 40).</p>
<h2>The script</h2>
<pre><code class="language-python">import requests
from collections import Counter
from datetime import date, timedelta

def colorado_new_companies(days=7, city=None):
    since = (date.today() - timedelta(days=days)).isoformat()
    where = f"entityformdate &gt;= '{since}T00:00:00' AND entitytype in ('DLLC','DPC','DNC')"
    if city:
        where += f" AND upper(principalcity) = '{city.upper()}'"
    return requests.get("https://data.colorado.gov/resource/4ykn-tg5h.json", params={
        "$select": "entityid,entityname,entitytype,entityformdate,principalcity,principalzipcode",
        "$where": where, "$order": "entityformdate DESC", "$limit": 50000}, timeout=120).json()

def texas_new_outlets(days=7, city=None):
    since = (date.today() - timedelta(days=days)).isoformat()
    where = f"outlet_permit_issue_date &gt;= '{since}T00:00:00'"
    if city:
        where += f" AND upper(outlet_city) = '{city.upper()}'"
    return requests.get("https://data.texas.gov/resource/jrea-zgmq.json", params={
        "$select": "outlet_name,outlet_city,outlet_zip_code,outlet_naics_code,outlet_permit_issue_date",
        "$where": where, "$order": "outlet_permit_issue_date DESC", "$limit": 50000}, timeout=120).json()

co = colorado_new_companies(7)
tx = texas_new_outlets(7)
print(len(co), "new Colorado companies; top cities:", Counter(r.get("principalcity", "").title() for r in co).most_common(5))
print(len(tx), "new Texas outlets; top cities:", Counter(r.get("outlet_city", "").title() for r in tx).most_common(5))
</code></pre>
<p>Want just Denver? <code>colorado_new_companies(7, city="Denver")</code>. Just Austin? <code>texas_new_outlets(7, city="Austin")</code>.</p>
<h2>Things that will trip you up</h2>
<ol>
<li><strong>Colorado entity types are codes.</strong> <code>DLLC</code> is a domestic LLC, <code>DPC</code> a domestic corporation, <code>DNC</code> a domestic nonprofit. Foreign entities (<code>FLLC</code>, <code>FPC</code>) are out-of-state companies registering to do business in Colorado. Add them if you sell to expanding companies.</li>
<li><strong>Texas has an industry code.</strong> <code>outlet_naics_code</code> lets you target, for example, <code>722511</code> (full-service restaurants) or <code>4441*</code> (building material dealers). Filter with <code>starts_with(outlet_naics_code, '722')</code> in the <code>$where</code>.</li>
<li><strong>Skip personal names.</strong> Both datasets include sole proprietors and registered agents who are individuals. The script selects only business and outlet names. That's better for privacy, and the business is who you want to reach anyway.</li>
<li><strong>Use the SoS link to verify.</strong> Each Colorado row has an <code>entityid</code>, and <code>https://www.coloradosos.gov/biz/BusinessEntityDetail.do?masterFileId=&lt;entityid&gt;</code> opens the official record.</li>
</ol>
<h2>Turning it into a weekly lead list</h2>
<p>Run it every Monday with <code>days=7</code>, de-duplicate on <code>entityid</code> (Colorado) or outlet name plus ZIP (Texas), and push the rows into a sheet or your CRM. Pair it with a building permits feed if you sell to trades: a new business plus a tenant-improvement permit at the same address is a hot lead.</p>
<hr />
<p><em>Optional: if you'd rather not maintain the script, I publish both as hosted, scheduled tools: <a href="https://apify.com/loomwise/colorado-new-business-filings">Colorado New Business Filings</a> and <a href="https://apify.com/loomwise/texas-new-business-leads">Texas New Business Leads</a> (paid per result). Disclosure: they're my tools. The code above does the same job for free.</em></p>
]]></content:encoded></item><item><title><![CDATA[Solar, Roofing and HVAC Leads from Building Permits: A How-To with 30 Days of Real Data]]></title><description><![CDATA[If you sell solar, roofing or HVAC, or sell to the companies that do, building permits are the most honest public signal of where work is happening. I…]]></description><link>https://loomwise.hashnode.dev/solar-roofing-and-hvac-leads-from-building-permits-a-how-to-with-30-days-of-real-data</link><guid isPermaLink="true">https://loomwise.hashnode.dev/solar-roofing-and-hvac-leads-from-building-permits-a-how-to-with-30-days-of-real-data</guid><category><![CDATA[Data Science]]></category><category><![CDATA[Python]]></category><category><![CDATA[lead generation]]></category><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Fri, 23 Oct 2026 14:21:30 GMT</pubDate><content:encoded><![CDATA[<p>If you sell solar, roofing or HVAC, or sell <em>to</em> the companies that do, building permits are the most honest public signal of where work is happening. I classified <strong>77,044 permits</strong> issued from Sep 20 to Oct 19, 2026, across 47 US cities and counties. Here is what the three most-requested trades look like.</p>
<h2>How the work categories are assigned</h2>
<p>Cities don't share a permit taxonomy. Austin uses "Electrical Permit - Residential" and Columbus uses "1,2,3 Family MEP"; others only have free text. I map each permit to one category using keyword rules over the type, work class and description, in priority order:</p>
<pre><code class="language-js">const RULES = [
  ['Solar', /solar|photovoltaic|\bpv\b/i],
  ['Roofing', /re-?roof|roofing|\broof\b/i],
  ['Mechanical / HVAC', /hvac|mechanical|furnace|heat pump|air condition|boiler|duct/i],
  // ...electrical, plumbing, new construction, demolition, etc.
];
const category = (p) =&gt;
  (RULES.find(([, re]) =&gt; re.test(`${p.type} ${p.workClass} ${p.description}`)) || ['Other'])[0];
</code></pre>
<p>Order matters: a "re-roof and add solar" permit is counted as solar. Expect some noise, so spot-check the descriptions.</p>
<h2>Solar: 1,439 permits</h2>
<table>
<thead>
<tr>
<th>City/county</th>
<th>Permits</th>
<th>Median value</th>
<th>With contractor name</th>
</tr>
</thead>
<tbody><tr>
<td>New York City</td>
<td>357</td>
<td>$39,864</td>
<td>51%</td>
</tr>
<tr>
<td>Chicago</td>
<td>237</td>
<td>$21,827</td>
<td>100%</td>
</tr>
<tr>
<td>San Diego County (unincorporated)</td>
<td>224</td>
<td>n/a</td>
<td>25%</td>
</tr>
<tr>
<td>San Jose (last 30 days feed)</td>
<td>188</td>
<td>n/a</td>
<td>46%</td>
</tr>
<tr>
<td>Boulder</td>
<td>95</td>
<td>$6,640</td>
<td>67%</td>
</tr>
<tr>
<td>Washington, DC (last 30 days feed)</td>
<td>79</td>
<td>n/a</td>
<td>0%</td>
</tr>
<tr>
<td>Baltimore</td>
<td>37</td>
<td>$16,000</td>
<td>0%</td>
</tr>
<tr>
<td>Norfolk</td>
<td>34</td>
<td>$29,698</td>
<td>0%</td>
</tr>
</tbody></table>
<p>Most active by permit count: SUNRUN INSTALLATION SERVICES INC (150), NYSS LLC (50), SUNRUN INC (35), CERTIFIED ELECTRIC LLC (30).</p>
<h2>Roofing: 4,130 permits</h2>
<table>
<thead>
<tr>
<th>City/county</th>
<th>Permits</th>
<th>Median value</th>
<th>With contractor name</th>
</tr>
</thead>
<tbody><tr>
<td>Columbus</td>
<td>1061</td>
<td>$13,702</td>
<td>52%</td>
</tr>
<tr>
<td>Miami-Dade County (unincorporated)</td>
<td>729</td>
<td>$23,000</td>
<td>91%</td>
</tr>
<tr>
<td>San Jose (last 30 days feed)</td>
<td>326</td>
<td>$16,646</td>
<td>51%</td>
</tr>
<tr>
<td>Chicago</td>
<td>231</td>
<td>$27,300</td>
<td>97%</td>
</tr>
<tr>
<td>Philadelphia</td>
<td>193</td>
<td>n/a</td>
<td>5%</td>
</tr>
<tr>
<td>Boston</td>
<td>191</td>
<td>$20,400</td>
<td>0%</td>
</tr>
<tr>
<td>Cleveland</td>
<td>186</td>
<td>$11,980</td>
<td>91%</td>
</tr>
<tr>
<td>Baton Rouge (East Baton Rouge Parish)</td>
<td>142</td>
<td>$22,998</td>
<td>89%</td>
</tr>
</tbody></table>
<p>Most active by permit count: NEWMAN ROOFING LLC (58), ABLE ROOFING LLC (44), Premier South LLC (43), WINDOW NATION LLC (39).</p>
<h2>Mechanical / HVAC: 6,624 permits</h2>
<table>
<thead>
<tr>
<th>City/county</th>
<th>Permits</th>
<th>Median value</th>
<th>With contractor name</th>
</tr>
</thead>
<tbody><tr>
<td>New York City</td>
<td>1738</td>
<td>$59,350</td>
<td>72%</td>
</tr>
<tr>
<td>Columbus</td>
<td>900</td>
<td>n/a</td>
<td>45%</td>
</tr>
<tr>
<td>Austin</td>
<td>743</td>
<td>n/a</td>
<td>43%</td>
</tr>
<tr>
<td>Virginia Beach</td>
<td>360</td>
<td>n/a</td>
<td>0%</td>
</tr>
<tr>
<td>Washington, DC (last 30 days feed)</td>
<td>329</td>
<td>n/a</td>
<td>0%</td>
</tr>
<tr>
<td>Norfolk</td>
<td>247</td>
<td>$8,554</td>
<td>0%</td>
</tr>
<tr>
<td>Cincinnati</td>
<td>234</td>
<td>$11,758</td>
<td>52%</td>
</tr>
<tr>
<td>Bellevue</td>
<td>226</td>
<td>n/a</td>
<td>57%</td>
</tr>
</tbody></table>
<p>Most active by permit count: JT MAGEN &amp; COMPANY INC (56), AIRTRON INC (53), Texas Multifamily Solution, LLC (41), COLONIAL HEATING AND COOLING CO INC (38).</p>
<h2>Reading the numbers</h2>
<ul>
<li><strong>Solar is concentrated among a few installers.</strong> Sunrun entities alone pulled about 185 permits in 30 days. For an installer, this is a picture of competitor share. For suppliers and financiers, it's a target list.</li>
<li><strong>Roofing is the most "lead-like" trade.</strong> It's high-volume and residential, and about half of the permits name the roofer. Columbus and Miami-Dade dominate this month.</li>
<li><strong>HVAC values are unreliable.</strong> Many HVAC permits are equipment swaps with no value or a token one, so filter on type and description rather than value.</li>
</ul>
<h2>A practical workflow</h2>
<ol>
<li>Pick your trade and metros, then pull the last 30 days.</li>
<li><strong>Selling to homeowners?</strong> A permit usually means that job is already sold. Use it for neighborhood marketing or for the follow-on trade (new roof, then solar, gutters or insulation).</li>
<li><strong>Selling to contractors?</strong> Group by contractor name and rank by permit count to get a list of active installers.</li>
<li>Re-run weekly and keep only new permit numbers.</li>
</ol>
<h2>Get the data</h2>
<p>Everything above came from the <a href="https://apify.com/loomwise/city-building-permits">Building Permits Scraper</a>. It has a <code>workCategories</code> filter (e.g. <code>["Solar","Roofing"]</code>) plus <code>states</code>/<code>regions</code> filters, and costs $2 per 1,000 permits. Every source is an official open-data portal, linked in the README if you'd rather query it directly.</p>
<p><em>Related: <a href="https://dev.to/loomwise/turning-contractor-registrations-and-building-permits-into-a-sales-pipeline-5ehj">Turning contractor registrations and building permits into a sales pipeline</a> · <a href="https://dev.to/loomwise/texas-building-permit-leads-10910-permits-from-austin-san-antonio-collin-county-and-more-3mlk">Texas building permit leads: 10,910 permits from Austin, San Antonio, Collin County and more</a></em></p>
]]></content:encoded></item><item><title><![CDATA[Find Newly Licensed Contractors in Oregon and Connecticut for Free (Python + State Open Data)]]></title><description><![CDATA[New contractor businesses buy a lot in their first months: insurance, bonds, trucks, tools, software, accounting and supplies. Several states publish their…]]></description><link>https://loomwise.hashnode.dev/find-newly-licensed-contractors-in-oregon-and-connecticut-for-free-python-state-open-data</link><guid isPermaLink="true">https://loomwise.hashnode.dev/find-newly-licensed-contractors-in-oregon-and-connecticut-for-free-python-state-open-data</guid><category><![CDATA[Python]]></category><category><![CDATA[open data]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[construction]]></category><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Thu, 22 Oct 2026 13:23:19 GMT</pubDate><content:encoded><![CDATA[<p>New contractor businesses buy a lot in their first months: insurance, bonds, trucks, tools, software, accounting and supplies. Several states publish their contractor license data as free open data, updated daily, with no login and no scraping. This post shows how to pull <strong>every new contractor business licensed in the last 30 days</strong> in Oregon and Connecticut with one short Python script, using only official state APIs.</p>
<p>Run on Oct 22, 2026, the script found:</p>
<ul>
<li><strong>Oregon:</strong> 495 new contractor businesses in 30 days. 275 were residential general contractors, 67 lead-safe renovators and 53 residential specialty contractors. The top counties were Multnomah (62), Washington (56), Marion (48) and Clackamas (40), and 84 were out-of-state firms entering Oregon.</li>
<li><strong>Connecticut:</strong> 285 new contractor business licenses. 237 were home improvement contractors, 28 new home construction contractors, 15 major contractors and 3 demolition contractors.</li>
</ul>
<h2>The data sources</h2>
<table>
<thead>
<tr>
<th>State</th>
<th>Agency</th>
<th>Dataset</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td>Oregon</td>
<td>Construction Contractors Board (CCB)</td>
<td><a href="https://data.oregon.gov/d/g77e-6bhs">CCB Active Licenses</a></td>
<td>Public domain. Includes the original registration date, phone, bond and insurance carrier</td>
</tr>
<tr>
<td>Connecticut</td>
<td>Dept. of Consumer Protection</td>
<td><a href="https://data.ct.gov/d/ngch-56tr">State Licenses and Credentials</a></td>
<td>Public domain. Covers every DCP license, not just contractors, with an entity <code>type</code> field</td>
</tr>
</tbody></table>
<p>Both run on Socrata, so you query them with plain HTTP and SoQL.</p>
<h2>The script</h2>
<pre><code class="language-python">import requests, re
from datetime import date, timedelta
ENTITY = re.compile(r"\b(LLC|L\.L\.C\.|INC|CORP|CORPORATION|CO|COMPANY|LTD|LLP|LP|PLLC|PC)\b\.?", re.I)

def ct_new_contractors(days=30):
    since = (date.today() - timedelta(days=days)).isoformat()
    rows = requests.get("https://data.ct.gov/resource/ngch-56tr.json", params={
        "$select": "businessname,name,type,credential,fullcredentialcode,status,issuedate,city,zip",
        "$where": (f"issuedate &gt;= '{since}' AND issuedate &lt;= '{date.today()}T23:59:59' "
                   "AND upper(credential) like '%CONTRACTOR%' "
                   "AND type in ('LIMITED LIABILITY COMPANY','CORPORATION','BUSINESS','PARTNERSHIP')"),
        "$order": "issuedate DESC", "$limit": 5000}, timeout=120).json()
    return rows

def or_new_contractors(days=30):
    since = date.today() - timedelta(days=days)
    rows = requests.get("https://data.oregon.gov/resource/g77e-6bhs.json", params={
        "$select": "license_number,license_type,endorsement_text,full_name,city,county_name,orig_regis_date,ins_company,ins_exp_date",
        "$where": f"orig_regis_date like '%/{since.year}'", "$limit": 50000}, timeout=120).json()
    out = []
    for r in rows:
        m, d, y = map(int, r["orig_regis_date"].split("/"))
        if date(y, m, d) &gt;= since and ENTITY.search(r.get("full_name", "")):
            out.append(r)
    return sorted(out, key=lambda r: r["orig_regis_date"][-4:] + r["orig_regis_date"][:5], reverse=True)

ct, orr = ct_new_contractors(), or_new_contractors()
print(len(ct), "new CT contractor businesses;", len(orr), "new OR contractor businesses")
</code></pre>
<h2>Three things that will trip you up</h2>
<ol>
<li><strong>Oregon stores dates as</strong> <code>MM/DD/YYYY</code> <strong>text.</strong> You can't run a date range query on the server. The script narrows by year with <code>like '%/2026'</code>, then filters the exact window in Python. (For windows that cross New Year, add the previous year too.)</li>
<li><strong>Connecticut has some future issue dates</strong> (data-entry errors such as 2027-03-31). Add an upper bound of today, as the script does.</li>
<li><strong>Keep it to businesses.</strong> Both datasets include sole proprietors, whose names are personal names. Connecticut has a <code>type</code> column, so filter to LLCs, corporations and partnerships. Oregon doesn't, so keep only names with an entity marker (LLC, Inc, Corp...) and never pull the responsible-individual column. That's better for privacy, and businesses are the leads you want anyway.</li>
</ol>
<h2>Turning it into a weekly lead list</h2>
<ul>
<li>Run it weekly with <code>days=7</code>, de-duplicate on license number, and append to a sheet or your CRM.</li>
<li>Oregon's <code>ins_company</code> and <code>ins_exp_date</code> are useful for insurance agents: sort by expiration date for renewal timing.</li>
<li>In Connecticut, change the credential filter to <code>'%LIQUOR%'</code> or <code>'%FOOD%'</code> and the same script finds new restaurants and bars.</li>
</ul>
<hr />
<p><em>Optional: if you'd rather not maintain the script, I publish both as hosted, scheduled tools:</em> <a href="https://apify.com/loomwise/oregon-contractor-licenses"><em>Oregon CCB Contractor License Scraper</em></a> <em>and</em> <a href="https://apify.com/loomwise/connecticut-business-licenses"><em>Connecticut License Lookup</em></a> <em>(paid per result). Disclosure: they're my tools. The code above does the same job for free.</em></p>
<p><em>Originally published on</em> <a href="http://dev.to"><em>dev.to</em></a><em>.</em></p>
]]></content:encoded></item><item><title><![CDATA[How to Pull Building Permits from Any US City's Open-Data API (Socrata, ArcGIS, CKAN) in Python]]></title><description><![CDATA[If you've tried to get building permit data for more than one city, you've hit the same wall: there's no national permit API. Every city publishes its own…]]></description><link>https://loomwise.hashnode.dev/how-to-pull-building-permits-from-any-us-city-s-open-data-api-socrata-arcgis-ckan-in-python</link><guid isPermaLink="true">https://loomwise.hashnode.dev/how-to-pull-building-permits-from-any-us-city-s-open-data-api-socrata-arcgis-ckan-in-python</guid><category><![CDATA[Python]]></category><category><![CDATA[open data]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[construction]]></category><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Thu, 22 Oct 2026 13:21:26 GMT</pubDate><content:encoded><![CDATA[<p>If you've tried to get building permit data for more than one city, you've hit the same wall: there's no national permit API. Every city publishes its own feed, and almost all of them run on one of three platforms: <strong>Socrata</strong>, <strong>ArcGIS</strong> or <strong>CKAN</strong>. Learn those three query styles and you can read most US permit portals.</p>
<p>This guide gives you a working Python function for each platform, the paging and date quirks that break naive scripts, and a way to merge cities into one table. Every snippet below was tested on Oct 22, 2026 against Chicago (Socrata), Louisville (ArcGIS) and Pittsburgh (CKAN). Together they returned <strong>5,165 permits issued in the last 30 days</strong>, 756 of them worth $100k or more.</p>
<h2>How to tell which platform a city uses</h2>
<table>
<thead>
<tr>
<th>You see in the URL</th>
<th>Platform</th>
<th>API shape</th>
</tr>
</thead>
<tbody><tr>
<td><code>/resource/xxxx-xxxx</code> or <code>data.&lt;city&gt;.gov/d/xxxx-xxxx</code></td>
<td>Socrata</td>
<td><code>https://&lt;domain&gt;/resource/&lt;id&gt;.json</code> + SoQL</td>
</tr>
<tr>
<td><code>/arcgis/rest/services/.../FeatureServer/0</code> or a Hub site</td>
<td>ArcGIS</td>
<td><code>&lt;layer&gt;/query?where=...&amp;f=json</code></td>
</tr>
<tr>
<td><code>/dataset/&lt;name&gt;</code> with a "Data API" button</td>
<td>CKAN</td>
<td><code>/api/3/action/datastore_search_sql</code></td>
</tr>
</tbody></table>
<p>To find a city's feed, search <code>catalog.data.gov</code>, <code>hub.arcgis.com</code>, or " open data building permits."</p>
<h2>The three fetchers</h2>
<pre><code class="language-python">import requests
from datetime import date, datetime, timedelta, timezone
SINCE = date.today() - timedelta(days=30)

def socrata(domain, dataset, date_field, limit=1000):
    url = f"https://{domain}/resource/{dataset}.json"
    out, offset = [], 0
    while True:
        page = requests.get(url, params={
            "$where": f"{date_field} &gt;= '{SINCE}T00:00:00'",
            "$order": f"{date_field} DESC, :id",   # stable order for paging
            "$limit": limit, "$offset": offset}, timeout=120).json()
        out += page
        if len(page) &lt; limit: return out
        offset += limit

def arcgis(layer, date_field, page_size=1000):
    out, offset = [], 0
    while True:
        r = requests.get(layer + "/query", params={
            "where": f"{date_field} &gt;= DATE '{SINCE}'", "outFields": "*", "f": "json",
            "orderByFields": f"{date_field} DESC", "resultOffset": offset,
            "resultRecordCount": page_size}, timeout=120).json()
        feats = [f["attributes"] for f in r.get("features", [])]
        for a in feats:  # ArcGIS dates are epoch milliseconds
            if isinstance(a.get(date_field), (int, float)):
                a[date_field] = datetime.fromtimestamp(a[date_field] / 1000, timezone.utc).date().isoformat()
        out += feats
        if not r.get("exceededTransferLimit"): return out
        offset += len(feats)

def ckan(domain, resource_id, date_field, limit=5000):
    sql = (f'SELECT * FROM "{resource_id}" WHERE "{date_field}" &gt;= \'{SINCE}\' '
           f'ORDER BY "{date_field}" DESC LIMIT {limit}')
    r = requests.get(f"https://{domain}/api/3/action/datastore_search_sql", params={"sql": sql}, timeout=120).json()
    return r["result"]["records"]
</code></pre>
<h3>The gotchas these functions handle</h3>
<ol>
<li><strong>Socrata paging needs a stable order.</strong> If you page with <code>$offset</code> but sort only by date, rows that share a timestamp can repeat or go missing between pages. Adding <code>:id</code> as a tie-breaker fixes it.</li>
<li><strong>Socrata leaves null fields out of the JSON.</strong> Rows come back with different keys. Use <code>.get()</code> everywhere, or call the <code>.csv</code> endpoint if you need fixed columns.</li>
<li><strong>ArcGIS dates are epoch milliseconds</strong>, and the <code>where</code> clause needs <code>DATE 'YYYY-MM-DD'</code> syntax. A plain string comparison silently returns nothing on many servers.</li>
<li><strong>ArcGIS caps each page</strong> (often at 1,000 or 2,000 rows) and tells you there's more with <code>exceededTransferLimit</code>. Loop until it's false.</li>
<li><strong>CKAN column names are case-sensitive and often contain spaces</strong> (San Antonio uses <code>"DATE ISSUED"</code>), so always double-quote them in SQL.</li>
<li><strong>Text-typed numbers.</strong> Many portals store valuation as text. Cast before you sum: <code>float(x or 0)</code> in Python, or <code>::number</code> in SoQL.</li>
</ol>
<h2>Use it: three cities, one table</h2>
<pre><code class="language-python">chicago = socrata("data.cityofchicago.org", "ydr8-5enu", "issue_date")
louisville = arcgis("https://services1.arcgis.com/79kfd2K6fskCAkyg/arcgis/rest/services/"
                    "active_construction_permits/FeatureServer/0", "ISSUE_DATE")
pittsburgh = ckan("data.wprdc.org", "f4d1177a-f597-4c32-8cbf-7885f56253f6", "issue_date")

def norm(city, r, num, date_f, desc, value, addr, contractor=None):
    return {"city": city, "permit": r.get(num), "issued": str(r.get(date_f))[:10],
            "description": r.get(desc), "value": float(r.get(value) or 0),
            "address": r.get(addr), "contractor": (r.get(contractor) or "").strip() or None}

rows = ([norm("Chicago", r, "permit_", "issue_date", "work_description", "reported_cost", "street_name") for r in chicago]
      + [norm("Louisville", r, "PERMIT_NUMBER", "ISSUE_DATE", "WORK_TYPE", "PROJECT_COSTS", "ADDRESS", "CONTRACTOR") for r in louisville]
      + [norm("Pittsburgh", r, "permit_id", "issue_date", "work_description", "total_project_value", "address", "contractor_name") for r in pittsburgh])
rows.sort(key=lambda r: r["issued"], reverse=True)
big = [r for r in rows if r["value"] &gt;= 100_000]
print(len(rows), "permits,", len(big), "over $100k")
</code></pre>
<p>From here it's normal pandas: group by ZIP, filter descriptions for <code>solar|roof|hvac|pool|adu</code>, or write it all to a CSV.</p>
<h2>Making it work for more cities</h2>
<ul>
<li><strong>Keep a config, not code.</strong> Store each city as (platform, endpoint, date field, field map) in a list. Adding a city then takes one line.</li>
<li><strong>Normalize work types yourself.</strong> Each city words them differently ("RES-ELC", "Electrical", "E-Permit"), so map them to your own categories with keyword rules.</li>
<li><strong>Watch for stale feeds.</strong> Some portals quietly stop updating. Log the newest <code>issued</code> date per city on every run and alert when it's more than about 14 days old.</li>
<li><strong>Respect the portals.</strong> Use their documented APIs, page politely, and cache. Socrata gives higher rate limits if you register a free app token.</li>
</ul>
<hr />
<p><em>Optional, and the code above is all you need: I maintain a hosted version of this pattern covering 47 cities and counties in one schema, <a href="https://apify.com/loomwise/city-building-permits">Building Permits Scraper on Apify</a> (paid per result). Disclosure: it's my tool.</em></p>
<p><em>Originally published on dev.to.</em></p>
]]></content:encoded></item><item><title><![CDATA[Making Jira Cloud comments internal by default with Forge]]></title><description><![CDATA[Atlassian's Forge platform doesn't let an app change the comment editor's default, so how do you make comments private by default? The pattern I used for…]]></description><link>https://loomwise.hashnode.dev/making-jira-cloud-comments-internal-by-default-with-forge</link><guid isPermaLink="true">https://loomwise.hashnode.dev/making-jira-cloud-comments-internal-by-default-with-forge</guid><dc:creator><![CDATA[Loomwise]]></dc:creator><pubDate>Tue, 20 Oct 2026 15:55:57 GMT</pubDate><content:encoded><![CDATA[<p>Atlassian's Forge platform doesn't let an app change the comment editor's default, so how do you make comments private by default? The pattern I used for Loomwise Private Comments:</p>
<ul>
<li>An <code>avi:jira:commented:issue</code> trigger fires on every new comment.</li>
<li>If the comment has no visibility set, the app calls the comment update API with the project's configured role/group.</li>
<li>Per-project settings live in Forge storage, edited from an admin page; an issue panel shows status.</li>
<li>No egress, so it qualifies as "Runs on Atlassian".</li>
</ul>
<p>Trade-off: there's a short window between posting and restriction, the same as any non-browser-extension approach. Comments that already have a visibility are left alone.</p>
<p>Comments from Jira's native comment box are restricted automatically, usually within a minute.</p>
<p>Free install and setup guide: <a href="https://loomwise.app/jira-private-comments">https://loomwise.app/jira-private-comments</a></p>
<p>Questions: <a href="mailto:hello@loomwise.app">hello@loomwise.app</a></p>
]]></content:encoded></item></channel></rss>