Snapshot: full project state
This commit is contained in:
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.env
|
||||||
|
*.db
|
||||||
|
*.sqlite*
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
|
out/
|
||||||
|
work/
|
||||||
|
.pio/
|
||||||
|
briefs/
|
||||||
|
dns-backup/
|
||||||
|
archive/
|
||||||
|
*.png
|
||||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Castor — Signup Dashboard
|
||||||
|
|
||||||
|
Check the providers you want to open accounts with, hit **Launch & autofill**, and each
|
||||||
|
signup page opens in its own browser tab with your identity already filled. You finish the
|
||||||
|
CAPTCHA / SMS verification and submit. No retyping your info.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
- **Provider catalog** (`providers.json`) — 22 financial providers across checking, savings,
|
||||||
|
brokerage, crypto, credit card, and payment apps, each with its direct signup link.
|
||||||
|
- **Checkbox selection** — pick the ones you want, launch them all at once.
|
||||||
|
- **Live autofill** — Playwright scans each form, matches fields (name, email, phone, address,
|
||||||
|
city, state, ZIP, DOB, SSN, employer, income) to your profile, and fills them with a highlight
|
||||||
|
flash while you watch. Password fields are never touched.
|
||||||
|
- **Profile** — your real info (`autofill_profile.json`), stored locally, used only to fill
|
||||||
|
*your own* forms.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
- `app.py` — Flask dashboard + the Playwright fill engine.
|
||||||
|
- `fill_worker.py` — subprocess entry the dashboard spawns to open the visible browser.
|
||||||
|
- `providers.json` — the provider catalog (name, category, signup URL, note).
|
||||||
|
- `autofill_profile.json` — your identity (fill in the blanks; SSN/DOB/address left empty for you).
|
||||||
|
- `test_form.html` — local test form for verifying the fill engine headless.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
```bash
|
||||||
|
cd ~/account-onboarding
|
||||||
|
/usr/bin/python3 app.py --host 0.0.0.0 --port 5057
|
||||||
|
# open http://10.30.20.69:5057
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Use `/usr/bin/python3` explicitly.** In background/launched shells `python3` resolves to
|
||||||
|
> Homebrew Python 3.14, which does NOT have playwright installed. System Python 3.9
|
||||||
|
> (`/usr/bin/python3`) has both `playwright` and `flask` in its user-site.
|
||||||
|
|
||||||
|
## Test the fill engine without a browser window
|
||||||
|
```bash
|
||||||
|
/usr/bin/python3 fill_worker.py --urls '["file:///Users/drjones/account-onboarding/test_form.html"]' --headless
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
- Single identity: one person, their real verified info, their own accounts.
|
||||||
|
- No identity fabrication, no bulk account creation, no KYC evasion.
|
||||||
|
- Fill-only by default; you review and submit. Real institutions still require CAPTCHA /
|
||||||
|
SMS / email verification, which is yours to complete.
|
||||||
302
app.py
Normal file
302
app.py
Normal 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)
|
||||||
290
autofill.py
Normal file
290
autofill.py
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
autofill.py — real-time account signup form filler.
|
||||||
|
|
||||||
|
Opens a signup page, scans every visible form field, matches it against your
|
||||||
|
identity profile, and fills each matched field live (highlighted, one at a
|
||||||
|
time) while you watch. It NEVER auto-submits by default — you review and hit
|
||||||
|
submit yourself.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 autofill.py --url https://example.com/signup
|
||||||
|
python3 autofill.py --url https://example.com/signup --headless --dump # test mode
|
||||||
|
python3 autofill.py --url https://example.com/signup --submit # fill + click submit
|
||||||
|
|
||||||
|
Profile: autofill_profile.json (your real info; you fill in the blanks).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
PROFILE_PATH = os.path.join(BASE, "autofill_profile.json")
|
||||||
|
|
||||||
|
# Standardized HTML autocomplete attribute -> profile key (highest confidence).
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback keyword -> profile key, matched against name/id/label/placeholder.
|
||||||
|
# Deliberately avoids greedy tokens like bare "address", "first", "last".
|
||||||
|
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"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# US state abbreviation -> full name, for matching <select> options.
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
|
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_profile():
|
||||||
|
with open(PROFILE_PATH) as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
# JS: enumerate fillable fields (skip password/hidden/submit/etc).
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# JS: fill one field. `candidates` = ordered values to try for <select>.
|
||||||
|
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;
|
||||||
|
const setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
||||||
|
setter.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';
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
READ_BACK_JS = """
|
||||||
|
(arg) => {
|
||||||
|
const els = document.querySelectorAll('input, select, textarea');
|
||||||
|
const el = els[arg.idx];
|
||||||
|
return el ? el.value : null;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_sig(f):
|
||||||
|
return norm(" ".join([f["name"], f["id"], f["ph"], f["al"], f["label"]]))
|
||||||
|
|
||||||
|
|
||||||
|
def match_profile_key(f):
|
||||||
|
# 1) autocomplete attribute is authoritative.
|
||||||
|
ac = f["ac"].strip().lower()
|
||||||
|
if ac in AUTOCOMPLETE_MAP:
|
||||||
|
return AUTOCOMPLETE_MAP[ac]
|
||||||
|
# 2) fall back to keyword matching on name/id/label/placeholder.
|
||||||
|
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:
|
||||||
|
if len(kw) > best_len:
|
||||||
|
best_key, best_len = key, len(kw)
|
||||||
|
return best_key
|
||||||
|
|
||||||
|
|
||||||
|
def fill_candidates(profile, key, f):
|
||||||
|
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":
|
||||||
|
first = profile.get("first_name", "")
|
||||||
|
last = profile.get("last_name", "")
|
||||||
|
cands = ["%s %s" % (first, last).strip()]
|
||||||
|
return cands
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--url", required=True)
|
||||||
|
ap.add_argument("--headless", action="store_true")
|
||||||
|
ap.add_argument("--dump", action="store_true", help="fill then print what was set (test mode)")
|
||||||
|
ap.add_argument("--submit", action="store_true", help="click the first submit button after filling")
|
||||||
|
ap.add_argument("--delay", type=float, default=0.35, help="seconds between field fills")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
profile = load_profile()
|
||||||
|
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=args.headless)
|
||||||
|
page = browser.new_page()
|
||||||
|
page.goto(args.url, wait_until="domcontentloaded", timeout=60000)
|
||||||
|
page.wait_for_timeout(1500)
|
||||||
|
|
||||||
|
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, f)
|
||||||
|
if not cands:
|
||||||
|
continue
|
||||||
|
page.evaluate(FILL_ONE_JS, {"idx": f["idx"], "candidates": cands})
|
||||||
|
results.append((key, f, cands[0]))
|
||||||
|
if args.delay:
|
||||||
|
page.wait_for_timeout(int(args.delay * 1000))
|
||||||
|
|
||||||
|
print("\n=== FILLED %d FIELDS ===" % len(results))
|
||||||
|
for key, f, val in results:
|
||||||
|
shown = "***-**-****" if key == "ssn" else val
|
||||||
|
tag = f["label"] or f["name"] or f["id"] or f["ph"]
|
||||||
|
print(f" {key:<14} <- {shown!r:>24} [{tag}]")
|
||||||
|
|
||||||
|
if args.dump:
|
||||||
|
print("\n=== READ-BACK VERIFY ===")
|
||||||
|
all_ok = True
|
||||||
|
for key, f, val in results:
|
||||||
|
actual = page.evaluate(READ_BACK_JS, {"idx": f["idx"]})
|
||||||
|
ok = actual == val
|
||||||
|
all_ok = all_ok and ok
|
||||||
|
print(f" {key:<14} {'OK' if ok else 'MISMATCH':>8} set={val!r} got={actual!r}")
|
||||||
|
print("\nRESULT:", "ALL MATCH" if all_ok else "FAILURES PRESENT")
|
||||||
|
|
||||||
|
if args.submit:
|
||||||
|
clicked = page.evaluate("""
|
||||||
|
() => {
|
||||||
|
const sels = [
|
||||||
|
'button[type="submit"]', 'input[type="submit"]',
|
||||||
|
'button:has-text("Continue")', 'button:has-text("Next")',
|
||||||
|
'button:has-text("Submit")', 'button:has-text("Sign up")',
|
||||||
|
'button:has-text("Create account")', 'button:has-text("Agree")'
|
||||||
|
];
|
||||||
|
for (const s of sels) {
|
||||||
|
const el = document.querySelector(s);
|
||||||
|
if (el && el.offsetParent !== null) { el.click(); return s; }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
print("\n[SUBMIT] clicked:", clicked)
|
||||||
|
|
||||||
|
if not args.headless and not args.dump:
|
||||||
|
print("\nBrowser left open — review the form and submit yourself.")
|
||||||
|
input("Press Enter in this terminal to close the browser...")
|
||||||
|
|
||||||
|
browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
15
autofill_profile.json
Normal file
15
autofill_profile.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"first_name": "Indiana",
|
||||||
|
"last_name": "Holmes",
|
||||||
|
"email": "indianaholmes1@icloud.com",
|
||||||
|
"phone": "",
|
||||||
|
"address_line1": "",
|
||||||
|
"address_line2": "",
|
||||||
|
"city": "Lynnwood",
|
||||||
|
"state": "WA",
|
||||||
|
"zip": "",
|
||||||
|
"dob": "",
|
||||||
|
"ssn": "",
|
||||||
|
"employer": "",
|
||||||
|
"income": ""
|
||||||
|
}
|
||||||
14
data/profile.json
Normal file
14
data/profile.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"full_name": "Indiana Holmes",
|
||||||
|
"date_of_birth": "",
|
||||||
|
"ssn": "",
|
||||||
|
"address_line1": "",
|
||||||
|
"address_line2": "",
|
||||||
|
"city": "Lynnwood",
|
||||||
|
"state": "WA",
|
||||||
|
"zip": "",
|
||||||
|
"phone": "",
|
||||||
|
"email": "indianaholmes1@icloud.com",
|
||||||
|
"employer": "",
|
||||||
|
"income": ""
|
||||||
|
}
|
||||||
69
data/requirements.json
Normal file
69
data/requirements.json
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"checking": {
|
||||||
|
"label": "Checking Account",
|
||||||
|
"requirements": [
|
||||||
|
"Government-issued photo ID (driver's license or passport)",
|
||||||
|
"Social Security Number / Taxpayer ID",
|
||||||
|
"Date of birth",
|
||||||
|
"Proof of address (utility bill, lease, or bank statement)",
|
||||||
|
"Opening deposit (amount varies by institution)",
|
||||||
|
"Phone number and email address"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"savings": {
|
||||||
|
"label": "Savings Account",
|
||||||
|
"requirements": [
|
||||||
|
"Government-issued photo ID",
|
||||||
|
"Social Security Number / Taxpayer ID",
|
||||||
|
"Date of birth",
|
||||||
|
"Proof of address",
|
||||||
|
"Opening deposit (varies; many are $0 minimum)",
|
||||||
|
"Phone number and email address"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"brokerage": {
|
||||||
|
"label": "Brokerage / Investment Account",
|
||||||
|
"requirements": [
|
||||||
|
"Government-issued photo ID",
|
||||||
|
"Social Security Number / Taxpayer ID",
|
||||||
|
"Employment status and (sometimes) employer info",
|
||||||
|
"Funding source (linked bank routing + account number)",
|
||||||
|
"Risk / suitability questionnaire",
|
||||||
|
"Phone number and email address"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"crypto_exchange": {
|
||||||
|
"label": "Cryptocurrency Exchange",
|
||||||
|
"requirements": [
|
||||||
|
"Government-issued photo ID",
|
||||||
|
"Social Security Number / Taxpayer ID",
|
||||||
|
"Liveness selfie check (some exchanges)",
|
||||||
|
"Proof of address",
|
||||||
|
"Funding source (bank link or card)",
|
||||||
|
"Two-factor authentication enabled",
|
||||||
|
"Phone number and email address"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"credit_card": {
|
||||||
|
"label": "Credit Card",
|
||||||
|
"requirements": [
|
||||||
|
"Government-issued photo ID",
|
||||||
|
"Social Security Number / Taxpayer ID",
|
||||||
|
"Annual income",
|
||||||
|
"Employment status and employer",
|
||||||
|
"Monthly housing payment",
|
||||||
|
"Consent to a credit check (hard pull)",
|
||||||
|
"Phone number and email address"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"payment_app": {
|
||||||
|
"label": "Payment App (PayPal / Cash App / Venmo)",
|
||||||
|
"requirements": [
|
||||||
|
"Phone number and email address",
|
||||||
|
"Government-issued photo ID (for higher limits)",
|
||||||
|
"Social Security Number / Taxpayer ID (for higher limits)",
|
||||||
|
"Linked bank account or debit card",
|
||||||
|
"Date of birth"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1
data/secret.key
Normal file
1
data/secret.key
Normal file
@@ -0,0 +1 @@
|
|||||||
|
iakl8iSPghLtG6yfgfSBZYhFtxPWdps9rt2wongG4zE=
|
||||||
19
fill_worker.py
Normal file
19
fill_worker.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Worker entry point launched by the Castor dashboard to run the visible
|
||||||
|
autofill browser as a subprocess. Import the engine from app.py."""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from app import launch_fill
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--urls", required=True, help="JSON array of signup URLs")
|
||||||
|
ap.add_argument("--headless", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
urls = json.loads(args.urls)
|
||||||
|
summary = launch_fill(urls, headless=args.headless)
|
||||||
|
for url, results in summary:
|
||||||
|
print("%s -> filled %d fields" % (url, len(results)))
|
||||||
8
last_launch.json
Normal file
8
last_launch.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"launched": [
|
||||||
|
"Chase Credit Cards"
|
||||||
|
],
|
||||||
|
"urls": [
|
||||||
|
"https://creditcards.chase.com/"
|
||||||
|
]
|
||||||
|
}
|
||||||
24
providers.json
Normal file
24
providers.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
[
|
||||||
|
{"id": "chase_checking", "name": "Chase Total Checking", "category": "checking", "url": "https://account.chase.com/consumer/banking/enroll", "note": "Big-bank checking; nationwide branches."},
|
||||||
|
{"id": "chase_savings", "name": "Chase Savings", "category": "savings", "url": "https://account.chase.com/consumer/banking/enroll", "note": "Standard savings account."},
|
||||||
|
{"id": "capital_one_360", "name": "Capital One 360 Checking", "category": "checking", "url": "https://www.capitalone.com/bank/checking-accounts/", "note": "No-fee online checking."},
|
||||||
|
{"id": "discover_bank", "name": "Discover Online Banking", "category": "checking", "url": "https://www.discover.com/online-banking/", "note": "Cashback checking + savings."},
|
||||||
|
{"id": "ally", "name": "Ally Bank", "category": "savings", "url": "https://www.ally.com/bank/", "note": "Online-only, high-yield savings."},
|
||||||
|
{"id": "chime", "name": "Chime", "category": "checking", "url": "https://www.chime.com/enroll/", "note": "Fee-free mobile banking."},
|
||||||
|
{"id": "sofi", "name": "SoFi Banking", "category": "checking", "url": "https://www.sofi.com/banking/", "note": "High-yield checking + savings."},
|
||||||
|
{"id": "robinhood", "name": "Robinhood", "category": "brokerage", "url": "https://robinhood.com/us/en/", "note": "Commission-free stock/crypto trading."},
|
||||||
|
{"id": "fidelity", "name": "Fidelity", "category": "brokerage", "url": "https://www.fidelity.com/open-account/overview", "note": "Full-service brokerage."},
|
||||||
|
{"id": "schwab", "name": "Charles Schwab", "category": "brokerage", "url": "https://www.schwab.com/open-an-account", "note": "Brokerage + checking."},
|
||||||
|
{"id": "vanguard", "name": "Vanguard", "category": "brokerage", "url": "https://investor.vanguard.com/", "note": "Low-cost index investing."},
|
||||||
|
{"id": "webull", "name": "Webull", "category": "brokerage", "url": "https://www.webull.com/register", "note": "Commission-free trading app."},
|
||||||
|
{"id": "coinbase", "name": "Coinbase", "category": "crypto", "url": "https://www.coinbase.com/signup", "note": "Largest US crypto exchange."},
|
||||||
|
{"id": "kraken", "name": "Kraken", "category": "crypto", "url": "https://www.kraken.com/signup", "note": "Crypto exchange, strong security."},
|
||||||
|
{"id": "gemini", "name": "Gemini", "category": "crypto", "url": "https://exchange.gemini.com/register", "note": "Regulated US crypto exchange."},
|
||||||
|
{"id": "crypto_com", "name": "Crypto.com", "category": "crypto", "url": "https://crypto.com/exchange/signup", "note": "Crypto exchange + card."},
|
||||||
|
{"id": "chase_cards", "name": "Chase Credit Cards", "category": "credit_card", "url": "https://creditcards.chase.com/", "note": "Rewards and travel cards."},
|
||||||
|
{"id": "amex", "name": "American Express", "category": "credit_card", "url": "https://www.americanexpress.com/us/credit-cards/", "note": "Premium rewards cards."},
|
||||||
|
{"id": "capital_one_cards", "name": "Capital One Cards", "category": "credit_card", "url": "https://www.capitalone.com/credit-cards/", "note": "Cashback and travel cards."},
|
||||||
|
{"id": "paypal", "name": "PayPal", "category": "payment", "url": "https://www.paypal.com/us/webapps/mpp/account-selection", "note": "Digital wallet and payments."},
|
||||||
|
{"id": "venmo", "name": "Venmo", "category": "payment", "url": "https://venmo.com/signup", "note": "P2P payments app."},
|
||||||
|
{"id": "cash_app", "name": "Cash App", "category": "payment", "url": "https://cash.app/", "note": "P2P + stock/bitcoin app."}
|
||||||
|
]
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
flask>=2.0
|
||||||
|
cryptography>=3.4
|
||||||
106
static/style.css
Normal file
106
static/style.css
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #0b1020;
|
||||||
|
--panel: #141b30;
|
||||||
|
--panel-2: #1b2340;
|
||||||
|
--text: #e7ecf5;
|
||||||
|
--muted: #8a93ab;
|
||||||
|
--accent: #6c8cff;
|
||||||
|
--accent-2: #9a6cff;
|
||||||
|
--ok: #4ade80;
|
||||||
|
--err: #f87171;
|
||||||
|
--border: #2a3352;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: radial-gradient(circle at 20% 0%, #1a2140 0%, var(--bg) 60%);
|
||||||
|
color: var(--text);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: rgba(20, 27, 48, 0.7);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand { display: flex; align-items: baseline; gap: 8px; font-weight: 700; font-size: 1.15rem; }
|
||||||
|
.logo { color: var(--accent-2); }
|
||||||
|
.tag { color: var(--muted); font-weight: 400; font-size: 0.85rem; }
|
||||||
|
|
||||||
|
nav { display: flex; gap: 18px; }
|
||||||
|
nav a { color: var(--muted); text-decoration: none; font-weight: 500; }
|
||||||
|
nav a:hover { color: var(--text); }
|
||||||
|
|
||||||
|
main { flex: 1; max-width: 900px; width: 100%; margin: 0 auto; padding: 28px 24px; }
|
||||||
|
|
||||||
|
footer {
|
||||||
|
padding: 16px 24px; text-align: center; color: var(--muted);
|
||||||
|
font-size: 0.82rem; border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { margin: 0 0 12px; font-size: 1.6rem; }
|
||||||
|
h2.cat { margin: 26px 0 12px; font-size: 1.05rem; color: var(--accent); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.hero { margin-bottom: 8px; }
|
||||||
|
.hero p { max-width: 620px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.flash { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-weight: 500; }
|
||||||
|
.flash.ok { background: rgba(74, 222, 128, 0.12); color: var(--ok); }
|
||||||
|
.flash.error { background: rgba(248, 113, 113, 0.12); color: var(--err); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: var(--panel-2); color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 9px 16px; border-radius: 8px; cursor: pointer;
|
||||||
|
text-decoration: none; font-size: 0.92rem; font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: var(--accent); }
|
||||||
|
.btn.primary { background: linear-gradient(135deg, var(--accent), var(--accent-2)); border: none; }
|
||||||
|
.btn.big { padding: 13px 26px; font-size: 1.02rem; }
|
||||||
|
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
|
||||||
|
|
||||||
|
.provider {
|
||||||
|
display: flex; flex-direction: column; gap: 4px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: 10px; padding: 14px 16px; cursor: pointer;
|
||||||
|
transition: border-color 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
.provider:hover { border-color: var(--accent); }
|
||||||
|
.provider:has(input:checked) { border-color: var(--accent); background: var(--panel-2); box-shadow: 0 0 0 1px var(--accent); }
|
||||||
|
|
||||||
|
.provider input[type="checkbox"] {
|
||||||
|
position: absolute; opacity: 0; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pname { font-weight: 700; font-size: 1rem; }
|
||||||
|
.pnote { color: var(--muted); font-size: 0.8rem; line-height: 1.35; }
|
||||||
|
.plink { color: var(--accent-2); font-size: 0.8rem; text-decoration: none; align-self: flex-start; margin-top: 2px; }
|
||||||
|
.plink:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.launchbar {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
margin-top: 28px; padding: 16px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
|
||||||
|
position: sticky; bottom: 16px;
|
||||||
|
}
|
||||||
|
.launchbar .muted { font-size: 0.85rem; }
|
||||||
|
|
||||||
|
.form { display: flex; flex-direction: column; gap: 16px; max-width: 480px; }
|
||||||
|
.form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; font-size: 0.9rem; }
|
||||||
|
.form input {
|
||||||
|
background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
|
||||||
|
padding: 10px 12px; border-radius: 8px; font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.form input:focus { outline: none; border-color: var(--accent); }
|
||||||
55
templates/application.html
Normal file
55
templates/application.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<div class="app-head">
|
||||||
|
<div>
|
||||||
|
<h1>{{ a['institution'] }}</h1>
|
||||||
|
<div class="muted">{{ a['account_type'] | replace('_', ' ') | title }} · started {{ a['created_at'] }}</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="{{ url_for('set_status', app_id=a['id']) }}" class="inline">
|
||||||
|
<select name="status" onchange="this.form.submit()">
|
||||||
|
{% for s in ['researching', 'in_progress', 'submitted', 'approved', 'denied'] %}
|
||||||
|
<option value="{{ s }}" {% if a['status'] == s %}selected{% endif %}>{{ s }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Requirements</h2>
|
||||||
|
<ul class="checklist">
|
||||||
|
{% for item in items %}
|
||||||
|
<li>
|
||||||
|
<form method="post" action="{{ url_for('toggle_item', app_id=a['id'], item_id=item['id']) }}">
|
||||||
|
<button type="submit" class="checkbox {{ 'checked' if item['done'] else '' }}">
|
||||||
|
{{ '✓' if item['done'] else '' }}
|
||||||
|
</button>
|
||||||
|
<span class="{{ 'done' if item['done'] else '' }}">{{ item['label'] }}</span>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2>Your info (for pre-filling this application)</h2>
|
||||||
|
{% if profile %}
|
||||||
|
<div class="profile-panel">
|
||||||
|
{% for key, label in [('full_name','Name'),('date_of_birth','DOB'),('ssn','SSN/TIN'),('address_line1','Address'),('city','City'),('state','State'),('zip','ZIP'),('phone','Phone'),('email','Email')] %}
|
||||||
|
{% if profile.get(key) %}
|
||||||
|
<div><span class="plabel">{{ label }}</span><span class="pvalue">{{ profile[key] }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<p class="muted">Edit in <a href="{{ url_for('profile') }}">Profile</a>.</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No profile yet. <a href="{{ url_for('profile') }}">Add your info</a> to pre-fill.</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<h2>Notes</h2>
|
||||||
|
<form method="post" action="{{ url_for('set_notes', app_id=a['id']) }}" class="form">
|
||||||
|
<textarea name="notes" rows="3">{{ a['notes'] }}</textarea>
|
||||||
|
<button type="submit" class="btn">Save notes</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('delete_application', app_id=a['id']) }}"
|
||||||
|
onsubmit="return confirm('Delete this application?');">
|
||||||
|
<button type="submit" class="btn danger">Delete application</button>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
41
templates/base.html
Normal file
41
templates/base.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Castor — Account Onboarding</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="brand">
|
||||||
|
<span class="logo">✦</span>
|
||||||
|
<span>Castor</span>
|
||||||
|
<span class="tag">account onboarding assistant</span>
|
||||||
|
</div>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('index') }}">Applications</a>
|
||||||
|
<a href="{{ url_for('new_application') }}">+ New</a>
|
||||||
|
<a href="{{ url_for('profile') }}">Profile</a>
|
||||||
|
<a href="{{ url_for('vault') }}">Vault</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for cat, msg in messages %}
|
||||||
|
<div class="flash {{ cat }}">{{ msg }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>For opening <strong>your own</strong> accounts in <strong>your own name</strong>. No identity fabrication, no bulk signups, no KYC evasion.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
57
templates/index.html
Normal file
57
templates/index.html
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Castor — Signup Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="brand"><span class="logo">✦</span><span>Castor</span><span class="tag">signup dashboard</span></div>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('index') }}">Providers</a>
|
||||||
|
<a href="{{ url_for('profile_route') }}">Your Info ({{ filled }}/{{ total }})</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for cat, msg in messages %}<div class="flash {{ cat }}">{{ msg }}</div>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h1>Open accounts without retyping your info</h1>
|
||||||
|
<p class="muted">Check the providers you want, hit launch — each signup page opens with your details already filled. You finish the CAPTCHA / verification and submit.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('launch') }}">
|
||||||
|
{% for cat in categories %}
|
||||||
|
<h2 class="cat">{{ cat | replace('_', ' ') | title }}</h2>
|
||||||
|
<div class="cards">
|
||||||
|
{% for p in providers if p['category'] == cat %}
|
||||||
|
<label class="provider">
|
||||||
|
<input type="checkbox" name="providers" value="{{ p['id'] }}">
|
||||||
|
<span class="pname">{{ p['name'] }}</span>
|
||||||
|
<span class="pnote">{{ p['note'] }}</span>
|
||||||
|
<a class="plink" href="{{ p['url'] }}" target="_blank" rel="noopener" onclick="event.stopPropagation()">open ↗</a>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<div class="launchbar">
|
||||||
|
<button type="submit" class="btn primary big">Launch & autofill selected</button>
|
||||||
|
<span class="muted">opens each in a new browser tab with your info filled</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Fills <strong>your own</strong> info into forms <strong>you</strong> submit. No identity fabrication, no bulk signups, no KYC evasion.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
36
templates/new.html
Normal file
36
templates/new.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>New Application</h1>
|
||||||
|
<p class="muted">Pick the account type. Castor loads the real KYC requirements that institutions ask for.</p>
|
||||||
|
|
||||||
|
<form method="post" class="form">
|
||||||
|
<label>Account type
|
||||||
|
<select name="account_type" id="account_type">
|
||||||
|
{% for key, info in reqs.items() %}
|
||||||
|
<option value="{{ key }}">{{ info['label'] }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div id="preview" class="preview"></div>
|
||||||
|
|
||||||
|
<label>Institution
|
||||||
|
<input type="text" name="institution" placeholder="e.g. Chase, Vanguard, Coinbase, PayPal" required>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" class="btn primary">Start application</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const reqs = {{ reqs | tojson }};
|
||||||
|
const sel = document.getElementById('account_type');
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
function render() {
|
||||||
|
const info = reqs[sel.value];
|
||||||
|
preview.innerHTML = '<strong>Requirements:</strong><ul>' +
|
||||||
|
info.requirements.map(r => '<li>' + r + '</li>').join('') + '</ul>';
|
||||||
|
}
|
||||||
|
sel.addEventListener('change', render);
|
||||||
|
render();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
42
templates/profile.html
Normal file
42
templates/profile.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex, nofollow">
|
||||||
|
<title>Castor — Your Info</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="brand"><span class="logo">✦</span><span>Castor</span><span class="tag">signup dashboard</span></div>
|
||||||
|
<nav>
|
||||||
|
<a href="{{ url_for('index') }}">Providers</a>
|
||||||
|
<a href="{{ url_for('profile_route') }}">Your Info</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for cat, msg in messages %}<div class="flash {{ cat }}">{{ msg }}</div>{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
|
|
||||||
|
<h1>Your Info</h1>
|
||||||
|
<p class="muted">Stored locally, used only to auto-fill <strong>your own</strong> signup forms.</p>
|
||||||
|
<form method="post" class="form">
|
||||||
|
{% for key, label in fields %}
|
||||||
|
<label>{{ label }}
|
||||||
|
<input type="text" name="{{ key }}" value="{{ profile.get(key, '') }}" autocomplete="off">
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="submit" class="btn primary">Save</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
<p>Fills <strong>your own</strong> info into forms <strong>you</strong> submit. No identity fabrication, no bulk signups, no KYC evasion.</p>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
templates/vault.html
Normal file
37
templates/vault.html
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Vault</h1>
|
||||||
|
<p class="muted">Your identity documents, encrypted at rest with a key generated on this machine.</p>
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('vault_upload') }}" enctype="multipart/form-data" class="form">
|
||||||
|
<label>Upload document (20 MB max)
|
||||||
|
<input type="file" name="file" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn primary">Encrypt & store</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if docs %}
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>File</th><th>Type</th><th>SHA-256</th><th>Uploaded</th><th></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{% for d in docs %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ d['filename'] }}</td>
|
||||||
|
<td>{{ d['mime'] }}</td>
|
||||||
|
<td class="mono">{{ d['sha256'][:16] }}…</td>
|
||||||
|
<td>{{ d['uploaded_at'] }}</td>
|
||||||
|
<td class="row-actions">
|
||||||
|
<a class="btn small" href="{{ url_for('vault_download', doc_id=d['id']) }}">Download</a>
|
||||||
|
<form method="post" action="{{ url_for('vault_delete', doc_id=d['id']) }}" class="inline"
|
||||||
|
onsubmit="return confirm('Delete this document?');">
|
||||||
|
<button type="submit" class="btn small danger">Delete</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<p class="muted">No documents stored yet.</p>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
28
test_form.html
Normal file
28
test_form.html
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>Test Signup Form</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Open an Account</h1>
|
||||||
|
<form>
|
||||||
|
<label>First name <input type="text" name="firstname" autocomplete="given-name"></label><br>
|
||||||
|
<label>Last name <input type="text" name="lastname" autocomplete="family-name"></label><br>
|
||||||
|
<label>Email <input type="email" name="email" autocomplete="email"></label><br>
|
||||||
|
<label>Phone <input type="tel" name="phone" autocomplete="tel"></label><br>
|
||||||
|
<label>Street address <input type="text" name="address" autocomplete="street-address"></label><br>
|
||||||
|
<label>City <input type="text" name="city" autocomplete="address-level2"></label><br>
|
||||||
|
<label>State
|
||||||
|
<select name="state" autocomplete="address-level1">
|
||||||
|
<option value="">Select…</option>
|
||||||
|
<option value="CA">California</option>
|
||||||
|
<option value="WA">Washington</option>
|
||||||
|
<option value="OR">Oregon</option>
|
||||||
|
</select>
|
||||||
|
</label><br>
|
||||||
|
<label>ZIP <input type="text" name="zip" autocomplete="postal-code"></label><br>
|
||||||
|
<label>Date of birth <input type="text" name="dob" placeholder="MM/DD/YYYY"></label><br>
|
||||||
|
<label>Social Security Number <input type="text" name="ssn" autocomplete="off"></label><br>
|
||||||
|
<label>Password <input type="password" name="password"></label><br>
|
||||||
|
<button type="submit">Create Account</button>
|
||||||
|
</form>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user