{"id":"5ykNveyFWw","url":"https://pastebin.ca/5ykNveyFWw","raw_url":"https://raw.anybin.ca/5ykNveyFWw","visibility":"public","access":"public","created_at":1780197945352,"expires_at":1780802745352,"fetch_limit":null,"fetches_used":0,"reads_remaining":null,"size_bytes":24422,"syntax_hint":null,"title":null,"filename":null,"change_note":null,"cipher":null,"cipher_meta":null,"parent_id":null,"root_id":"5ykNveyFWw","version":1,"owner_id":null,"recipient_id":null,"body":"\"\"\"\nKnox County TN Jail — Auto Monitor\n====================================\nAutomatically checks the Knox County jail roster every 10-15 minutes\nfor new inmates. When a new inmate is found, their info is saved to\nthe output folder as a ready-to-post Facebook text file + mugshot image.\n\nFACEBOOK API SUPPORT:\n  Set FACEBOOK_ENABLED = True and fill in your page token and page ID\n  to automatically post to Facebook when a new inmate is detected.\n  Instructions for getting a token are in the README comments below.\n\nHOW TO RUN:\n  python knox_monitor.py\n  (Leave the terminal open — it will keep running until you close it)\n\nHOW TO STOP:\n  Press Ctrl+C in the terminal\n\"\"\"\n\nimport time\nimport random\nimport json\nimport os\nimport requests\nimport traceback\nfrom datetime import datetime\nfrom dataclasses import dataclass, field\nfrom typing import Optional\nfrom bs4 import BeautifulSoup\n\n# ─────────────────────────────────────────────\n#  CONFIGURATION\n# ─────────────────────────────────────────────\n\nREAL_SITE_URL = \"https://sheriff.knoxcountytn.gov\"\nROSTER_PATH   = \"/index.php\"\n\n# How often to check for new inmates (in seconds)\nMIN_CHECK_INTERVAL = 10 * 60   # 10 minutes\nMAX_CHECK_INTERVAL = 15 * 60   # 15 minutes\n\n# Delay between image downloads\nMIN_DELAY_SECONDS = 3\nMAX_DELAY_SECONDS = 7\n\n# ── How long to wait between Facebook posts ───\n# Change these two numbers to control posting speed\n# Examples:\n#   2-3 minutes:  MIN_POST_INTERVAL = 2 * 60 / MAX_POST_INTERVAL = 3 * 60\n#   25-30 minutes: MIN_POST_INTERVAL = 25 * 60 / MAX_POST_INTERVAL = 30 * 60\nMIN_POST_INTERVAL = 2 * 60    # 2 minutes  ← change this\nMAX_POST_INTERVAL = 3 * 60    # 3 minutes  ← change this\n\n# Where to save output files\nOUTPUT_DIR = r\"C:\\Knox County\"\nSEEN_FILE  = os.path.join(OUTPUT_DIR, \"seen_inmates.json\")\n\n# Your Facebook page name (used in posts)\nFACEBOOK_PAGE_NAME = \"Knox County Mugshots\"\n\n# ── Facebook API (optional) ───────────────────\n# Set FACEBOOK_ENABLED = True to auto-post when new inmates are found\n# To get your token and page ID:\n#   1. Go to https://developers.facebook.com and create an app\n#   2. Add the \"Pages API\" product\n#   3. Generate a Page Access Token for your mugshots page\n#   4. Find your Page ID in your Facebook page settings → About\n\nFACEBOOK_ENABLED  = True\nFACEBOOK_TOKEN    = \"EAAVsKezZBGQ8BRtZBR5Pg7i2yEh1m8ZBOIknV5LcwHSqbffFTKTk8tqiM8MsWnbGCttCjCPQ3nKVP9nu4dyIwVu5HjZCIKquT3ankwBeuizXZA3fiM5GJnIf99mac7Qd8Sty958qcqW1l3KoZArRYmsyTPIZAszLWaD8F1BMY8xWgdWVdxqOOvPtcYnBImO8b2qA20ZA\"\nFACEBOOK_PAGE_ID  = \"1057858950737975\"\n\n# ── Discord Webhook (optional) ────────────────\n# Set DISCORD_ENABLED = True to receive error alerts in Discord\n# To get your webhook URL:\n#   1. Open Discord and go to the channel you want alerts in\n#   2. Click the gear icon (Edit Channel) → Integrations → Webhooks\n#   3. Click \"New Webhook\", give it a name, copy the URL and paste below\n\nDISCORD_ENABLED     = True\nDISCORD_WEBHOOK_URL = \"https://discord.com/api/webhooks/1484760889457643632/Z4cBmdR8_urJVbwWLkkVe3yFmkHcildddpP0XU-RpAFnGJH_cccq5foMK11saAaeuI6j\"\n\n# Disclaimer added to every post\nDISCLAIMER = (\n    \"All persons are presumed innocent until proven guilty in a court of law. \"\n    \"Booking information is a matter of public record.\"\n)\n\n\n# ─────────────────────────────────────────────\n#  DISCORD ALERTS\n# ─────────────────────────────────────────────\n\ndef discord_alert(message: str):\n    \"\"\"Send an alert message to a Discord channel via webhook.\"\"\"\n    if not DISCORD_ENABLED:\n        return\n    try:\n        response = requests.post(DISCORD_WEBHOOK_URL, json={\n            \"content\": f\"⚠️ **Knox County Monitor Alert**\\n{message}\",\n            \"username\": \"Knox Monitor\"\n        })\n        if response.status_code in (200, 204):\n            print(f\"  Discord alert sent\")\n        else:\n            print(f\"  Discord alert failed ({response.status_code}): {response.text}\")\n    except Exception as e:\n        print(f\"  Discord alert error: {e}\")\n\n# ─────────────────────────────────────────────\n#  DATA MODEL\n# ─────────────────────────────────────────────\n\n@dataclass\nclass ArrestRecord:\n    name: str\n    dob: Optional[str] = None\n    booking_number: Optional[str] = None\n    booking_date: Optional[str] = None\n    bond: Optional[str] = None\n    bonds: list[str] = field(default_factory=list)\n    charges: list[str] = field(default_factory=list)\n    arresting_agency: Optional[str] = None\n    image_url: Optional[str] = None\n    image_data: Optional[bytes] = None\n    source_url: Optional[str] = None\n\n\n# ─────────────────────────────────────────────\n#  SEEN INMATES TRACKER\n# Saves known IDN#s to a file so memory persists across restarts\n# ─────────────────────────────────────────────\n\ndef load_seen_inmates() -> set:\n    \"\"\"Load the set of already-seen IDN numbers from disk.\"\"\"\n    os.makedirs(OUTPUT_DIR, exist_ok=True)\n    if not os.path.exists(SEEN_FILE):\n        return set()\n    try:\n        with open(SEEN_FILE, \"r\") as f:\n            data = json.load(f)\n            return set(data)\n    except Exception:\n        return set()\n\ndef save_seen_inmates(seen: set):\n    \"\"\"Save the set of seen IDN numbers to disk.\"\"\"\n    os.makedirs(OUTPUT_DIR, exist_ok=True)\n    with open(SEEN_FILE, \"w\") as f:\n        json.dump(list(seen), f, indent=2)\n\n\n# ─────────────────────────────────────────────\n#  SCRAPER\n# ─────────────────────────────────────────────\n\nclass KnoxCountyScraper:\n\n    def __init__(self):\n        self.session = requests.Session()\n        self.session.headers.update({\n            \"User-Agent\": (\n                \"MugshotPageBot/1.0 (public records aggregator; \"\n                \"contact: your@email.com)\"\n            )\n        })\n\n    def polite_wait(self):\n        delay = random.uniform(MIN_DELAY_SECONDS, MAX_DELAY_SECONDS)\n        print(f\"  Waiting {delay:.1f}s...\")\n        time.sleep(delay)\n\n    def fetch_page(self, url: str) -> BeautifulSoup:\n        print(f\"  Fetching: {url}\")\n        response = self.session.get(url, timeout=15)\n        response.raise_for_status()\n        return BeautifulSoup(response.text, \"html.parser\")\n\n    def parse_roster(self, soup: BeautifulSoup) -> list[ArrestRecord]:\n        \"\"\"Parse all inmates from the roster page.\"\"\"\n        records = []\n        all_tables = soup.find_all(\"table\")\n\n        # Find the starting table index for each inmate\n        inmate_start_indices = []\n        for idx, table in enumerate(all_tables):\n            name_span = table.find(\"span\", class_=\"redbold\")\n            if name_span:\n                name_text = name_span.get_text(strip=True)\n                if name_text and len(name_text) > 3 and \"IDN\" not in name_text:\n                    inmate_start_indices.append(idx)\n\n        for pos, start_idx in enumerate(inmate_start_indices):\n            end_idx = inmate_start_indices[pos + 1] if pos + 1 < len(inmate_start_indices) else len(all_tables)\n            inmate_tables = all_tables[start_idx:end_idx]\n\n            try:\n                # Name + DOB\n                t1 = inmate_tables[0]\n                name_span = t1.find(\"span\", class_=\"redbold\")\n                name = name_span.get_text(strip=True) if name_span else None\n                if not name:\n                    continue\n\n                dob = None\n                for td in t1.find_all(\"td\"):\n                    text = td.get_text(strip=True)\n                    if \"D.O.B.\" in text:\n                        dob = text.replace(\"D.O.B.\", \"\").strip()\n                        break\n\n                # Mugshot image\n                image_url = None\n                prev_img = t1.find_previous(\"img\")\n                if prev_img and prev_img.get(\"src\") and \"showfile\" in prev_img[\"src\"]:\n                    src = prev_img[\"src\"]\n                    image_url = src if src.startswith(\"http\") else REAL_SITE_URL + \"/\" + src.lstrip(\"/\")\n\n                # IDN#\n                booking_number = None\n                for t in inmate_tables:\n                    for td in t.find_all(\"td\"):\n                        text = td.get_text(strip=True)\n                        if \"IDN#\" in text:\n                            booking_number = text.replace(\"IDN#:\", \"\").strip()\n                            break\n                    if booking_number:\n                        break\n\n                # Charges + Bond — collect all charge/bond pairs\n                charges = []\n                bonds = []       # list of bond strings, one per charge row\n                booking_date = None\n\n                for t in inmate_tables:\n                    if \"Booked/Served\" not in t.get_text():\n                        continue\n\n                    rows = t.find_all(\"tr\")\n                    data_rows = [r for r in rows if not r.find(\"th\")]\n\n                    idx = 0\n                    while idx < len(data_rows):\n                        charge_row = data_rows[idx]\n                        bond_row   = data_rows[idx + 1] if idx + 1 < len(data_rows) else None\n                        idx += 2\n\n                        # ── Parse charge row ──────────────────────────────────\n                        tds = charge_row.find_all(\"td\")\n                        if not tds or len(tds) < 3:\n                            continue\n\n                        date_text   = tds[1].get_text(strip=True)\n                        charge_text = tds[2].get_text(strip=True)\n                        doc_type    = tds[0].get_text(strip=True)\n\n                        skip = (\"\\xa0\", \"Charge\", \"\", \"Court Date\", \"Document Type\")\n\n                        # Some rows pack multiple charges into one cell separated by commas\n                        if charge_text and charge_text not in skip:\n                            raw_charges = [c.strip() for c in charge_text.split(\",\") if c.strip()]\n                        elif charge_text.upper().startswith(\"EXPIRES\"):\n                            raw_charges = [f\"{doc_type} - {charge_text}\"]\n                        else:\n                            raw_charges = []\n\n                        for c in raw_charges:\n                            if c not in charges:\n                                charges.append(c)\n\n                        if date_text and not booking_date and date_text not in (\"Booked/Served\", \"\\xa0\", \"\"):\n                            booking_date = date_text\n\n                        # ── Parse bond row ────────────────────────────────────\n                        if not bond_row:\n                            continue\n\n                        bond_tags = bond_row.find_all(\"strong\", class_=\"bond\", recursive=False)\n                        if not bond_tags:\n                            bond_tags = []\n                            for td in bond_row.find_all(\"td\", recursive=False):\n                                bond_tags.extend(td.find_all(\"strong\", class_=\"bond\", recursive=False))\n                        if not bond_tags:\n                            # Not a bond row — back up and reprocess\n                            idx -= 1\n                            continue\n\n                        bond_type   = None\n                        bond_amount = None\n                        for tag in bond_tags:\n                            label = tag.get_text(strip=True)\n                            value = tag.next_sibling\n                            while value and isinstance(value, str) and not value.strip():\n                                value = value.next_sibling\n                            value = value.strip() if isinstance(value, str) else (value.get_text(strip=True) if value else \"\")\n                            if \"Bond Amount\" in label and value and value.upper() not in (\"NONE\", \"DENIED\"):\n                                bond_amount = value\n                            if \"Bond Type\" in label and value and value.upper() not in (\"NONE\", \"DENIED\"):\n                                bond_type = value\n\n                        if bond_amount:\n                            bond_str = f\"{bond_type + ' - ' if bond_type else ''}{bond_amount}\"\n                        elif bond_type and bond_type.upper() == \"DENIED\":\n                            bond_str = \"DENIED\"\n                        else:\n                            bond_str = \"None\"\n\n                        # Pair bond with all charges from this charge row\n                        for c in raw_charges:\n                            bonds.append(f\"{c}: {bond_str}\")\n\n                    break\n\n                records.append(ArrestRecord(\n                    name=name,\n                    dob=dob,\n                    booking_number=booking_number,\n                    booking_date=booking_date,\n                    bonds=bonds,\n                    charges=charges,\n                    arresting_agency=\"Knox County Sheriff's Office\",\n                    image_url=image_url,\n                    source_url=REAL_SITE_URL + ROSTER_PATH,\n                ))\n\n            except Exception as e:\n                print(f\"  ERROR parsing inmate at table {start_idx}: {e}\")\n                traceback.print_exc()\n\n        return records\n\n    def download_image(self, image_url: str) -> Optional[bytes]:\n        try:\n            self.polite_wait()\n            resp = self.session.get(image_url, timeout=15)\n            resp.raise_for_status()\n            return resp.content\n        except Exception as e:\n            print(f\"  Image download failed: {e}\")\n            return None\n\n    def is_refresh_page(self, soup: BeautifulSoup) -> bool:\n        \"\"\"Detect the 'page refreshing, please check back soon' holding page.\"\"\"\n        page_text = soup.get_text().lower()\n        refresh_phrases = [\n            \"please check back soon\",\n            \"page refreshing\",\n            \"temporarily unavailable\",\n            \"updating records\",\n            \"system is updating\",\n        ]\n        return any(phrase in page_text for phrase in refresh_phrases)\n\n    def check_for_new_inmates(self, seen: set) -> list[ArrestRecord]:\n        \"\"\"Fetch the roster and return only inmates not in the seen set.\"\"\"\n        roster_url = REAL_SITE_URL.rstrip(\"/\") + ROSTER_PATH\n        soup = self.fetch_page(roster_url)\n\n        # If the site is refreshing, wait and retry up to 3 times\n        if self.is_refresh_page(soup):\n            print(\"  Site is refreshing — will retry in 60 seconds...\")\n            for attempt in range(1, 4):\n                time.sleep(60)\n                print(f\"  Retry attempt {attempt}/3...\")\n                soup = self.fetch_page(roster_url)\n                if not self.is_refresh_page(soup):\n                    print(\"  Site is back up, continuing...\")\n                    break\n            else:\n                # All retries failed — skip this check entirely\n                print(\"  Site still refreshing after 3 attempts — skipping this check\")\n                return []\n\n        all_records = self.parse_roster(soup)\n\n        new_records = []\n        for record in all_records:\n            if record.booking_number and record.booking_number not in seen:\n                new_records.append(record)\n\n        return new_records\n\n\n# ─────────────────────────────────────────────\n#  FACEBOOK POSTER (optional)\n# ─────────────────────────────────────────────\n\nclass FacebookPoster:\n\n    def post(self, record: ArrestRecord, post_text: str):\n        \"\"\"Post text + image to Facebook page via the Graph API.\"\"\"\n        if not FACEBOOK_ENABLED:\n            return\n\n        try:\n            if record.image_data:\n                # Post with photo\n                url = f\"https://graph.facebook.com/{FACEBOOK_PAGE_ID}/photos\"\n                response = requests.post(url, data={\n                    \"caption\": post_text,\n                    \"access_token\": FACEBOOK_TOKEN,\n                }, files={\n                    \"source\": (\"mugshot.jpg\", record.image_data, \"image/jpeg\")\n                })\n            else:\n                # Text-only post\n                url = f\"https://graph.facebook.com/{FACEBOOK_PAGE_ID}/feed\"\n                response = requests.post(url, data={\n                    \"message\": post_text,\n                    \"access_token\": FACEBOOK_TOKEN,\n                })\n\n            print(f\"  Facebook response ({response.status_code}): {response.text}\")\n\n            if response.status_code == 200:\n                print(f\"  Posted to Facebook: {record.name}\")\n            else:\n                error_msg = (\n                    f\"Failed to post **{record.name}** (IDN: {record.booking_number})\\n\"\n                    f\"Status: {response.status_code}\\n\"\n                    f\"Error: {response.text}\"\n                )\n                print(f\"  Facebook post FAILED ({response.status_code}): {response.text}\")\n                discord_alert(error_msg)\n\n        except Exception as e:\n            print(f\"  Facebook post error: {e}\")\n            traceback.print_exc()\n            discord_alert(f\"Exception while posting **{record.name}**: {e}\")\n\n\n# ─────────────────────────────────────────────\n#  POST FORMATTER\n# ─────────────────────────────────────────────\n\ndef format_name(raw: str) -> str:\n    \"\"\"\n    Convert 'LAST, FIRST MIDDLE' to 'First Last' or 'First Middle Last'.\n    Handles names with hyphens and apostrophes.\n    \"\"\"\n    raw = raw.strip()\n    if \",\" in raw:\n        parts = raw.split(\",\", 1)\n        last = parts[0].strip().title()\n        first_middle = parts[1].strip().title()\n        return f\"{first_middle} {last}\"\n    # No comma — just title case as-is\n    return raw.title()\n\n\ndef format_post(record: ArrestRecord) -> str:\n    # Format name from LAST, FIRST MIDDLE → First Middle Last\n    display_name = format_name(record.name)\n\n    # Charges\n    charge_list = \"\\n\".join(record.charges) if record.charges else \"Not listed\"\n\n    # Hashtags\n    # Agency tag — strip spaces and special chars\n    agency = record.arresting_agency or \"\"\n    agency_tag = \"#\" + \"\".join(c for c in agency if c.isalnum()) if agency else \"\"\n\n    # Name tag — First Last, no middle, no punctuation\n    name_parts = display_name.split()\n    if len(name_parts) >= 2:\n        name_tag = \"#\" + name_parts[0] + name_parts[-1]\n    else:\n        name_tag = \"#\" + display_name.replace(\" \", \"\")\n\n    # ICE tag — add if charges mention immigration/ICE\n    ice_keywords = [\"immigration\", \"ice\", \"ice detainer\", \"hold for ice\",\n                    \"immigration detainee\", \"hold for immigration\"]\n    charges_lower = \" \".join(record.charges).lower()\n    ice_tag = \"\\n#ICE\" if any(k in charges_lower for k in ice_keywords) else \"\"\n\n    return (\n        f\"{display_name}\\n\"\n        f\"\\n\"\n        f\"Date Booked: {record.booking_date or 'Unknown'}\\n\"\n        f\"\\n\"\n        f\"Charges:\\n\"\n        f\"{charge_list}\\n\"\n        f\"\\n\"\n        f\"Arresting Agency: {agency or 'Unknown'}\\n\"\n        f\"\\n\"\n        f\"{agency_tag}{ice_tag}\\n\"\n        f\"{name_tag}\\n\"\n        f\"#knoxcountymugshots\\n\"\n        f\"#knoxvillemugshots\\n\"\n        f\"#mugshots\"\n    )\n\ndef save_record(record: ArrestRecord, index: int):\n    \"\"\"Save text post and image to the output folder.\"\"\"\n    os.makedirs(OUTPUT_DIR, exist_ok=True)\n    safe_name = \"\".join(c if c.isalnum() or c in \" _-\" else \"_\" for c in record.name)\n    base = f\"{index:03d}_{safe_name}\"\n\n    # Save text\n    text_path = os.path.join(OUTPUT_DIR, f\"{base}.txt\")\n    with open(text_path, \"w\", encoding=\"utf-8\") as f:\n        f.write(format_post(record))\n    print(f\"  Saved: {text_path}\")\n\n    # Save image\n    if record.image_data:\n        img_path = os.path.join(OUTPUT_DIR, f\"{base}.jpg\")\n        with open(img_path, \"wb\") as f:\n            f.write(record.image_data)\n        print(f\"  Image saved: {img_path}\")\n\n\n# ─────────────────────────────────────────────\n#  MAIN MONITOR LOOP\n# ─────────────────────────────────────────────\n\ndef main():\n    print(\"=\" * 55)\n    print(\"  Knox County TN Jail — Auto Monitor\")\n    print(f\"  Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\")\n    print(f\"  Checking every {MIN_CHECK_INTERVAL//60}–{MAX_CHECK_INTERVAL//60} minutes\")\n    print(f\"  Output: {os.path.abspath(OUTPUT_DIR)}\")\n    print(f\"  Facebook: {'ENABLED' if FACEBOOK_ENABLED else 'disabled'}\")\n    print(\"  Press Ctrl+C to stop\")\n    print(\"=\" * 55)\n\n    scraper = KnoxCountyScraper()\n    poster  = FacebookPoster()\n\n    # Load previously seen inmates\n    seen = load_seen_inmates()\n    print(f\"\\nLoaded {len(seen)} previously seen inmates from disk\")\n\n    check_count = 0\n\n    while True:\n        check_count += 1\n        now = datetime.now().strftime(\"%Y-%m-%d %H:%M:%S\")\n        print(f\"\\n[Check #{check_count} at {now}]\")\n\n        try:\n            new_records = scraper.check_for_new_inmates(seen)\n\n            if not new_records:\n                print(f\"  No new inmates found ({len(seen)} total seen so far)\")\n            else:\n                print(f\"  {len(new_records)} NEW inmate(s) found!\")\n\n                for i, record in enumerate(new_records, start=1):\n                    print(f\"\\n  >> NEW: {record.name} (IDN: {record.booking_number})\")\n\n                    # Download image\n                    if record.image_url:\n                        record.image_data = scraper.download_image(record.image_url)\n\n                    # Save to disk\n                    save_record(record, i)\n\n                    # Post to Facebook (if enabled)\n                    if FACEBOOK_ENABLED:\n                        poster.post(record, format_post(record))\n                        last_posted = datetime.now()\n                        print(f\"  Last posted at: {last_posted.strftime('%Y-%m-%d %H:%M:%S')}\")\n                        # Wait between posts — controlled by MIN/MAX_POST_INTERVAL in config\n                        if i < len(new_records):\n                            wait = random.randint(MIN_POST_INTERVAL, MAX_POST_INTERVAL)\n                            next_post = datetime.fromtimestamp(time.time() + wait).strftime(\"%H:%M:%S\")\n                            print(f\"  Next post at {next_post} (in {wait//60}m {wait%60}s)...\")\n                            time.sleep(wait)\n\n                    # Mark as seen\n                    if record.booking_number:\n                        seen.add(record.booking_number)\n\n                # Save updated seen list\n                save_seen_inmates(seen)\n\n        except Exception as e:\n            print(f\"  ERROR during check: {e}\")\n            traceback.print_exc()\n            print(\"  Will try again next interval...\")\n\n        # Wait before next check\n        wait = random.randint(MIN_CHECK_INTERVAL, MAX_CHECK_INTERVAL)\n        next_check = datetime.fromtimestamp(time.time() + wait).strftime(\"%H:%M:%S\")\n        print(f\"\\n  Next check at {next_check} (in {wait//60}m {wait%60}s)...\")\n        time.sleep(wait)\n\n\nif __name__ == \"__main__\":\n    try:\n        main()\n    except KeyboardInterrupt:\n        print(\"\\n\\nMonitor stopped by user. Goodbye!\")"}