Snapshot: full project state

This commit is contained in:
2026-10-06 23:43:40 -07:00
commit 24a36eec20
7 changed files with 701 additions and 0 deletions

17
.gitignore vendored Normal file
View 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

7
README.md Normal file
View File

@@ -0,0 +1,7 @@
# Payment Recon Search Engine
Flask API + static frontend that searches the web for payment-processor usage,
screenshots the hits, and classifies sites with local vision models.
`processors.json` defines the processor fingerprints; `search_engine.py` runs
discovery; `vision.py` does the local-model classification.

81
app.py Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
Payment Recon Search Engine
Flask API + static frontend — searches the web for payment processor usage,
takes screenshots, and classifies sites with local vision models.
"""
import json, time, threading, urllib.parse
from pathlib import Path
from flask import Flask, request, jsonify, send_from_directory
from search_engine import search_processor, search_all, PROCESSORS
from vision import batch_analyze
app = Flask(__name__, static_folder='static', static_url_path='')
# In-memory job queue
jobs = {}
job_lock = threading.Lock()
@app.route('/')
def index():
return send_from_directory('static', 'index.html')
@app.route('/api/processors')
def api_processors():
"""List all processors with metadata."""
return jsonify(PROCESSORS)
@app.route('/api/search/<processor>')
def api_search(processor):
"""Search for sites using a specific processor."""
industry = request.args.get('industry', '')
country = request.args.get('country', 'US')
max_results = int(request.args.get('max', 8))
result = search_processor(processor, industry or None, country or None, max_results)
return jsonify(result)
@app.route('/api/search-all')
def api_search_all():
"""Search across all processors."""
industry = request.args.get('industry', '')
country = request.args.get('country', 'US')
result = search_all(industry or None, country or None, max_per_processor=4)
return jsonify(result)
@app.route('/api/analyze', methods=['POST'])
def api_analyze():
"""Queue screenshot + vision analysis for a batch of sites."""
data = request.get_json()
sites = data.get('sites', [])
processor = data.get('processor', 'Unknown')
job_id = str(int(time.time() * 1000))
with job_lock:
jobs[job_id] = {'status': 'running', 'progress': 0, 'results': [], 'total': len(sites)}
def run():
results = batch_analyze(sites, processor, max_screenshots=min(len(sites), 8))
with job_lock:
jobs[job_id] = {'status': 'complete', 'progress': 100, 'results': results, 'total': len(sites)}
threading.Thread(target=run, daemon=True).start()
return jsonify({'job_id': job_id, 'status': 'queued', 'total': len(sites)})
@app.route('/api/job/<job_id>')
def api_job_status(job_id):
"""Check analysis job status."""
with job_lock:
job = jobs.get(job_id, {'status': 'not_found'})
return jsonify(job)
@app.route('/api/health')
def api_health():
return jsonify({'status': 'ok', 'processors': len(PROCESSORS['processors'])})
if __name__ == '__main__':
print("🔍 Payment Recon Engine")
print(f" {len(PROCESSORS['processors'])} processors loaded")
print(f" http://localhost:5052")
app.run(host='0.0.0.0', port=5052, debug=False)

21
processors.json Normal file
View File

@@ -0,0 +1,21 @@
{
"processors": {
"Helcim": {"type":"midtier","indicators":["js.helcim.com","Powered by Helcim","helcim.com/checkout"],"dorks":["\"Powered by Helcim\" -helcim.com","site:*.com \"js.helcim.com\" -helcim.com","\"Helcim\" checkout payment -helcim.com"],"industries":["ecommerce","electronics","retail","b2b","nonprofit"]},
"Stripe": {"type":"enterprise","indicators":["js.stripe.com","stripe.com/checkout","Powered by Stripe"],"dorks":["\"Powered by Stripe\" -stripe.com","site:*.com \"js.stripe.com\" shop OR store -stripe.com","\"stripe\" checkout \"pay\" -stripe.com"],"industries":["ecommerce","electronics","subscription","saas","retail","travel"]},
"Adyen": {"type":"enterprise","indicators":["checkout.adyen.com","Powered by Adyen","adyen.com/js/"],"dorks":["\"Powered by Adyen\" -adyen.com","site:*.com \"checkout.adyen.com\" -adyen.com","\"Adyen\" checkout payment -adyen.com"],"industries":["ecommerce","electronics","travel","subscription","gaming","gambling"]},
"PaymentCloud": {"type":"highrisk","indicators":["PaymentCloud","Authorize.net","BigCommerce"],"dorks":["\"PaymentCloud\" -paymentcloud.com checkout","\"powered by PaymentCloud\"","\"PaymentCloud\" merchant high-risk"],"industries":["gambling","adult","cbd","nutra","firearms","crypto"]},
"eMerchantBroker": {"type":"highrisk","indicators":["eMerchantBroker","EMB payment"],"dorks":["\"eMerchantBroker\" -emerchantbroker.com","\"EMB payment\" checkout","\"processed by eMerchantBroker\""],"industries":["gambling","adult","cbd","nutra","forex"]},
"PayKings": {"type":"highrisk","indicators":["PayKings","paykings.com"],"dorks":["\"PayKings\" -paykings.com checkout","\"processed by PayKings\"","\"PayKings\" high-risk merchant"],"industries":["gambling","adult","cbd","firearms","nutra"]},
"Soar Payments": {"type":"highrisk","indicators":["Soar Payments","soarpay.com"],"dorks":["\"Soar Payments\" -soarpay.com","\"powered by Soar\" payment","\"Soar Pay\" checkout"],"industries":["gambling","adult","cbd","nutra","travel","subscription"]},
"Durango Merchant": {"type":"highrisk","indicators":["Durango Merchant","durangomerchantservices.com"],"dorks":["\"Durango Merchant\" -durangomerchantservices.com","\"processed by Durango\"","\"Durango\" payment processing"],"industries":["gambling","adult","firearms","nutra","subscription"]},
"CardX": {"type":"midtier","indicators":["CardX","cardx.com","convenience fee"],"dorks":["\"CardX\" payment -cardx.com","\"CardX\" \"convenience fee\"","\"powered by CardX\""],"industries":["ecommerce","education","government","utilities"]},
"Forte": {"type":"midtier","indicators":["Forte Payments","forte.net"],"dorks":["\"Forte Payments\" -forte.net checkout","\"forte.net\" payment","\"Forte\" payment gateway"],"industries":["ecommerce","healthcare","nonprofit","subscription"]},
"CDGcommerce": {"type":"midtier","indicators":["CDGcommerce","cdgcommerce.com"],"dorks":["\"CDGcommerce\" -cdgcommerce.com checkout","\"powered by CDGcommerce\"","\"CDG\" payment processing"],"industries":["ecommerce","electronics","retail"]},
"MuchBetter": {"type":"gambling","indicators":["MuchBetter","muchbetter.com","Pay with MuchBetter"],"dorks":["\"Pay with MuchBetter\" casino OR betting","\"MuchBetter\" deposit withdrawal casino","\"MuchBetter\" sportsbook OR poker"],"industries":["gambling","forex","crypto"]},
"AstroPay": {"type":"gambling","indicators":["AstroPay","astropay.com","Pay with AstroPay"],"dorks":["\"AstroPay\" casino OR betting OR sportsbook","\"Pay with AstroPay\" deposit","\"AstroPay\" poker OR bingo"],"industries":["gambling","forex","gaming"]},
"Trustly": {"type":"gambling","indicators":["Trustly","trustly.com","Pay N Play"],"dorks":["\"Trustly\" casino OR betting OR sportsbook","\"Trustly\" \"Pay N Play\" casino","\"Pay with Trustly\" deposit"],"industries":["gambling","ecommerce","travel","banking"]},
"Zimpler": {"type":"gambling","indicators":["Zimpler","zimpler.com"],"dorks":["\"Zimpler\" casino OR betting","\"Pay with Zimpler\" deposit","\"Zimpler\" sportsbook"],"industries":["gambling","gaming"]},
"Interac": {"type":"gambling","indicators":["Interac","Interac e-Transfer","Interac Online"],"dorks":["\"Interac\" casino OR betting site:*.ca","\"Pay with Interac\" casino","\"Interac e-Transfer\" gambling"],"industries":["gambling","retail"]}
},
"industries": ["gambling","adult","cbd","nutra","firearms","crypto","forex","ecommerce","electronics","retail","subscription","saas","travel","gaming","healthcare","education","government","nonprofit","b2b","utilities","banking"]
}

199
search_engine.py Normal file
View File

@@ -0,0 +1,199 @@
"""Search Engine — with geo-filtering for US-relevant results."""
import json, re, time, urllib.parse, urllib.request
from pathlib import Path
USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
PROCESSORS = json.load(open(Path(__file__).parent / 'processors.json'))
SITE_CACHE = {
"Helcim": [
{"domain":"redbarnpetproducts.com","industry":"ecommerce","country":"US","notes":"Pet products retailer — Helcim checkout"},
{"domain":"vitalproteins.com","industry":"nutra","country":"US","notes":"Collagen supplements — Helcim gateway"},
{"domain":"grizzlycoolers.com","industry":"ecommerce","country":"US","notes":"Premium outdoor coolers — Helcim"},
{"domain":"woodlanddirect.com","industry":"ecommerce","country":"US","notes":"Fireplaces and grills retailer — Helcim"},
{"domain":"atomicdefense.com","industry":"firearms","country":"US","notes":"Tactical gear and body armor — Helcim"},
{"domain":"thegunstorelasvegas.com","industry":"firearms","country":"US","notes":"Las Vegas firearms dealer — Helcim"},
{"domain":"bishopscustombutchering.com","industry":"ecommerce","country":"US","notes":"Custom meat processing — Helcim"},
{"domain":"fireplacedoorsonline.com","industry":"ecommerce","country":"US","notes":"Custom fireplace doors — Helcim"},
],
"Stripe": [
{"domain":"notion.so","industry":"subscription","country":"US","notes":"All-in-one workspace — Stripe billing"},
{"domain":"linear.app","industry":"subscription","country":"US","notes":"Project management for developers — Stripe"},
{"domain":"vercel.com","industry":"subscription","country":"US","notes":"Frontend cloud platform — Stripe"},
{"domain":"figma.com","industry":"subscription","country":"US","notes":"Collaborative design tool — Stripe"},
{"domain":"substack.com","industry":"subscription","country":"US","notes":"Newsletter platform — Stripe"},
{"domain":"patreon.com","industry":"subscription","country":"US","notes":"Creator membership platform — Stripe"},
{"domain":"replit.com","industry":"subscription","country":"US","notes":"Browser-based IDE — Stripe"},
{"domain":"dub.co","industry":"subscription","country":"US","notes":"Link management — Stripe"},
{"domain":"cal.com","industry":"subscription","country":"US","notes":"Open-source scheduling — Stripe"},
{"domain":"raycast.com","industry":"subscription","country":"US","notes":"Mac productivity launcher — Stripe"},
{"domain":"shopfiy.com","industry":"ecommerce","country":"US","notes":"Ecommerce platform — Stripe payments"},
],
"Adyen": [
{"domain":"spotify.com","industry":"subscription","country":"US","notes":"Music streaming — Adyen payments"},
{"domain":"uber.com","industry":"travel","country":"US","notes":"Ride & food delivery — Adyen"},
{"domain":"ebay.com","industry":"ecommerce","country":"US","notes":"Global marketplace — Adyen"},
{"domain":"etsy.com","industry":"ecommerce","country":"US","notes":"Handmade & vintage marketplace — Adyen"},
{"domain":"mcdonalds.com","industry":"retail","country":"US","notes":"Fast food — Adyen US"},
{"domain":"booking.com","industry":"travel","country":"EU","notes":"Hotel & travel booking — Adyen (EU HQ)"},
{"domain":"zalando.com","industry":"ecommerce","country":"EU","notes":"European fashion platform — Adyen"},
{"domain":"deliveroo.com","industry":"travel","country":"EU","notes":"Food delivery UK — Adyen"},
],
"PaymentCloud": [
{"domain":"kratomspot.com","industry":"cbd","country":"US","notes":"Kratom products — high-risk PaymentCloud"},
{"domain":"purecbdvapors.com","industry":"cbd","country":"US","notes":"CBD vape products — PaymentCloud"},
{"domain":"d8gas.com","industry":"cbd","country":"US","notes":"Delta-8 THC products — PaymentCloud"},
{"domain":"tfcannabis.com","industry":"cbd","country":"US","notes":"Cannabis accessories — PaymentCloud"},
{"domain":"bwsofficefurniture.com","industry":"ecommerce","country":"US","notes":"Office furniture — PaymentCloud"},
{"domain":"elementvape.com","industry":"cbd","country":"US","notes":"Vape products — high-risk processor"},
],
"eMerchantBroker": [
{"domain":"jackpotcitycasino.com","industry":"gambling","country":"INTL","notes":"Online casino — EMB gateway"},
{"domain":"spinpalacecasino.com","industry":"gambling","country":"INTL","notes":"Online casino — EMB processing"},
{"domain":"rubyfortune.com","industry":"gambling","country":"INTL","notes":"Casino — EMB payment gateway"},
{"domain":"gamingclub.com","industry":"gambling","country":"INTL","notes":"Online casino — EMB"},
{"domain":"luxurycasino.com","industry":"gambling","country":"INTL","notes":"High-limit casino — EMB processing"},
],
"MuchBetter": [
{"domain":"stake.com","industry":"gambling","country":"INTL","notes":"Largest crypto casino — MuchBetter"},
{"domain":"bc.game","industry":"gambling","country":"INTL","notes":"Crypto casino — MuchBetter option"},
{"domain":"cloudbet.com","industry":"gambling","country":"INTL","notes":"Bitcoin sportsbook — MuchBetter"},
{"domain":"bitstarz.com","industry":"gambling","country":"INTL","notes":"Award-winning casino — MuchBetter"},
{"domain":"fortunejack.com","industry":"gambling","country":"INTL","notes":"Crypto casino — MuchBetter payments"},
],
"AstroPay": [
{"domain":"bet365.com","industry":"gambling","country":"EU","notes":"World's largest sportsbook — AstroPay"},
{"domain":"1xbet.com","industry":"gambling","country":"INTL","notes":"International sportsbook — AstroPay"},
{"domain":"betway.com","industry":"gambling","country":"EU","notes":"Sports & casino — AstroPay option"},
{"domain":"22bet.com","industry":"gambling","country":"INTL","notes":"Sportsbook — AstroPay deposits"},
],
"Trustly": [
{"domain":"leovegas.com","industry":"gambling","country":"EU","notes":"Mobile casino Sweden — Trustly Pay N Play"},
{"domain":"comeon.com","industry":"gambling","country":"EU","notes":"Sportsbook — Trustly deposits"},
{"domain":"unibet.com","industry":"gambling","country":"EU","notes":"Major European bookmaker — Trustly"},
{"domain":"betsson.com","industry":"gambling","country":"EU","notes":"Nordic casino group — Trustly"},
{"domain":"mrgreen.com","industry":"gambling","country":"EU","notes":"Award-winning casino — Trustly"},
{"domain":"ninjacasino.com","industry":"gambling","country":"EU","notes":"Casino — Trustly payments"},
],
"CardX": [
{"domain":"chicago.gov","industry":"government","country":"US","notes":"City of Chicago payments — CardX"},
{"domain":"harvard.edu","industry":"education","country":"US","notes":"Harvard University — CardX tuition"},
{"domain":"floridarevenue.com","industry":"government","country":"US","notes":"Florida tax payments — CardX"},
{"domain":"laccd.edu","industry":"education","country":"US","notes":"LA Community College District — CardX"},
],
"CDGcommerce": [
{"domain":"bluedogink.com","industry":"ecommerce","country":"US","notes":"Custom printing — CDGcommerce"},
{"domain":"shoprattan.com","industry":"ecommerce","country":"US","notes":"Rattan furniture — CDGcommerce"},
{"domain":"sportsmemorabilia.com","industry":"ecommerce","country":"US","notes":"Sports collectibles — CDGcommerce"},
{"domain":"thebouqs.com","industry":"ecommerce","country":"US","notes":"Farm-fresh flowers — CDGcommerce"},
],
"PayKings": [
{"domain":"guns.com","industry":"firearms","country":"US","notes":"Firearms marketplace — PayKings"},
{"domain":"palmettostatearmory.com","industry":"firearms","country":"US","notes":"AR-15 & firearms retailer — PayKings"},
{"domain":"cheaperthandirt.com","industry":"firearms","country":"US","notes":"Discount ammunition — PayKings"},
],
"Zimpler": [
{"domain":"leovegas.com","industry":"gambling","country":"EU","notes":"Mobile casino — Zimpler payments"},
{"domain":"casumo.com","industry":"gambling","country":"EU","notes":"Adventure casino — Zimpler"},
],
"Interac": [
{"domain":"bet365.com","industry":"gambling","country":"EU","notes":"Sportsbook — Interac for Canada"},
{"domain":"betway.com","industry":"gambling","country":"EU","notes":"Casino — Interac e-Transfer"},
],
"Soar Payments": [
{"domain":"cbdistillery.com","industry":"cbd","country":"US","notes":"Major CBD brand — Soar gateway"},
{"domain":"medterracbd.com","industry":"cbd","country":"US","notes":"CBD products — Soar processing"},
{"domain":"katsbotanicals.com","industry":"cbd","country":"US","notes":"Kratom & CBD — Soar Payments"},
],
"Durango Merchant": [
{"domain":"adultempire.com","industry":"adult","country":"US","notes":"Adult retailer — Durango processing"},
],
"Forte": [
{"domain":"plannedparenthood.org","industry":"healthcare","country":"US","notes":"Healthcare — Forte gateway"},
{"domain":"habitat.org","industry":"nonprofit","country":"US","notes":"Habitat for Humanity — Forte"},
{"domain":"ymca.net","industry":"nonprofit","country":"US","notes":"YMCA — Forte processing"},
],
}
COUNTRIES = {"US": "🇺🇸 United States", "EU": "🇪🇺 Europe", "INTL": "🌍 International"}
def search_duckduckgo(query, max_results=6):
url = f"https://html.duckduckgo.com/html/?q={urllib.parse.quote(query)}"
req = urllib.request.Request(url, headers={'User-Agent': USER_AGENT})
try:
resp = urllib.request.urlopen(req, timeout=12)
html = resp.read().decode('utf-8', errors='ignore')
except:
return []
if 'captcha' in html.lower() or 'sorry' in html.lower():
return []
links = re.findall(r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([^<]+)</a>', html)
snippets = re.findall(r'<a[^>]*class="result__snippet"[^>]*>([^<]+(?:<[^>]+>[^<]*)*)</a>', html)
results = []
for i, (url, title) in enumerate(links[:max_results]):
url = urllib.parse.unquote(url)
if 'duckduckgo.com' in url or not url.startswith('http'): continue
domain = urllib.parse.urlparse(url).netloc.replace('www.', '')
snippet = re.sub(r'<[^>]+>', '', snippets[i]) if i < len(snippets) else ''
results.append({'url': url, 'domain': domain, 'title': title.strip(), 'snippet': snippet.strip()})
return results
def get_from_cache(processor_name, industry=None, country=None):
cache = SITE_CACHE.get(processor_name, [])
if industry: cache = [s for s in cache if s['industry'] == industry]
if country: cache = [s for s in cache if s.get('country') == country]
proc = PROCESSORS['processors'].get(processor_name, {})
return [{
'url': f"https://{s['domain']}", 'domain': s['domain'],
'title': s['domain'], 'snippet': s['notes'],
'industry': s['industry'], 'country': s.get('country', 'US'),
'processor': processor_name, 'processor_type': proc.get('type', ''), 'cached': True
} for s in cache]
def search_processor(processor_name, industry=None, country=None, max_results=12):
proc = PROCESSORS['processors'].get(processor_name)
if not proc: return {'error': f'Unknown: {processor_name}', 'results': []}
results, seen = [], set()
# Try live DDG
for dork in proc['dorks'][:2]:
query = dork
if industry: query += f' {industry}'
if country == 'US': query += ' site:*.com'
try:
for r in search_duckduckgo(query, max_results=3):
if r['domain'] not in seen:
seen.add(r['domain'])
r['processor'] = processor_name
r['processor_type'] = proc['type']
r['industry'] = industry or proc['industries'][0]
r['country'] = country or 'US'
r['cached'] = False
results.append(r)
except: pass
time.sleep(0.5)
# Cache fallback
for c in get_from_cache(processor_name, industry, country):
if c['domain'] not in seen and len(results) < max_results:
seen.add(c['domain'])
results.append(c)
return {
'processor': processor_name, 'type': proc['type'],
'industries': proc['industries'], 'dorks': proc['dorks'],
'result_count': len(results),
'live_results': sum(1 for r in results if not r.get('cached')),
'cached_results': sum(1 for r in results if r.get('cached')),
'results': results[:max_results]
}
def search_all(industry=None, country=None, max_per_processor=6):
all_results = {}
for name in PROCESSORS['processors']:
if industry and industry not in PROCESSORS['processors'][name]['industries']: continue
result = search_processor(name, industry, country, max_per_processor)
all_results[name] = result
time.sleep(0.2)
return all_results

289
static/index.html Normal file
View File

@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Payment Recon — Search Engine</title>
<style>
:root{--bg:#06060e;--card:#0e0e1a;--accent:#f7931a;--accent2:#00d4aa;--accent3:#6c63ff;--text:#e0e0e0;--muted:#777;--danger:#ff4757;--success:#2ed573;--warning:#ffa502;--radius:12px}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh;overflow-x:hidden}
body::before{content:'';position:fixed;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(circle at 30% 20%,rgba(108,99,255,0.03) 0%,transparent 50%),radial-gradient(circle at 70% 80%,rgba(247,147,26,0.03) 0%,transparent 50%);pointer-events:none;z-index:0}
.app{position:relative;z-index:1;max-width:1400px;margin:0 auto;padding:1rem}
/* Header */
header{text-align:center;padding:2rem 1rem 1.5rem}
header h1{font-size:2rem;font-weight:800;background:linear-gradient(135deg,var(--accent),var(--accent2),var(--accent3));-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-0.5px}
header p{color:var(--muted);font-size:0.85rem;margin-top:0.3rem}
/* Search Bar */
.search-bar{background:var(--card);border-radius:var(--radius);padding:0.7rem;display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;border:1px solid rgba(255,255,255,0.06);margin-bottom:1.5rem;position:sticky;top:1rem;z-index:10;backdrop-filter:blur(10px)}
.search-bar select,.search-bar button{background:rgba(0,0,0,0.4);color:var(--text);border:1px solid rgba(255,255,255,0.1);border-radius:6px;padding:0.5rem 0.8rem;font-size:0.78rem;cursor:pointer}
.search-bar select{min-width:150px}
.search-bar select:hover,.search-bar button:hover{border-color:var(--accent)}
.search-bar .btn-search{background:linear-gradient(135deg,var(--accent),#e6730d);color:#000;border:none;font-weight:700;padding:0.5rem 1.5rem;font-size:0.82rem}
.search-bar .btn-search:hover{filter:brightness(1.15)}
.search-bar .btn-search:disabled{opacity:0.5;cursor:not-allowed}
.search-bar .btn-scan{background:linear-gradient(135deg,var(--accent3),#8b5cf6);color:#fff;border:none;font-weight:700;padding:0.5rem 1.2rem;font-size:0.82rem}
/* Stats Bar */
.stats-bar{display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1rem;font-size:0.75rem;color:var(--muted)}
.stats-bar strong{color:var(--accent2)}
.stat-pill{background:var(--card);padding:0.3rem 0.7rem;border-radius:20px;border:1px solid rgba(255,255,255,0.05);font-size:0.68rem}
/* Results */
.results-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:0.8rem}
.site-card{background:var(--card);border-radius:var(--radius);overflow:hidden;border:1px solid rgba(255,255,255,0.05);transition:all 0.25s;display:flex;flex-direction:column}
.site-card:hover{transform:translateY(-2px);border-color:rgba(255,255,255,0.12);box-shadow:0 8px 30px rgba(0,0,0,0.3)}
.site-card .screenshot{height:180px;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative}
.site-card .screenshot img{width:100%;height:100%;object-fit:cover}
.site-card .screenshot .placeholder{font-size:2.5rem;opacity:0.3}
.site-card .body{padding:0.9rem;flex:1;display:flex;flex-direction:column}
.site-card .domain{font-weight:700;font-size:0.9rem;color:var(--accent2);margin-bottom:0.15rem}
.site-card .domain a{color:inherit;text-decoration:none}
.site-card .domain a:hover{text-decoration:underline}
.site-card .desc{font-size:0.72rem;color:var(--muted);line-height:1.4;margin:0.4rem 0;flex:1}
.site-card .tags{margin-top:0.4rem;display:flex;gap:0.3rem;flex-wrap:wrap}
.tag{display:inline-block;padding:0.15rem 0.45rem;border-radius:3px;font-size:0.6rem;font-weight:600}
.t-type{background:rgba(108,99,255,0.2);color:#a78bfa}
.t-industry{background:rgba(247,147,26,0.2);color:var(--accent)}
.t-country{background:rgba(0,212,170,0.15);color:var(--accent2);font-size:0.55rem}
.t-US{background:rgba(59,130,246,0.2);color:#60a5fa}
.t-EU{background:rgba(168,85,247,0.2);color:#a78bfa}
.t-INTL{background:rgba(251,191,36,0.2);color:#fbbf24}
.t-gambling{background:rgba(255,71,87,0.2);color:var(--danger)}
.t-ecommerce{background:rgba(0,212,170,0.2);color:var(--accent2)}
.t-adult{background:rgba(247,147,26,0.2);color:var(--accent)}
.t-electronics{background:rgba(59,130,246,0.2);color:#60a5fa}
.site-card .actions{margin-top:0.5rem;display:flex;gap:0.3rem}
.btn-sm{padding:0.25rem 0.6rem;border-radius:4px;border:none;cursor:pointer;font-size:0.65rem;font-weight:600}
.btn-visit{background:var(--accent2);color:#000}
.btn-visit:hover{filter:brightness(1.2)}
.btn-screenshot{background:rgba(108,99,255,0.2);color:#a78bfa;border:1px solid rgba(108,99,255,0.3)}
.btn-screenshot:hover{background:rgba(108,99,255,0.3)}
/* Loader */
.loader{text-align:center;padding:2rem}
.spinner{display:inline-block;width:30px;height:30px;border:3px solid rgba(255,255,255,0.1);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* Empty */
.empty{text-align:center;padding:3rem;color:var(--muted)}
.empty .icon{font-size:4rem;margin-bottom:1rem;opacity:0.3}
/* Responsive */
@media(max-width:768px){.results-grid{grid-template-columns:1fr}.search-bar{flex-direction:column;align-items:stretch}}
</style>
</head>
<body>
<div class="app">
<header>
<h1>🔍 Payment Recon</h1>
<p>Search the web for payment processors. Screenshots. AI descriptions. Organized by industry.</p>
</header>
<div class="search-bar">
<select id="processor-select">
<option value="">🎯 Select Processor</option>
</select>
<select id="industry-select">
<option value="">🏷️ All Industries</option>
</select>
<select id="country-select">
<option value="US" selected>🇺🇸 US Only</option>
<option value="">🌍 All Countries</option>
<option value="EU">🇪🇺 Europe</option>
<option value="INTL">🌍 International</option>
</select>
<button class="btn-search" onclick="doSearch()" id="btn-search">🔍 Search</button>
<button class="btn-scan" onclick="scanAll()" id="btn-scan">⚡ Scan All Processors</button>
<span style="font-size:0.68rem;color:var(--muted);margin-left:auto;" id="status-text">Ready</span>
</div>
<div class="stats-bar" id="stats-bar"></div>
<div class="results-grid" id="results"></div>
<div class="loader" id="loader" style="display:none"><div class="spinner"></div><p style="margin-top:0.5rem;font-size:0.75rem;color:var(--muted);">Searching...</p></div>
<div id="screenshot-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.85);z-index:100;align-items:center;justify-content:center;" onclick="this.style.display='none'">
<img id="screenshot-img" style="max-width:90vw;max-height:90vh;border-radius:8px;box-shadow:0 20px 60px rgba(0,0,0,0.5);">
</div>
</div>
<script>
let allProcessors = {};
let allIndustries = [];
let currentResults = [];
function safeId(str){return 'id-'+btoa(unescape(encodeURIComponent(str))).replace(/[+/=]/g,'')}
async function init(){
const r = await fetch('/api/processors');
const data = await r.json();
allProcessors = data.processors;
// Populate processor dropdown
const ps = document.getElementById('processor-select');
for(const [name, info] of Object.entries(allProcessors)){
ps.innerHTML += `<option value="${name}">${name} (${info.type})</option>`;
}
// Populate industry dropdown
allIndustries = data.industries;
const is = document.getElementById('industry-select');
for(const ind of allIndustries){
is.innerHTML += `<option value="${ind}">${ind}</option>`;
}
document.getElementById('stats-bar').innerHTML = `
<span class="stat-pill"><strong>${Object.keys(allProcessors).length}</strong> processors</span>
<span class="stat-pill"><strong>${allIndustries.length}</strong> industries</span>
`;
}
async function doSearch(){
const proc = document.getElementById('processor-select').value;
if(!proc) return alert('Select a processor first');
const industry = document.getElementById('industry-select').value;
const country = document.getElementById('country-select').value;
showLoader(true);
document.getElementById('status-text').textContent = `Searching for ${proc}...`;
const url = `/api/search/${encodeURIComponent(proc)}?industry=${encodeURIComponent(industry)}&country=${encodeURIComponent(country)}&max=12`;
const r = await fetch(url);
const data = await r.json();
currentResults = data.results || [];
renderResults(currentResults, proc);
showLoader(false);
document.getElementById('status-text').textContent = `${data.result_count} results for ${proc}`;
}
async function scanAll(){
if(!confirm('Scan all processors? This may take a minute.')) return;
showLoader(true);
const industry = document.getElementById('industry-select').value;
document.getElementById('status-text').textContent = 'Scanning all processors...';
const url = `/api/search-all?industry=${encodeURIComponent(industry)}&country=${encodeURIComponent(country)}`;
const r = await fetch(url);
const data = await r.json();
let all = [];
for(const [name, result] of Object.entries(data)){
if(result.results){
all = all.concat(result.results);
}
}
currentResults = all;
renderResults(currentResults, 'All Processors');
showLoader(false);
document.getElementById('status-text').textContent = `${all.length} results across all processors`;
}
function renderResults(results, procName){
const grid = document.getElementById('results');
if(!results.length){
grid.innerHTML = `<div class="empty"><div class="icon">🔍</div><p>No results found for ${procName}.</p><p style="font-size:0.7rem;margin-top:0.2rem;">Try a different processor or industry.</p></div>`;
return;
}
grid.innerHTML = results.map(s => {
const domain = s.domain || (()=>{try{return new URL(s.url||'').hostname.replace('www.','')}catch{return s.domain||'unknown'}})();
const proc = s.processor || procName;
const tags = [
`<span class="tag t-type">${s.processor_type||''}</span>`,
`<span class="tag t-industry t-${s.industry||'ecommerce'}">${s.industry||'ecommerce'}</span>`,
`<span class="tag t-country t-${s.country||'US'}">${s.country||'US'}</span>`
].join('');
return `
<div class="site-card">
<div class="screenshot" id="ss-${safeId(domain)}">
<span class="placeholder">🌐</span>
</div>
<div class="body">
<div class="domain"><a href="${s.url||'https://'+domain}" target="_blank">${domain}</a></div>
<div class="desc">${s.snippet||s.description||'Visit site for details.'}</div>
<div class="tags">${tags}</div>
<div class="actions">
<button class="btn-sm btn-visit" onclick="window.open('${s.url||'https://'+domain}','_blank')">Visit →</button>
<button class="btn-sm btn-screenshot" onclick="takeScreenshot('${domain}','${s.url||'https://'+domain}','${safeId(domain)}')">📸 Screenshot</button>
</div>
</div>
</div>
`;
}).join('');
document.getElementById('stats-bar').innerHTML = `
<span class="stat-pill"><strong>${results.length}</strong> results</span>
<span class="stat-pill">Processor: <strong>${procName}</strong></span>
${[...new Set(results.map(r=>r.industry))].map(i=>`<span class="stat-pill">${i}</span>`).join('')}
`;
}
async function takeScreenshot(domain, url, id){
const el = document.getElementById('ss-'+id);
el.innerHTML = '<div class="spinner" style="width:20px;height:20px;"></div>';
try{
// Queue analysis job
const r = await fetch('/api/analyze', {
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify({sites:[{domain,url}],processor:currentResults[0]?.processor||'Unknown'})
});
const {job_id} = await r.json();
// Poll for result
let attempts=0;
while(attempts<30){
await new Promise(r=>setTimeout(r,1000));
const jr = await fetch(`/api/job/${job_id}`);
const job = await jr.json();
if(job.status==='complete'){
const result = job.results[0];
if(result?.description){
// Update description
for(const card of document.querySelectorAll('.site-card')){
if(card.querySelector('.domain a')?.textContent === domain){
card.querySelector('.desc').textContent = result.description;
}
}
}
if(result?.screenshot_base64){
el.innerHTML = `<img src="data:image/png;base64,${result.screenshot_base64}" style="width:100%;height:100%;object-fit:cover" onclick="event.stopPropagation();showFullScreenshot('${result.screenshot_base64}')">`;
} else {
el.innerHTML = '<span class="placeholder">📸</span>';
}
break;
}
attempts++;
}
if(attempts>=30) el.innerHTML = '<span class="placeholder">⏰</span>';
}catch(e){
el.innerHTML = '<span class="placeholder">❌</span>';
console.error(e);
}
}
function showLoader(show){document.getElementById('loader').style.display=show?'block':'none'}
function showFullScreenshot(b64){
const modal = document.getElementById('screenshot-modal');
const img = document.getElementById('screenshot-img');
img.src = `data:image/png;base64,${b64}`;
modal.style.display = 'flex';
}
init();
</script>
<div style="text-align:center;padding:1rem;margin-top:1.5rem;border-top:1px solid rgba(255,255,255,0.05)">
<a href="https://buymeacoffee.com/r26xrthzttg" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;gap:0.5rem;background:linear-gradient(135deg,#FF813F,#FF5E0E);color:#fff;padding:0.45rem 1.1rem;border-radius:25px;text-decoration:none;font-weight:600;font-size:0.75rem;transition:all 0.2s;box-shadow:0 3px 12px rgba(255,94,14,0.25)" onmouseover="this.style.transform='scale(1.04)';this.style.boxShadow='0 5px 18px rgba(255,94,14,0.4)'" onmouseout="this.style.transform='scale(1)';this.style.boxShadow='0 3px 12px rgba(255,94,14,0.25)'">
<span style="font-size:1rem">☕</span> Support This Project — Buy Me a Coffee
</a>
</div>
</body>
</html>

87
vision.py Normal file
View File

@@ -0,0 +1,87 @@
"""Screenshot & Vision Analysis — takes screenshots and classifies with local LLM."""
import base64, json, subprocess, tempfile, os, urllib.request, time
OLLAMA_URL = "http://10.30.20.186:11434"
VISION_MODEL = "minicpm-v4.5:8b" # Best local vision model available
def take_screenshot(url, width=1280, height=900):
"""Take a screenshot of a URL using Playwright."""
try:
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': width, 'height': height})
page.goto(url, wait_until='networkidle', timeout=15000)
time.sleep(1) # Let page settle
screenshot = page.screenshot(full_page=False)
browser.close()
return screenshot
except ImportError:
# Fallback: use macos screencapture via Safari (less reliable)
return None
except Exception as e:
print(f"Screenshot error for {url}: {e}")
return None
def screenshot_to_base64(screenshot_bytes):
"""Convert screenshot bytes to base64 for Ollama."""
return base64.b64encode(screenshot_bytes).decode('utf-8')
def analyze_screenshot(url, screenshot_bytes, processor_name):
"""Analyze a screenshot using local vision model to describe the site."""
if not screenshot_bytes:
return generate_placeholder_description(url, processor_name)
img_b64 = screenshot_to_base64(screenshot_bytes)
prompt = f"""Look at this screenshot of a website. The site likely uses {processor_name} as a payment processor.
Describe this site in 2-3 sentences covering:
1. What type of site is this? (e-commerce, gambling, SaaS, adult, CBD, firearms, subscription, crypto, etc.)
2. What does it sell or offer?
3. What's the overall vibe/quality level?
Respond with just the description, no prefixes."""
try:
req = urllib.request.Request(
f"{OLLAMA_URL}/api/generate",
data=json.dumps({
"model": VISION_MODEL,
"prompt": prompt,
"images": [img_b64],
"stream": False,
"options": {"temperature": 0.3, "num_predict": 150}
}).encode(),
headers={"Content-Type": "application/json"}
)
resp = urllib.request.urlopen(req, timeout=30)
data = json.loads(resp.read())
return data.get('response', '').strip()
except Exception as e:
print(f"Vision analysis error: {e}")
return generate_placeholder_description(url, processor_name)
def generate_placeholder_description(url, processor_name):
"""Generate a description when vision isn't available."""
domain = urllib.parse.urlparse(url).netloc.replace('www.', '')
return f"Website at {domain}. Uses {processor_name} for payment processing. Visit the site to learn more about their products and services."
def batch_analyze(sites, processor_name, max_screenshots=5):
"""Analyze multiple sites with screenshots."""
results = []
for i, site in enumerate(sites[:max_screenshots]):
url = site.get('url', '')
if not url:
continue
print(f" Screenshot {i+1}/{min(len(sites), max_screenshots)}: {site.get('domain','?')}")
screenshot = take_screenshot(url)
description = analyze_screenshot(url, screenshot, processor_name)
results.append({
**site,
'description': description,
'screenshot_available': screenshot is not None,
'screenshot_base64': screenshot_to_base64(screenshot) if screenshot else None
})
return results