#!/usr/bin/env python3 """Generic directory harvester for the Quebec store discovery pipeline. For each configured source: collect entity-page URLs (from sitemaps or listing pages), fetch each entity page (with on-disk cache), and extract structured signals: business name, outbound website links, social links, phone, postal code, city/region mentions, categories. Output: data/raw/.jsonl (one record per entity page) """ import concurrent.futures as cf import hashlib import json import os import re import sys import time from urllib.parse import urlparse, urljoin import requests from bs4 import BeautifulSoup ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CACHE = os.path.join(ROOT, "data", "cache") RAW = os.path.join(ROOT, "data", "raw") os.makedirs(CACHE, exist_ok=True) os.makedirs(RAW, exist_ok=True) HDRS = {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36", "Accept-Language": "fr-CA,fr;q=0.9,en;q=0.8"} SOCIAL_HOSTS = ("facebook.com", "instagram.com", "twitter.com", "x.com", "linkedin.com", "youtube.com", "tiktok.com", "pinterest.") JUNK_HOSTS = ("google.", "goo.gl", "maps.app", "apple.com", "wp.me", "bit.ly", "mailchi", "eepurl", "linktr.ee", "youtu.be", "vimeo.com", "flickr.com", "issuu.com", "addtoany", "sharethis", "twitter.com", "cloudfront", "list-manage", "doubleclick", "shopify.com", "squarespace.com", "wordpress.org", "wix.com") POSTAL_RE = re.compile(r"\b([GHJ]\d[A-Z])\s?\d[A-Z]\d\b") PHONE_RE = re.compile(r"\(?\b(418|514|450|819|579|581|438|873|367|263|354)\)?[\s.\-]?\d{3}[\s.\-]?\d{4}\b") REGIONS = ["Bas-Saint-Laurent", "Saguenay", "Lac-Saint-Jean", "Capitale-Nationale", "Mauricie", "Estrie", "Montréal", "Montreal", "Outaouais", "Abitibi", "Témiscamingue", "Côte-Nord", "Nord-du-Québec", "Gaspésie", "Îles-de-la-Madeleine", "Chaudière-Appalaches", "Laval", "Lanaudière", "Laurentides", "Montérégie", "Centre-du-Québec", "Cantons-de-l'Est"] def cache_path(url): h = hashlib.sha1(url.encode()).hexdigest() return os.path.join(CACHE, h[:2], h + ".html") def fetch(url, delay=0.0, timeout=25): p = cache_path(url) if os.path.exists(p): with open(p, encoding="utf-8", errors="replace") as f: return f.read() try: r = requests.get(url, headers=HDRS, timeout=timeout) if r.status_code != 200: return "" os.makedirs(os.path.dirname(p), exist_ok=True) with open(p, "w", encoding="utf-8") as f: f.write(r.text) if delay: time.sleep(delay) return r.text except Exception: return "" def sitemap_urls(root, depth=0): xml = fetch(root) locs = re.findall(r"\s*(.*?)\s*", xml) if "