From 2fb416ac33a90d51b75b888b19ad4ec9a173379e Mon Sep 17 00:00:00 2001 From: kory Date: Mon, 12 Oct 2026 08:21:40 +0800 Subject: [PATCH] feat: extract address tool into standalone project --- .env.example | 4 + .gitignore | 9 + README.md | 43 ++ deploy/address-tool.service.example | 22 + index.html | 802 ++++++++++++++++++++++++++++ server.py | 278 ++++++++++ 6 files changed, 1158 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 deploy/address-tool.service.example create mode 100644 index.html create mode 100644 server.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..076ae07 --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +# Optional: FakeNameGenerator API key. Leave empty to use the fallback provider. +FNG_API_KEY= +# HTTP listen port (default 18990). +ADDRESS_TOOL_PORT=18990 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7f92cdc --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +*.log +*.bak* +.wrangler/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..ba73f1d --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# Address Tool + +A small Python standard-library HTTP service with a static web UI for address/profile test-data generation. + +## Contents + +- `server.py` — HTTP server and API proxy/fallback logic. +- `index.html` — browser UI; calls the local `/api/v1/dz` endpoint. +- `.env.example` — documented optional runtime environment variables. +- `deploy/address-tool.service.example` — example systemd unit for this repository layout. + +## Requirements + +- Python 3.10+ (standard library only; no `pip install` required). +- Network access to configured upstream services if generation is to use them. +- The page currently loads the Cap.js widget from jsDelivr and uses the CAPTCHA endpoint configured in `index.html`; those remain external services, not bundled code. + +## Run locally + +```sh +cp .env.example .env +# Optional: edit .env and set FNG_API_KEY. +set -a +. ./.env +set +a +python3 server.py +``` + +The service listens on `0.0.0.0:${ADDRESS_TOOL_PORT:-18990}`. Open `http://127.0.0.1:18990/` on the same host. Do not expose this unauthenticated service directly to the public internet without an appropriate access-control/reverse-proxy layer. + +`FNG_API_KEY` is optional. When absent, the service skips the FakeNameGenerator API path and relies on the existing fallback provider. Never commit `.env` or API credentials. + +## Existing deployment + +The currently running production unit still points to `/root/.openclaw/workspace/address_tool`. This repository is an isolated extraction and does not change or restart that service. Review and test the new copy before migrating the systemd unit. + +## External services + +- FakeNameGenerator API (optional, when `FNG_API_KEY` is configured) +- `https://www.meiguodizhi.com/api/v1/dz` fallback API +- Cap.js widget/CDN configured in `index.html` + +Check each provider's terms, availability, and privacy requirements before production use. diff --git a/deploy/address-tool.service.example b/deploy/address-tool.service.example new file mode 100644 index 0000000..2b079fd --- /dev/null +++ b/deploy/address-tool.service.example @@ -0,0 +1,22 @@ +[Unit] +Description=Address Tool API Service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/opt/address-tool +EnvironmentFile=-/etc/address-tool/address-tool.env +ExecStart=/usr/bin/python3 /opt/address-tool/server.py +Restart=on-failure +RestartSec=3 +User=address-tool +Group=address-tool +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ReadWritePaths=/opt/address-tool + +[Install] +WantedBy=multi-user.target diff --git a/index.html b/index.html new file mode 100644 index 0000000..054694a --- /dev/null +++ b/index.html @@ -0,0 +1,802 @@ + + + + + + 快速地址与身份生成器 + + + + +
+
+

快速地址与身份生成

+

点击一键生成身份、地址与安全信息

+
+ + +
+ + +
+ +
+ +
+ +
+ +
+ + + + + + + + +
+ +
已复制到剪贴板
+ + + + diff --git a/server.py b/server.py new file mode 100644 index 0000000..4cf7591 --- /dev/null +++ b/server.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +import http.server +import json +import os +import random +import re +import socketserver +import string +import threading +import time +import urllib.error +import urllib.request + +PORT = int(os.getenv("ADDRESS_TOOL_PORT", "18990")) +WEB_DIR = os.path.dirname(os.path.abspath(__file__)) +UPSTREAM_API = "https://www.meiguodizhi.com/api/v1/dz" +FNG_API_KEY = os.getenv("FNG_API_KEY", "").strip() +FNG_BASE_URL = "https://www.fakenamegenerator.com/api/v1" + +FIRST_NAMES = [ + "James", "John", "Robert", "Michael", "William", "David", "Richard", "Joseph", "Thomas", "Charles", + "Christopher", "Daniel", "Matthew", "Anthony", "Mark", "Donald", "Steven", "Paul", "Andrew", "Joshua", + "Kenneth", "Kevin", "Brian", "George", "Edward", "Ronald", "Timothy", "Jason", "Jeffrey", "Ryan", + "Jacob", "Gary", "Nicholas", "Eric", "Jonathan", "Stephen", "Larry", "Justin", "Scott", "Brandon", + "Mary", "Patricia", "Jennifer", "Linda", "Elizabeth", "Barbara", "Susan", "Jessica", "Sarah", "Karen", + "Lisa", "Nancy", "Betty", "Margaret", "Sandra", "Ashley", "Kimberly", "Emily", "Donna", "Michelle", + "Carol", "Amanda", "Melissa", "Deborah", "Stephanie", "Rebecca", "Sharon", "Laura", "Cynthia", "Kathleen" +] + +LAST_NAMES = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", "Rodriguez", "Martinez", + "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Jackson", "Martin", + "Lee", "Perez", "Thompson", "White", "Harris", "Sanchez", "Clark", "Ramirez", "Lewis", "Robinson", + "Walker", "Young", "Allen", "King", "Wright", "Scott", "Torres", "Nguyen", "Hill", "Flores" +] + +US_STATE_CODES = { + "alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA", + "colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA", + "hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA", + "kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD", + "massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO", + "montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ", + "new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH", + "oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC", + "south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT", + "virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY", + "district of columbia": "DC" +} + +MIN_COOLDOWN_SECONDS = 0.5 +REQUEST_TIMEOUT = 10 +RATE_LIMIT_COOLDOWN = 15 + +_lock = threading.Lock() +_is_requesting = False +_last_request_time = 0.0 +_cooldown_until = 0.0 + +def generate_secure_password(length=14): + upper = random.choice(string.ascii_uppercase) + lower = random.choice(string.ascii_lowercase) + digit = random.choice(string.digits) + chars = string.ascii_letters + string.digits + rest = "".join(random.choices(chars, k=length - 3)) + pwd_list = list(upper + lower + digit + rest) + random.shuffle(pwd_list) + return "".join(pwd_list) + +def resolve_us_state(city_str): + if not city_str: + return "" + c_lower = city_str.strip().lower() + if len(c_lower) == 2 and c_lower.upper() in US_STATE_CODES.values(): + return c_lower.upper() + for name, code in US_STATE_CODES.items(): + if name in c_lower or c_lower == name: + return code + return "" + +def transform_fng_to_dz(item): + name_obj = item.get("name", {}) + addr_obj = item.get("address", {}) + card_obj = item.get("credit_card", {}) + bday_obj = item.get("birthday", {}) + height_obj = item.get("height", {}) + weight_obj = item.get("weight", {}) + + first = name_obj.get("first", "") + last = name_obj.get("last", "") + full = name_obj.get("full", f"{first} {last}".strip()) + + # 格式化过期时间 YYYY-MM -> MM/YYYY + expires_raw = card_obj.get("expires", "") + expires = expires_raw + if "-" in expires_raw: + parts = expires_raw.split("-") + if len(parts) == 2: + expires = f"{parts[1]}/{parts[0]}" + + height_str = "" + if height_obj.get("in") and height_obj.get("cm"): + height_str = f"{height_obj.get('in')}\" ({height_obj.get('cm')}cm)" + + weight_str = "" + if weight_obj.get("lb") and weight_obj.get("kg"): + weight_str = f"{weight_obj.get('lb')} lbs ({weight_obj.get('kg')}kg)" + + transformed = { + "Address": addr_obj.get("street", ""), + "Telephone": item.get("phone", {}).get("number", ""), + "Address_Alias": "", + "Trans_Address": "", + "Trans_Cn_Address": "", + "Fax": "", + "City": addr_obj.get("city", ""), + "Zip_Code": addr_obj.get("postal_code", ""), + "rowkey": item.get("id", ""), + "State": addr_obj.get("state", ""), + "State_Full": addr_obj.get("state", ""), + "Expires": expires, + "Credit_Card_Type": card_obj.get("network", "Visa"), + "Credit_Card_Number": card_obj.get("number", ""), + "CVV2": str(card_obj.get("cvv", "")), + "Full_Name": full, + "Gender": (item.get("gender") or "Male").capitalize(), + "Full_Name_Tran": "", + "Title": name_obj.get("title", "Mr."), + "Birthday": bday_obj.get("date", ""), + "Username": item.get("username", ""), + "Password": item.get("password", ""), + "Height": height_str, + "Weight": weight_str, + "Temporary_mail": item.get("email", ""), + "System": "Windows 11", + "GUID": item.get("id", ""), + "Blood_Type": item.get("blood_type", "O+"), + "Browser_User_Agent": item.get("user_agent", ""), + "Occupation": item.get("occupation", ""), + "Company_Name": item.get("company", ""), + "Social_Security_Number": item.get("national_id", {}).get("value", "") if item.get("national_id") else "", + "First_Name": first, + "Last_Name": last, + "Generated_Password": generate_secure_password(14), + "_source": "fakenamegenerator.com" + } + return transformed + +def fetch_from_fng(city_or_state): + url = f"{FNG_BASE_URL}/identities?count=1&country=us" + state_code = resolve_us_state(city_or_state) + if state_code: + url += f"&state={state_code}" + + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {FNG_API_KEY}", + "User-Agent": "OpenClaw-AddressTool/2.0" + } + ) + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as response: + if response.status == 200: + res_data = json.loads(response.read().decode("utf-8")) + if res_data.get("data") and len(res_data["data"]) > 0: + addr_transformed = transform_fng_to_dz(res_data["data"][0]) + return {"status": "ok", "address": addr_transformed} + return None + +class ProxyHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=WEB_DIR, **kwargs) + + def send_standard_json(self, status_code, data): + self.send_response(status_code) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(json.dumps(data, ensure_ascii=False).encode("utf-8")) + + def do_POST(self): + global _is_requesting, _last_request_time, _cooldown_until + + if self.path == "/api/v1/dz" or self.path == "/api/generate": + content_length = int(self.headers.get("Content-Length", 0)) + post_data = self.rfile.read(content_length) + + now = time.time() + with _lock: + if _is_requesting: + self.send_standard_json(429, { + "status": "busy", + "error_code": "ERR_CONCURRENT_REQUEST", + "message": "正在处理上一条生成请求,请稍候", + "retry_after": 1 + }) + return + _is_requesting = True + + try: + payload = {} + try: + payload = json.loads(post_data.decode("utf-8")) + except Exception: + pass + + city = payload.get("city", "") + + # 优先调用官方 FakeNameGenerator API + if FNG_API_KEY: + try: + fng_result = fetch_from_fng(city) + if fng_result: + self.send_standard_json(200, fng_result) + return + except Exception as e: + print(f"[WARN] FNG API failed, falling back to upstream: {e}") + + # 降级备用:原有的 meiguodizhi.com 接口 + req = urllib.request.Request( + UPSTREAM_API, + data=post_data, + headers={ + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", + "Referer": "https://www.meiguodizhi.com/" + } + ) + + try: + with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT) as response: + raw_body = response.read() + data = json.loads(raw_body.decode("utf-8")) + if data.get("status") == "ok" and "address" in data: + addr = data["address"] + orig_fn = addr.get("Full_Name", "") + # 格式化 + cleaned = re.sub(r"[^a-zA-Z\s]", "", orig_fn).strip() + words = [w.capitalize() for w in cleaned.split() if w] + if len(words) >= 2: + fn = f"{words[0]} {words[-1]}" + addr["First_Name"] = words[0] + addr["Last_Name"] = words[-1] + else: + fn = f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}" + p = fn.split() + addr["First_Name"] = p[0] + addr["Last_Name"] = p[1] + addr["Full_Name"] = fn + addr["Generated_Password"] = generate_secure_password(14) + addr["_source"] = "meiguodizhi.com" + + self.send_standard_json(200, data) + except Exception as e: + self.send_standard_json(500, { + "status": "error", + "message": f"生成失败: {str(e)}" + }) + + finally: + with _lock: + _is_requesting = False + _last_request_time = time.time() + else: + self.send_error(404, "Not Found") + + def do_OPTIONS(self): + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + self.end_headers() + +if __name__ == "__main__": + socketserver.TCPServer.allow_reuse_address = True + with socketserver.TCPServer(("0.0.0.0", PORT), ProxyHandler) as httpd: + print(f"Address tool server running on port {PORT}") + httpd.serve_forever()