<?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[Mantis Leads Blog]]></title><description><![CDATA[Mantis Leads Blog]]></description><link>https://mantisleads.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aeb74548eb663165c00a645/547d121e-6615-41a7-9bba-db56131d56b6.png</url><title>Mantis Leads Blog</title><link>https://mantisleads.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:27:23 GMT</lastBuildDate><atom:link href="https://mantisleads.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Detect a Hotel Website's Booking Engine, Analytics and Ad Pixels With Python]]></title><description><![CDATA[A 60-line Python checker that finds whether a hotel website has a booking engine, sends guests to an OTA, or runs analytics and ad pixels.]]></description><link>https://mantisleads.hashnode.dev/detect-a-hotel-website-s-booking-engine-analytics-and-ad-pixels-with-python</link><guid isPermaLink="true">https://mantisleads.hashnode.dev/detect-a-hotel-website-s-booking-engine-analytics-and-ad-pixels-with-python</guid><category><![CDATA[Python]]></category><category><![CDATA[web scraping]]></category><category><![CDATA[automation]]></category><dc:creator><![CDATA[Yashraj Verma]]></dc:creator><pubDate>Sat, 07 Nov 2026 09:26:09 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeb74548eb663165c00a645/1e157ced-1ea0-454b-9c9e-4270be58ef48.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you build websites or booking systems for hotels, the first question about any prospect is simple: can a guest book a room directly on this property's site, or does every booking go through an OTA? You can answer that for hundreds of properties with about 60 lines of Python.</p>
<p>This tutorial builds a small checker that fetches a hotel's homepage and reports four things:</p>
<ul>
<li>whether a known booking engine is embedded or linked</li>
<li>whether the "Book now" button sends guests to an OTA instead</li>
<li>whether Google Analytics or Google Tag Manager is installed</li>
<li>whether a Meta pixel or a Google Ads tag is installed</li>
</ul>
<h2>What you need</h2>
<ul>
<li>Python 3.9 or newer</li>
<li><code>requests</code> and <code>beautifulsoup4</code> (<code>pip install requests beautifulsoup4</code>)</li>
<li>A CSV of hotel names and website URLs. Any source works: your own CRM export, a spreadsheet of properties you already know, or a lead list.</li>
</ul>
<h2>Step 1: Signatures to look for</h2>
<p>Booking engines leave fingerprints in the HTML: a script, an iframe or a link to the vendor's domain. The list below covers vendors you will meet often on Indian and international hotel sites. It is a starting point, so extend it as you find new ones.</p>
<pre><code class="language-python">import re

BOOKING_ENGINES = {
    "eZee": ["live.ipms247.com", "ezeeabsolute"],
    "SiteMinder": ["thebookingbutton.com", "direct-book.com", "siteminder.com"],
    "Cloudbeds": ["hotels.cloudbeds.com"],
    "SynXis": ["synxis.com"],
    "STAAH": ["staah.net", "staah.com"],
    "AxisRooms": ["axisrooms.com"],
    "Djubo": ["djubo.com"],
    "Hotelogix": ["hotelogix.com"],
    "Simplotel": ["simplotel.com"],
    "BookingJini": ["bookingjini.com"],
    "ResAvenue": ["resavenue.com"],
}

OTA_DOMAINS = ["booking.com", "makemytrip.com", "goibibo.com", "agoda.com",
               "expedia.", "airbnb.", "tripadvisor.", "oyorooms.com"]

TRACKING = {
    "google_analytics": [r"gtag/js\?id=G-", r"google-analytics\.com/analytics\.js",
                         r"gtag\(\s*['\"]config['\"]\s*,\s*['\"]G-"],
    "google_tag_manager": [r"googletagmanager\.com/gtm\.js", r"GTM-[A-Z0-9]{4,}"],
    "meta_pixel": [r"connect\.facebook\.net/[^\"']+/fbevents\.js", r"fbq\(\s*['\"]init['\"]"],
    "google_ads": [r"gtag\(\s*['\"]config['\"]\s*,\s*['\"]AW-",
                   r"googleadservices\.com/pagead/conversion"],
}
</code></pre>
<h2>Step 2: Check one website</h2>
<pre><code class="language-python">import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin

HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; site-audit/1.0)"}
BOOK_WORDS = re.compile(r"book\s*now|check\s*availability|reserve", re.I)

def check_site(url: str) -&gt; dict:
    result = {"url": url, "ok": False, "booking_engine": "", "book_button_to_ota": "",
              **{k: False for k in TRACKING}}
    try:
        resp = requests.get(url, headers=HEADERS, timeout=15, allow_redirects=True)
        resp.raise_for_status()
    except requests.RequestException as exc:
        result["error"] = type(exc).__name__
        return result

    html = resp.text
    result["ok"] = True
    lower = html.lower()

    # 1. Booking engine: vendor domain anywhere in the page
    for vendor, needles in BOOKING_ENGINES.items():
        if any(n in lower for n in needles):
            result["booking_engine"] = vendor
            break

    # 2. Where do "Book now" style buttons point?
    soup = BeautifulSoup(html, "html.parser")
    for a in soup.find_all("a", href=True):
        if BOOK_WORDS.search(a.get_text(" ", strip=True)):
            target = urljoin(resp.url, a["href"]).lower()
            ota = next((d for d in OTA_DOMAINS if d in target), "")
            if ota:
                result["book_button_to_ota"] = ota
                break

    # 3. Analytics and ad tags
    for name, patterns in TRACKING.items():
        result[name] = any(re.search(p, html) for p in patterns)

    return result
</code></pre>
<p>Run it on one property:</p>
<pre><code class="language-python">print(check_site("https://example-hotel.in"))
# {'url': 'https://example-hotel.in', 'ok': True, 'booking_engine': '',
#  'book_button_to_ota': 'booking.com', 'google_analytics': True,
#  'google_tag_manager': False, 'meta_pixel': False, 'google_ads': False}
</code></pre>
<p>That example output is the profile you are looking for: the site has traffic tracking, but the only way to book is through an OTA, and nothing retargets visitors who leave.</p>
<h2>Step 3: Run it over a list</h2>
<p>Hotel sites are slow, so use a thread pool. Twenty workers is plenty and stays polite.</p>
<pre><code class="language-python">import csv
from concurrent.futures import ThreadPoolExecutor

def audit(in_csv: str, out_csv: str) -&gt; list:
    with open(in_csv, newline="", encoding="utf-8") as f:
        rows = [r for r in csv.DictReader(f) if r.get("website")]

    with ThreadPoolExecutor(max_workers=20) as pool:
        results = list(pool.map(lambda r: {"name": r["name"], **check_site(r["website"])}, rows))

    fields = ["name", "url", "ok", "booking_engine", "book_button_to_ota",
              *TRACKING.keys(), "error"]
    with open(out_csv, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=fields, extrasaction="ignore")
        w.writeheader()
        w.writerows(results)
    return results

results = audit("hotels.csv", "hotels_audit.csv")
</code></pre>
<h2>Step 4: Turn the audit into a call list</h2>
<p>Sort by the gap you sell. For a booking-engine or hotel-website pitch, this order works:</p>
<pre><code class="language-python">def priority(r: dict) -&gt; int:
    if not r["ok"]:
        return 0                      # site down: a different pitch
    score = 0
    if not r["booking_engine"]:
        score += 3                    # no direct booking at all
    if r["book_button_to_ota"]:
        score += 2                    # actively sending guests to an OTA
    if not (r["meta_pixel"] or r["google_ads"]):
        score += 1                    # no retargeting
    return score

results.sort(key=priority, reverse=True)
</code></pre>
<h2>Three gotchas</h2>
<ul>
<li><strong>Tags loaded through Google Tag Manager.</strong> A Meta pixel added inside a GTM container does not appear in the raw HTML. If <code>google_tag_manager</code> is true and <code>meta_pixel</code> is false, record the pixel as "unknown", not "missing".</li>
<li><strong>Chain hotels.</strong> Many chain properties book through the group website, so a property page without an engine is not always a gap. Focus on independents and small groups.</li>
<li><strong>Booking on a subpage.</strong> Some sites only load the engine on a /rooms or /book page. If the homepage shows nothing, follow the first "Book now" link that stays on the same domain and check that page too.</li>
</ul>
<h2>What the output looks like at scale</h2>
<p>I work on Mantis, which runs this kind of check on every website it finds on Google Maps and shows the result as flags such as "No online booking", "No analytics" or "No ad pixel". You can see the result on a public page like the <a href="https://mantisai.in/lists/list-of-hotels-in-india/bangalore">list of hotels in Bangalore</a>: 97 properties, 74 with a website, and per-property flags. If you are turning an audit like this into a sales pitch, <a href="https://mantisai.in/resources/how-to-sell-websites-to-guest-houses">this guide on selling websites to guest houses</a> covers the conversation.</p>
<p>The script above is enough to run the same audit yourself on any list of hotel URLs. If you extend the vendor list, I would like to hear which engines you find most often in your city.</p>
<p><em>Disclosure: I work on <a href="https://mantisai.in/">Mantis</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Rank Local Business Leads in Python: A Simple Demand Score From Review Counts]]></title><description><![CDATA[If you scrape or export local business listings (from Google Maps, a directory, or your own CRM), you quickly end up with hundreds of rows and no idea…]]></description><link>https://mantisleads.hashnode.dev/rank-local-business-leads-in-python-a-simple-demand-score-from-review-counts</link><guid isPermaLink="true">https://mantisleads.hashnode.dev/rank-local-business-leads-in-python-a-simple-demand-score-from-review-counts</guid><dc:creator><![CDATA[Yashraj Verma]]></dc:creator><pubDate>Thu, 05 Nov 2026 19:53:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeb74548eb663165c00a645/91b1d0c0-7c41-4cb3-86b9-2a8c86ffafee.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you scrape or export local business listings (from Google Maps, a directory, or your own CRM), you quickly end up with hundreds of rows and no idea where to start. This tutorial builds a small, transparent scoring function in Python that ranks businesses by likely demand and flags the ones with no website, so the top of your list is where the easy conversations are.</p>
<p>No external services are needed. Everything below runs on the standard library plus <code>csv</code>.</p>
<h2>The input</h2>
<p>Assume a CSV with one row per business:</p>
<pre><code class="language-csv">name,category,city,reviews,rating,website
Amit's Gym,gym,Kolkata,847,4.7,
SAS FITNESS,gym,Kolkata,1403,4.6,
Kross9 Gym Kasba,gym,Kolkata,551,4.8,
Big Chain Fitness,gym,Kolkata,2300,4.2,https://example.com
Corner Studio,gym,Kolkata,12,5.0,
</code></pre>
<p><code>website</code> is empty when the listing has no site. Ratings near 5.0 with very few reviews are common and not very informative, so we want the score to trust review volume more than the raw rating.</p>
<h2>Step 1: a demand score that does not over-reward huge numbers</h2>
<p>Review counts are heavily skewed. A business with 1,400 reviews is not 100 times more interesting than one with 14. A log scale keeps big numbers from dominating:</p>
<pre><code class="language-python">import math

def demand_score(reviews: int, rating: float) -&gt; float:
    """0-100. Log-scaled review volume, lightly adjusted by rating."""
    volume = min(math.log10(reviews + 1) / 3.0, 1.0)   # 1,000 reviews ~ 1.0
    quality = max(min((rating - 3.5) / 1.5, 1.0), 0.0)  # 3.5 -&gt; 0, 5.0 -&gt; 1
    return round(100 * (0.8 * volume + 0.2 * quality), 1)
</code></pre>
<p>Volume carries 80% of the weight because it reflects real footfall. Rating only nudges the result.</p>
<h2>Step 2: flag the gap</h2>
<p>For anyone selling websites or digital services, a business with strong demand and no website is the best lead. Add a gap flag and a combined priority:</p>
<pre><code class="language-python">def priority(row: dict) -&gt; float:
    score = demand_score(int(row["reviews"]), float(row["rating"] or 0))
    has_site = bool(row["website"].strip())
    return score if not has_site else score * 0.3
</code></pre>
<p>Businesses that already have a site drop to the bottom instead of disappearing. They can still be leads for redesigns.</p>
<h2>Step 3: rank and export</h2>
<pre><code class="language-python">import csv

def rank(path_in: str, path_out: str, top: int = 20) -&gt; None:
    with open(path_in, newline="", encoding="utf-8") as f:
        rows = list(csv.DictReader(f))
    for r in rows:
        r["priority"] = priority(r)
        r["gap"] = "no website" if not r["website"].strip() else ""
    rows.sort(key=lambda r: r["priority"], reverse=True)
    with open(path_out, "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows[:top])

rank("listings.csv", "ranked.csv")
</code></pre>
<p>With the sample input, the order comes out as SAS FITNESS, Amit's Gym, Kross9 Gym Kasba, then Corner Studio (few reviews), with Big Chain Fitness last because it already has a website.</p>
<h2>Step 4: sanity-check the weights</h2>
<p>Before trusting any score, eyeball the extremes:</p>
<ul>
<li>Does a 5.0 rating with 3 reviews outrank a 4.5 with 600? It should not.</li>
<li>Does every row in the top 10 have a reason you could say out loud to the owner?</li>
<li>Does the score change sensibly if you double one business's reviews?</li>
</ul>
<p>If any answer is "no", adjust the 0.8 / 0.2 split or the log divisor. Keep the formula simple enough to explain; that is the point.</p>
<h2>What the output looks like in practice</h2>
<p>The same idea, applied to live listings, produces lists like this one of <a href="https://mantisai.in/leads/website-development/in/kolkata/categories/gym?utm_source=hashnode&amp;utm_medium=guest_post&amp;utm_campaign=devangle-lead-scoring">gyms with no website in Kolkata</a>, where review counts range from about 370 to 1,400 per gym. For turning a ranked list into conversations, this <a href="https://mantisai.in/resources/how-to-sell-websites-to-gyms?utm_source=hashnode&amp;utm_medium=guest_post&amp;utm_campaign=devangle-lead-scoring">guide on selling websites to gyms</a> covers the pitch.</p>
<h2>Wrap-up</h2>
<ul>
<li>Log-scale review counts so large businesses do not drown everything else.</li>
<li>Treat rating as a light adjustment, not the main signal.</li>
<li>Down-weight, rather than delete, businesses that already have a website.</li>
<li>Keep the formula explainable.</li>
</ul>
<p><em>Disclosure: I work on <a href="https://mantisai.in/?utm_source=hashnode&amp;utm_medium=guest_post&amp;utm_campaign=devangle-lead-scoring">Mantis</a>, which builds lead lists like the one above.</em></p>
]]></content:encoded></item><item><title><![CDATA[Find Local Businesses With No Website Using the Google Places API (Python, With a Simple Lead Score)]]></title><description><![CDATA[If you freelance as a web developer, the warmest prospects are often businesses that customers already love but that have no website. A dance academy with…]]></description><link>https://mantisleads.hashnode.dev/find-local-businesses-with-no-website-using-the-google-places-api-python-with-a-simple-lead-score</link><guid isPermaLink="true">https://mantisleads.hashnode.dev/find-local-businesses-with-no-website-using-the-google-places-api-python-with-a-simple-lead-score</guid><category><![CDATA[Python]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[api]]></category><dc:creator><![CDATA[Yashraj Verma]]></dc:creator><pubDate>Wed, 04 Nov 2026 19:29:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aeb74548eb663165c00a645/6016d233-7901-461d-b938-3b7f14504c18.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you freelance as a web developer, the warmest prospects are often businesses that customers already love but that have no website. A dance academy with 300 Google reviews and nowhere to show batch timings is an easy conversation.</p>
<p>You can find these businesses yourself with the official Google Places API. This post walks through a small Python script that searches a category in a city, keeps only places with no website, scores them, and writes a CSV you can work through.</p>
<h2>What we're building</h2>
<ul>
<li>Input: a text query like <code>dance academy in Pune</code></li>
<li>Output: <code>leads.csv</code> with name, address, rating, review count, phone and a 0–100 score</li>
<li>Only places where the API returns <strong>no <code>websiteUri</code></strong></li>
</ul>
<h2>Prerequisites</h2>
<ul>
<li>A Google Cloud project with <strong>Places API (New)</strong> enabled and an API key</li>
<li>Python 3.9+ and <code>requests</code> (<code>pip install requests</code>)</li>
<li>Read the <a href="https://developers.google.com/maps/documentation/places/web-service/policies">Places API policies</a> first. You may store place IDs indefinitely, but most other Places content has caching limits, so treat the CSV as a short-lived working list, not a database.</li>
</ul>
<h2>Step 1: Text Search with a field mask</h2>
<p>Places API (New) requires a field mask. You're billed by the fields you request, so ask only for what you need. Fields like <code>websiteUri</code> and <code>rating</code> can move a request into a higher pricing tier, so check the <a href="https://developers.google.com/maps/billing-and-pricing/pricing">pricing page</a> before running large jobs.</p>
<pre><code class="language-python">import csv, os, time, requests

API_KEY = os.environ["PLACES_API_KEY"]
URL = "https://places.googleapis.com/v1/places:searchText"
FIELDS = ",".join([
    "places.id", "places.displayName", "places.formattedAddress",
    "places.rating", "places.userRatingCount", "places.websiteUri",
    "places.nationalPhoneNumber", "places.businessStatus", "nextPageToken",
])

def search(query: str, max_pages: int = 3):
    body, page = {"textQuery": query, "pageSize": 20}, 0
    while page &lt; max_pages:
        r = requests.post(URL, json=body, headers={
            "X-Goog-Api-Key": API_KEY, "X-Goog-FieldMask": FIELDS}, timeout=30)
        r.raise_for_status()
        data = r.json()
        yield from data.get("places", [])
        token = data.get("nextPageToken")
        if not token:
            break
        body["pageToken"] = token   # keep every other parameter identical
        page += 1
        time.sleep(1)
</code></pre>
<p>Text Search returns at most 20 results per page and, per Google's docs, a maximum of 60 across pages. For a whole city, run several narrower queries (by locality) instead of one broad one.</p>
<h2>Step 2: Keep only "no website" places and score them</h2>
<p>There's no official "lead score", so here is a deliberately simple heuristic you can tune. Reviews show real demand, rating shows reputation, and a closed business scores zero.</p>
<pre><code class="language-python">import math

def score(p: dict) -&gt; int:
    if p.get("businessStatus") != "OPERATIONAL":
        return 0
    reviews = p.get("userRatingCount", 0)
    rating = p.get("rating", 0.0)
    demand = min(math.log10(reviews + 1) / 3, 1.0)      # ~1000 reviews -&gt; 1.0
    quality = max(rating - 3.5, 0) / 1.5                # 3.5 -&gt; 0, 5.0 -&gt; 1.0
    return round(100 * (0.6 * demand + 0.4 * quality))

def leads(query: str):
    seen = set()
    for p in search(query):
        if p["id"] in seen or p.get("websiteUri"):
            continue
        seen.add(p["id"])
        yield {
            "name": p["displayName"]["text"],
            "address": p.get("formattedAddress", ""),
            "rating": p.get("rating", ""),
            "reviews": p.get("userRatingCount", 0),
            "phone": p.get("nationalPhoneNumber", ""),
            "score": score(p),
            "place_id": p["id"],
        }
</code></pre>
<p>Dedupe on the place ID, never the name. Chains share names and one shop can be spelled four ways.</p>
<h2>Step 3: Write the CSV, best leads first</h2>
<pre><code class="language-python">if __name__ == "__main__":
    rows = sorted(leads("dance academy in Pune"), key=lambda r: r["score"], reverse=True)
    with open("leads.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader(); w.writerows(rows)
    print(f"{len(rows)} places without a website")
</code></pre>
<p>Run it with <code>PLACES_API_KEY=... python find_leads.py</code>.</p>
<h2>Gotchas worth knowing</h2>
<ul>
<li><strong>"No <code>websiteUri</code>" isn't the same as "no website".</strong> Some owners have a site and never added it to their listing. Spot-check before you pitch, and phrase it as "your Google listing doesn't link to a website".</li>
<li><strong>Facebook or Instagram as the website.</strong> Some listings put a social profile in <code>websiteUri</code>. Decide whether to count those as "no real website" by checking the host.</li>
<li><strong>Data goes stale.</strong> Listings close and owners add sites. Re-run the search rather than trusting an old export.</li>
<li><strong>Stay inside the terms.</strong> Use the official API. Scraping the consumer Maps UI breaks Google's terms.</li>
</ul>
<h2>What this looks like at scale</h2>
<p>Running this per locality and category across many cities, then refreshing it, becomes a data pipeline of its own. For a sense of what the finished output looks like, here's a <a href="https://mantisai.in/leads/website-development/in/pune/categories/sports_school?utm_source=hashnode&amp;utm_medium=guest_post&amp;utm_campaign=devangle-places-no-website">live list of Pune dance and self-defence academies with no website</a> on Mantis. Once you have the list, the hard part is the first conversation; <a href="https://mantisai.in/resources/google-maps-prospecting-for-web-designers?utm_source=hashnode&amp;utm_medium=guest_post&amp;utm_campaign=devangle-places-no-website">this guide to Google Maps prospecting for web designers</a> covers that side.</p>
<hr />
<p><em>I work on <a href="https://mantisai.in/?utm_source=hashnode&amp;utm_medium=guest_post&amp;utm_campaign=devangle-places-no-website">Mantis</a>, a live map search for businesses without websites.</em></p>
]]></content:encoded></item></channel></rss>