303 lines
11 KiB
Python
303 lines
11 KiB
Python
#!/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)
|