Snapshot: full project state

This commit is contained in:
2026-10-06 23:43:48 -07:00
commit 4f56907c45
20 changed files with 1207 additions and 0 deletions

302
app.py Normal file
View File

@@ -0,0 +1,302 @@
#!/usr/bin/env python3
"""
Castor — Signup Dashboard
Check the providers you want to open accounts with, then launch them in a
browser with your identity auto-filled. No retyping your info.
It fills YOUR OWN verified info into forms YOU submit, one account at a time.
"""
import argparse
import json
import os
import re
import subprocess
import sys
from flask import Flask, request, render_template, redirect, url_for, flash
from playwright.sync_api import sync_playwright
BASE = os.path.dirname(os.path.abspath(__file__))
PROFILE_PATH = os.path.join(BASE, "autofill_profile.json")
PROVIDERS_PATH = os.path.join(BASE, "providers.json")
AUTOCOMPLETE_MAP = {
"given-name": "first_name",
"family-name": "last_name",
"name": "full_name",
"email": "email",
"tel": "phone",
"tel-national": "phone",
"tel-local": "phone",
"street-address": "address_line1",
"address-line1": "address_line1",
"address-line2": "address_line2",
"address-level1": "state",
"address-level2": "city",
"postal-code": "zip",
"bday": "dob",
"organization": "employer",
"organization-title": "employer",
}
KEYWORD_MAP = {
"first_name": ["firstname", "givenname", "fname"],
"last_name": ["lastname", "surname", "familyname", "lname"],
"email": ["emailaddress", "email"],
"phone": ["phonenumber", "phonenum", "telephone", "mobile", "cellphone", "phone"],
"address_line1": ["streetaddress", "addressline1", "address1", "mailingaddress", "street"],
"address_line2": ["addressline2", "address2", "suite", "apt", "unit"],
"city": ["city", "town", "municipality"],
"state": ["stateprovince", "province", "state"],
"zip": ["zipcode", "postalcode", "zip", "postal"],
"dob": ["dateofbirth", "birthdate", "birthday", "dob"],
"ssn": ["socialsecuritynumber", "socialsecurity", "ssnnumber", "ssn", "taxpayerid", "taxid", "tin", "nationalid"],
"employer": ["currentemployer", "employername", "employer", "companyname"],
"income": ["annualincome", "incomeamount", "income", "salary"],
}
STATE_NAMES = {
"AL": "Alabama", "AK": "Alaska", "AZ": "Arizona", "AR": "Arkansas", "CA": "California",
"CO": "Colorado", "CT": "Connecticut", "DE": "Delaware", "FL": "Florida", "GA": "Georgia",
"HI": "Hawaii", "ID": "Idaho", "IL": "Illinois", "IN": "Indiana", "IA": "Iowa",
"KS": "Kansas", "KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland",
"MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota", "MS": "Mississippi",
"MO": "Missouri", "MT": "Montana", "NE": "Nebraska", "NV": "Nevada", "NH": "New Hampshire",
"NJ": "New Jersey", "NM": "New Mexico", "NY": "New York", "NC": "North Carolina",
"ND": "North Dakota", "OH": "Ohio", "OK": "Oklahoma", "OR": "Oregon", "PA": "Pennsylvania",
"RI": "Rhode Island", "SC": "South Carolina", "SD": "South Dakota", "TN": "Tennessee",
"TX": "Texas", "UT": "Utah", "VT": "Vermont", "VA": "Virginia", "WA": "Washington",
"WV": "West Virginia", "WI": "Wisconsin", "WY": "Wyoming", "DC": "District of Columbia",
}
PROFILE_FIELDS = [
("first_name", "First name"), ("last_name", "Last name"), ("email", "Email"),
("phone", "Phone"), ("address_line1", "Address line 1"), ("address_line2", "Address line 2"),
("city", "City"), ("state", "State (2-letter)"), ("zip", "ZIP"),
("dob", "Date of birth (MM/DD/YYYY)"), ("ssn", "SSN (no dashes)"),
("employer", "Employer"), ("income", "Annual income"),
]
PASSWORD_LIKE = re.compile(r"password|passwd|passcode|pwd|confirmpassword", re.I)
def norm(s):
return re.sub(r"[^a-z0-9]", "", (s or "").lower())
def load_json(path, default):
if os.path.exists(path):
with open(path) as fh:
return json.load(fh)
return default
def load_profile():
return load_json(PROFILE_PATH, {})
def save_profile(data):
with open(PROFILE_PATH, "w") as fh:
json.dump(data, fh, indent=2)
def load_providers():
return load_json(PROVIDERS_PATH, [])
# ------------------------- Playwright fill engine -------------------------
GATHER_FIELDS_JS = """
() => {
const out = [];
const els = document.querySelectorAll('input, select, textarea');
els.forEach((el, idx) => {
const tag = el.tagName.toLowerCase();
const type = tag === 'input' ? (el.type || 'text').toLowerCase() : tag;
if (['hidden','submit','button','reset','checkbox','radio','file','password'].includes(type)) return;
if (el.offsetParent === null) return;
let label = '';
if (el.id) { const l = document.querySelector('label[for="' + CSS.escape(el.id) + '"]'); if (l) label = l.innerText; }
if (!label && el.closest('label')) label = el.closest('label').innerText;
if (!label && el.parentElement) label = el.parentElement.innerText.split('\\n')[0];
out.push({ idx, tag, type, name: el.name || '', id: el.id || '', ac: el.autocomplete || '',
ph: el.placeholder || '', al: el.getAttribute('aria-label') || '', label: (label || '').trim() });
});
return out;
}
"""
FILL_ONE_JS = """
(arg) => {
const idx = arg.idx; const candidates = arg.candidates;
const els = document.querySelectorAll('input, select, textarea');
const el = els[idx];
if (!el) return 'missing';
el.scrollIntoView({ block: 'center' });
el.style.outline = '3px solid #6c8cff'; el.style.outlineOffset = '1px';
let ok = false;
if (el.tagName === 'SELECT') {
const opts = Array.from(el.options);
outer:
for (const want of candidates) {
const w = String(want).toLowerCase();
for (const o of opts) {
const t = o.text.trim().toLowerCase();
if (t === w || t.includes(w) || w.includes(t) || o.value.toLowerCase() === w) { el.value = o.value; ok = true; break outer; }
}
}
} else {
const proto = el.tagName === 'TEXTAREA' ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, String(candidates[0]));
ok = true;
}
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
setTimeout(() => { el.style.outline = ok ? '2px solid #4ade80' : '2px solid #f87171'; }, 500);
return ok ? 'filled' : 'no-match';
}
"""
def build_sig(f):
return norm(" ".join([f["name"], f["id"], f["ph"], f["al"], f["label"]]))
def match_profile_key(f):
ac = f["ac"].strip().lower()
if ac in AUTOCOMPLETE_MAP:
return AUTOCOMPLETE_MAP[ac]
sig = build_sig(f)
best_key, best_len = None, 0
for key, kws in KEYWORD_MAP.items():
for kw in kws:
if kw in sig and len(kw) > best_len:
best_key, best_len = key, len(kw)
return best_key
def fill_candidates(profile, key):
val = profile.get(key, "")
if not val:
return None
cands = [val]
if key == "state":
cands.append(STATE_NAMES.get(val.upper(), val))
if key == "full_name":
cands = ["%s %s" % (profile.get("first_name", ""), profile.get("last_name", "")).strip()]
return cands
def fill_page(page, profile, delay=0.2):
fields = page.evaluate(GATHER_FIELDS_JS)
results = []
for f in fields:
blob = f["name"] + " " + f["id"] + " " + f["label"] + " " + f["ac"]
if PASSWORD_LIKE.search(blob):
continue
key = match_profile_key(f)
if key is None:
continue
cands = fill_candidates(profile, key)
if not cands:
continue
page.evaluate(FILL_ONE_JS, {"idx": f["idx"], "candidates": cands})
results.append((key, f, cands[0]))
if delay:
page.wait_for_timeout(int(delay * 1000))
return results
def launch_fill(urls, headless=False, delay=0.2):
"""Open each URL in its own tab, auto-fill, leave the browser open. Returns a summary."""
import time
profile = load_profile()
summary = []
with sync_playwright() as p:
browser = p.chromium.launch(headless=headless)
context = browser.new_context()
pages = []
for url in urls:
page = context.new_page()
pages.append(page)
page.goto(url, wait_until="domcontentloaded", timeout=60000)
page.wait_for_timeout(1500)
results = fill_page(page, profile, delay)
summary.append((url, results))
if not headless:
# Keep the browser alive until the user closes it (signups in progress).
while browser.is_connected():
time.sleep(1)
browser.close()
return summary
# ------------------------- Flask app -------------------------
app = Flask(__name__)
app.secret_key = "castor-signup-dashboard-local-only"
@app.route("/")
def index():
providers = load_providers()
profile = load_profile()
filled = sum(1 for k, v in profile.items() if v)
total = len(PROFILE_FIELDS)
categories = []
seen = []
for p in providers:
c = p["category"]
if c not in seen:
seen.append(c)
categories.append(c)
return render_template(
"index.html", providers=providers, categories=categories,
profile=profile, filled=filled, total=total,
)
@app.route("/launch", methods=["POST"])
def launch():
ids = request.form.getlist("providers")
providers = load_providers()
chosen = [p for p in providers if p["id"] in ids]
if not chosen:
flash("Select at least one provider.", "error")
return redirect(url_for("index"))
urls = [p["url"] for p in chosen]
names = [p["name"] for p in chosen]
# Fill a report log to disk, then launch the fill engine in a visible browser.
report = {"launched": names, "urls": urls}
with open(os.path.join(BASE, "last_launch.json"), "w") as fh:
json.dump(report, fh, indent=2)
# Launch in a subprocess so the visible browser opens on this machine.
subprocess.Popen(
[sys.executable, os.path.join(BASE, "fill_worker.py"), "--urls", json.dumps(urls)],
cwd=BASE, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
flash("Opened %d signup page(s) — completing auto-fill in the browser now." % len(chosen), "ok")
return redirect(url_for("index"))
@app.route("/profile", methods=["GET", "POST"])
def profile_route():
if request.method == "POST":
data = {}
for key, _label in PROFILE_FIELDS:
data[key] = request.form.get(key, "").strip()
save_profile(data)
flash("Profile saved.", "ok")
return redirect(url_for("profile_route"))
return render_template("profile.html", fields=PROFILE_FIELDS, profile=load_profile())
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=5057)
args = ap.parse_args()
app.run(host=args.host, port=args.port, debug=False)