基础打标控制台

This commit is contained in:
yml2213
2026-09-11 22:09:35 +08:00
parent 424d547417
commit f1c664f622
6 changed files with 902 additions and 0 deletions
+242
View File
@@ -0,0 +1,242 @@
"""
腾讯天御点选验证码 (Tencent Turing Captcha) 样本纯协议采集器
通过模拟前端两步 HTTP 接口直接拉取高清原图:
1. GET /cap_union_prehandle 获取初始会话 sess
2. POST /cap_union_new_getsig 获取题目配置与背景/提示图 URL
3. 并行下载 672x480 背景大图与 170x50 提示图标图
"""
import argparse
import asyncio
import json
import logging
import os
import random
import re
import time
from pathlib import Path
from typing import Optional, Tuple
import httpx
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S",
)
logger = logging.getLogger("tcaptcha_collector")
class TencentCaptchaCollector:
BASE_URL = "https://turing.captcha.qcloud.com"
DEFAULT_AID = "2039519451"
def __init__(self, aid: str = DEFAULT_AID, output_dir: str = "tcaptcha/data/raw", timeout: float = 10.0):
self.aid = aid
self.output_dir = Path(output_dir)
self.output_dir.mkdir(parents=True, exist_ok=True)
self.index_file = self.output_dir / "index.jsonl"
self.timeout = timeout
self.client = httpx.Client(
base_url=self.BASE_URL,
trust_env=False,
headers={
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36"
),
"Referer": "https://turing.captcha.qcloud.com/",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
},
timeout=self.timeout,
follow_redirects=True,
)
def _fetch_session(self) -> Tuple[Optional[str], Optional[str]]:
"""第一步:预检请求获取 sess"""
cb_name = f"_aq_{random.randint(100000, 999999)}"
tkid = str(random.randint(100000000, 999999999))
ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2"
params = {
"aid": self.aid,
"protocol": "https",
"accver": "1",
"showtype": "popup",
"ua": ua_b64,
"noheader": "1",
"fb": "1",
"grayscale": "1",
"clientype": "2",
"lang": "zh-cn",
"js": "/tcaptcha-frame.ba387dd1.js",
"wb": "2",
"tkid": tkid,
"subsid": "1",
"callback": cb_name,
}
resp = self.client.get("/cap_union_prehandle", params=params)
if resp.status_code != 200:
logger.error("prehandle failed with status %d", resp.status_code)
return None, None
match = re.search(rf"{cb_name}\((.*)\)", resp.text)
if not match:
logger.error("Failed to parse prehandle response: %s", resp.text[:100])
return None, None
try:
data = json.loads(match.group(1))
sess = data.get("sess")
return sess, tkid
except Exception as e:
logger.error("Failed to decode JSON from prehandle: %s", e)
return None, None
def _fetch_sig_and_images(self, sess: str, tkid: str) -> Optional[dict]:
"""第二步:请求题目信息并下载图片对"""
resp = self.client.post(
"/cap_union_new_getsig",
data={"sess": sess, "tkid": tkid},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
if resp.status_code != 200:
logger.error("getsig failed with status %d", resp.status_code)
return None
try:
res_json = resp.json()
except Exception as e:
logger.error("Failed to decode getsig JSON: %s", e)
return None
if str(res_json.get("ret")) != "0":
logger.warning("getsig returned non-zero ret: %s", res_json)
return None
cfg = res_json.get("data", {})
instruction = cfg.get("instruction", "请依次点击:")
bg_cfg = cfg.get("bg_elem_cfg", {})
img_url_bg = bg_cfg.get("img_url", "")
if not img_url_bg:
logger.warning("No img_url found in getsig response")
return None
img_url_prompt = img_url_bg.replace("img_index=1", "img_index=0")
# 下载背景大图 (672x480) 与 提示图 (170x50)
bg_resp = self.client.get(img_url_bg)
prompt_resp = self.client.get(img_url_prompt)
if bg_resp.status_code != 200 or prompt_resp.status_code != 200:
logger.error("Failed to download image pairs")
return None
return {
"instruction": instruction,
"bg_bytes": bg_resp.content,
"prompt_bytes": prompt_resp.content,
"img_url_bg": img_url_bg,
"img_url_prompt": img_url_prompt,
"sess": sess,
"tkid": tkid,
}
def fetch_one(self) -> Optional[str]:
"""采集单组样本并保存到本地"""
sess, tkid = self._fetch_session()
if not sess or not tkid:
return None
data = self._fetch_sig_and_images(sess, tkid)
if not data:
return None
sample_id = f"{int(time.time() * 1000)}_{random.randint(1000, 9999)}"
bg_filename = f"{sample_id}_bg.png"
prompt_filename = f"{sample_id}_prompt.png"
meta_filename = f"{sample_id}_meta.json"
bg_path = self.output_dir / bg_filename
prompt_path = self.output_dir / prompt_filename
meta_path = self.output_dir / meta_filename
bg_path.write_bytes(data["bg_bytes"])
prompt_path.write_bytes(data["prompt_bytes"])
meta_info = {
"sample_id": sample_id,
"timestamp": time.time(),
"aid": self.aid,
"instruction": data["instruction"],
"bg_file": bg_filename,
"prompt_file": prompt_filename,
"bg_size": len(data["bg_bytes"]),
"prompt_size": len(data["prompt_bytes"]),
"sess": data["sess"],
"tkid": data["tkid"],
}
meta_path.write_text(json.dumps(meta_info, ensure_ascii=False, indent=2), encoding="utf-8")
# 写入 index.jsonl
with open(self.index_file, "a", encoding="utf-8") as f:
f.write(json.dumps(meta_info, ensure_ascii=False) + "\n")
logger.info(
"✓ 采集成功 [%s]: 大图 %d KB, 提示图 %d KB | 提示: %s",
sample_id,
len(data["bg_bytes"]) // 1024,
len(data["prompt_bytes"]) // 1024,
data["instruction"],
)
return sample_id
def collect_batch(self, count: int = 10, delay: float = 0.8) -> int:
"""批量采集样本"""
logger.info("开始批量采集点选验证码样本,目标数量: %d, 间隔: %.2fs", count, delay)
success_count = 0
for i in range(count):
try:
sid = self.fetch_one()
if sid:
success_count += 1
else:
logger.warning("第 %d/%d 个样本采集失败,准备重试", i + 1, count)
except Exception as e:
logger.error("采集异常: %s", e)
if i < count - 1 and delay > 0:
# 随机微弱浮动,模拟正常采集
actual_delay = delay + random.uniform(-0.2, 0.3)
time.sleep(max(0.2, actual_delay))
logger.info("批量采集完成: 成功 %d/%d (存储目录: %s)", success_count, count, self.output_dir)
return success_count
def close(self):
self.client.close()
def main():
parser = argparse.ArgumentParser(description="腾讯天御点选验证码纯协议批量采集器")
parser.add_argument("--count", type=int, default=10, help="采集样本数量 (默认: 10)")
parser.add_argument("--delay", type=float, default=0.8, help="每次请求间隔秒数 (默认: 0.8)")
parser.add_argument("--aid", type=str, default=TencentCaptchaCollector.DEFAULT_AID, help="业务 AppID")
parser.add_argument("--output-dir", type=str, default="tcaptcha/data/raw", help="保存路径")
args = parser.parse_args()
collector = TencentCaptchaCollector(aid=args.aid, output_dir=args.output_dir)
try:
collector.collect_batch(count=args.count, delay=args.delay)
finally:
collector.close()
if __name__ == "__main__":
main()
+86
View File
@@ -0,0 +1,86 @@
"""
提示词图像自动切割模块
将 170x50 的提示图自动切分为 3 个独立的图标小图
"""
from pathlib import Path
from typing import List, Tuple
import numpy as np
from PIL import Image
def slice_prompt_image(prompt_img_path: str | Path, padding: int = 2) -> List[Tuple[Image.Image, Tuple[int, int, int, int]]]:
"""
自动识别并切割提示图中的 3 个字符/图标
返回: [(PIL_Image, (x1, y1, x2, y2)), ...]
"""
img = Image.open(prompt_img_path).convert("L")
arr = np.array(img)
h, w = arr.shape
# 背景灰度基准 (通常取左上角像素)
bg_val = int(arr[0, 0])
# 提取暗色笔画像素(排除灰色背景)
mask = arr < (bg_val - 25)
# 水平方向投影,找出连续包含笔画的列区间
cols_has_dark = mask.any(axis=0)
segments: List[Tuple[int, int]] = []
in_seg = False
start = 0
for i, val in enumerate(cols_has_dark):
if val and not in_seg:
in_seg = True
start = i
elif not val and in_seg:
in_seg = False
if i - start >= 5: # 过滤过窄杂点
segments.append((start, i))
if in_seg and (w - start >= 5):
segments.append((start, w))
# 若投影分割不足 3 段(极罕见),按固定 3 等分兜底
if len(segments) != 3:
step = w // 3
segments = [(i * step + 5, (i + 1) * step - 5) for i in range(3)]
# 原始彩色图用于切片
orig_img = Image.open(prompt_img_path).convert("RGBA")
crops = []
for seg_start, seg_end in segments[:3]:
# 纵向精确裁剪
sub_mask = mask[:, seg_start:seg_end]
rows_has_dark = sub_mask.any(axis=1)
row_indices = np.where(rows_has_dark)[0]
if len(row_indices) > 0:
top = max(0, int(row_indices[0]) - padding)
bottom = min(h, int(row_indices[-1]) + 1 + padding)
else:
top, bottom = 0, h
left = max(0, seg_start - padding)
right = min(w, seg_end + padding)
box = (left, top, right, bottom)
crop = orig_img.crop(box)
crops.append((crop, box))
return crops
if __name__ == "__main__":
import sys
sample = sys.argv[1] if len(sys.argv) > 1 else "tcaptcha/data/raw/1789135559041_4170_prompt.png"
if Path(sample).exists():
results = slice_prompt_image(sample)
print(f"切片完成,切出 {len(results)} 个目标:")
for idx, (c, b) in enumerate(results):
print(f" [{idx+1}] 尺寸: {c.size}, 边界框: {b}")
else:
print(f"样本文件不存在: {sample}")
+493
View File
@@ -0,0 +1,493 @@
"""
极简本地 Web 点选验证码打标工具
启动方式: uv run python tcaptcha/web_labeler.py --port 8765
访问地址: http://127.0.0.1:8765
"""
import argparse
import base64
import json
import logging
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from pathlib import Path
from urllib.parse import parse_qs, urlparse
from PIL import Image
try:
from tcaptcha.slicer import slice_prompt_image
except ImportError:
from slicer import slice_prompt_image
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger("web_labeler")
RAW_DIR = Path("tcaptcha/data/raw")
LABEL_FILE = Path("tcaptcha/data/labels.jsonl")
CROPS_DIR = Path("tcaptcha/data/crops")
def get_labeled_sample_ids() -> set:
if not LABEL_FILE.exists():
return set()
labeled = set()
with open(LABEL_FILE, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line:
try:
record = json.loads(line)
labeled.add(record.get("sample_id"))
except Exception:
pass
return labeled
def get_all_samples() -> list:
if not RAW_DIR.exists():
return []
meta_files = sorted(RAW_DIR.glob("*_meta.json"))
samples = []
for mf in meta_files:
try:
meta = json.loads(mf.read_text(encoding="utf-8"))
samples.append(meta)
except Exception:
pass
return samples
def get_sample_data(sample_id: str):
bg_path = RAW_DIR / f"{sample_id}_bg.png"
prompt_path = RAW_DIR / f"{sample_id}_prompt.png"
meta_path = RAW_DIR / f"{sample_id}_meta.json"
if not bg_path.exists() or not prompt_path.exists() or not meta_path.exists():
return None
meta = json.loads(meta_path.read_text(encoding="utf-8"))
# 切割提示图标
crops = slice_prompt_image(prompt_path)
sliced_b64 = []
for idx, (crop_img, _) in enumerate(crops):
buf = BytesIO()
crop_img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode("utf-8")
sliced_b64.append(f"data:image/png;base64,{b64}")
bg_b64 = base64.b64encode(bg_path.read_bytes()).decode("utf-8")
prompt_b64 = base64.b64encode(prompt_path.read_bytes()).decode("utf-8")
return {
"sample_id": sample_id,
"instruction": meta.get("instruction", "请依次点击:"),
"bg_b64": f"data:image/png;base64,{bg_b64}",
"prompt_b64": f"data:image/png;base64,{prompt_b64}",
"sliced_prompts": sliced_b64,
}
HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>腾讯天御点选验证码 - 标注工作台</title>
<style>
:root {
--bg: #0f172a;
--card: #1e293b;
--border: #334155;
--text: #f8fafc;
--muted: #94a3b8;
--accent: #38bdf8;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
.header {
width: 720px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.title { font-size: 20px; font-weight: 700; color: var(--accent); }
.stats { font-size: 14px; color: var(--muted); }
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 20px;
width: 720px;
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.5);
}
.prompt-section {
display: flex;
align-items: center;
justify-content: space-between;
background: #090d16;
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 18px;
margin-bottom: 16px;
}
.prompt-label { font-size: 15px; font-weight: 600; color: #e2e8f0; }
.targets-container {
display: flex;
gap: 16px;
align-items: center;
}
.target-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.target-badge {
font-size: 12px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
background: #334155;
color: #cbd5e1;
}
.target-item:nth-child(1) .target-badge { background: #0284c7; color: #fff; }
.target-item:nth-child(2) .target-badge { background: #16a34a; color: #fff; }
.target-item:nth-child(3) .target-badge { background: #d97706; color: #fff; }
.target-img {
height: 48px;
background: #cbd5e1;
border-radius: 4px;
padding: 2px;
}
.canvas-wrapper {
position: relative;
width: 672px;
height: 480px;
margin: 0 auto;
border-radius: 8px;
overflow: hidden;
cursor: crosshair;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
#bgCanvas {
position: absolute;
left: 0;
top: 0;
width: 672px;
height: 480px;
}
.toolbar {
margin-top: 18px;
display: flex;
justify-content: space-between;
align-items: center;
}
.btn-group { display: flex; gap: 10px; }
button {
padding: 8px 16px;
border-radius: 6px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary { background: #0284c7; color: white; }
.btn-primary:hover { background: #0369a1; }
.btn-primary:disabled { background: #334155; cursor: not-allowed; opacity: 0.6; }
.btn-secondary { background: #334155; color: #cbd5e1; }
.btn-secondary:hover { background: #475569; }
.btn-danger { background: #dc2626; color: white; }
.btn-danger:hover { background: #b91c1c; }
.points-display {
font-size: 13px;
color: var(--muted);
font-family: monospace;
}
.shortcut-tip {
font-size: 12px;
color: #64748b;
margin-top: 12px;
text-align: center;
}
</style>
</head>
<body>
<div class="header">
<div class="title">🎯 腾讯天御点选验证码 · 标注工具</div>
<div class="stats" id="statsInfo">加载中...</div>
</div>
<div class="card" id="mainCard">
<div class="prompt-section">
<div class="prompt-label" id="instructionText">请依次点击:</div>
<div class="targets-container" id="targetsBox"></div>
</div>
<div class="canvas-wrapper" id="canvasWrapper">
<canvas id="bgCanvas" width="672" height="480"></canvas>
</div>
<div class="toolbar">
<div class="points-display" id="pointsText">已标记: 0 / 3 点</div>
<div class="btn-group">
<button class="btn-secondary" onclick="undoPoint()">撤销 (Z)</button>
<button class="btn-secondary" onclick="resetPoints()">重选 (R)</button>
<button class="btn-secondary" onclick="skipSample()">跳过 (S)</button>
<button class="btn-primary" id="saveBtn" onclick="submitLabel()" disabled>保存并下一题 (Enter)</button>
</div>
</div>
<div class="shortcut-tip">快捷键: 点击大图标记坐标 | [Z] 撤销 | [R] 重置 | [S] 跳过 | [Enter/Space] 保存</div>
</div>
<script>
let currentSample = null;
let clickedPoints = [];
const canvas = document.getElementById("bgCanvas");
const ctx = canvas.getContext("2d");
let bgImage = new Image();
const COLORS = ["#0284c7", "#16a34a", "#d97706"];
async function loadNext() {
const res = await fetch("/api/next");
const data = await res.json();
if (data.finished) {
alert("🎉 所有已采集样本标注完成!");
return;
}
currentSample = data.sample;
document.getElementById("statsInfo").innerText = `已标注: ${data.stats.labeled} / 总样本: ${data.stats.total}`;
document.getElementById("instructionText").innerText = currentSample.instruction || "请依次点击:";
// 渲染提示图标
const container = document.getElementById("targetsBox");
container.innerHTML = "";
currentSample.sliced_prompts.forEach((src, i) => {
const item = document.createElement("div");
item.className = "target-item";
item.innerHTML = `
<span class="target-badge">目标 ${i+1}</span>
<img class="target-img" src="${src}">
`;
container.appendChild(item);
});
// 渲染背景图
clickedPoints = [];
updateDisplay();
bgImage.onload = () => drawCanvas();
bgImage.src = currentSample.bg_b64;
}
function drawCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(bgImage, 0, 0, canvas.width, canvas.height);
// 绘制点击点
clickedPoints.forEach((pt, i) => {
const color = COLORS[i] || "#ef4444";
// 外圆
ctx.beginPath();
ctx.arc(pt.x, pt.y, 16, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = "#ffffff";
ctx.stroke();
// 内部数字
ctx.fillStyle = "#ffffff";
ctx.font = "bold 13px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(`${i+1}`, pt.x, pt.y);
});
}
canvas.addEventListener("click", (e) => {
if (!currentSample || clickedPoints.length >= 3) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = Math.round((e.clientX - rect.left) * scaleX);
const y = Math.round((e.clientY - rect.top) * scaleY);
clickedPoints.push({ x, y });
drawCanvas();
updateDisplay();
});
function updateDisplay() {
const saveBtn = document.getElementById("saveBtn");
document.getElementById("pointsText").innerText = `已标记: ${clickedPoints.length} / 3 点 ${JSON.stringify(clickedPoints.map(p => [p.x, p.y]))}`;
saveBtn.disabled = clickedPoints.length !== 3;
}
function undoPoint() {
if (clickedPoints.length > 0) {
clickedPoints.pop();
drawCanvas();
updateDisplay();
}
}
function resetPoints() {
clickedPoints = [];
drawCanvas();
updateDisplay();
}
function skipSample() {
loadNext();
}
async function submitLabel() {
if (clickedPoints.length !== 3) return;
const payload = {
sample_id: currentSample.sample_id,
points: clickedPoints,
};
const res = await fetch("/api/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (res.ok) {
loadNext();
} else {
alert("保存失败,请检查终端日志");
}
}
window.addEventListener("keydown", (e) => {
if (e.key === "z" || e.key === "Z") undoPoint();
if (e.key === "r" || e.key === "R") resetPoints();
if (e.key === "s" || e.key === "S") skipSample();
if (e.key === "Enter" || e.key === " ") {
if (clickedPoints.length === 3) submitLabel();
}
});
loadNext();
</script>
</body>
</html>
"""
class LabelHandler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlparse(self.path)
if url.path in ["/", "/index.html"]:
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(HTML_TEMPLATE.encode("utf-8"))
return
if url.path == "/api/next":
all_samples = get_all_samples()
labeled_ids = get_labeled_sample_ids()
unlabeled = [s for s in all_samples if s.get("sample_id") not in labeled_ids]
if not unlabeled:
resp_data = {"finished": True, "stats": {"total": len(all_samples), "labeled": len(labeled_ids)}}
else:
next_sample = unlabeled[0]
sample_data = get_sample_data(next_sample["sample_id"])
resp_data = {
"finished": False,
"sample": sample_data,
"stats": {"total": len(all_samples), "labeled": len(labeled_ids)},
}
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(resp_data).encode("utf-8"))
return
self.send_response(404)
self.end_headers()
def do_POST(self):
url = urlparse(self.path)
if url.path == "/api/save":
content_len = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_len).decode("utf-8")
data = json.loads(body)
sample_id = data.get("sample_id")
points = data.get("points", [])
if not sample_id or len(points) != 3:
self.send_response(400)
self.end_headers()
return
LABEL_FILE.parent.mkdir(parents=True, exist_ok=True)
CROPS_DIR.mkdir(parents=True, exist_ok=True)
# 保存切片作为正样本
bg_path = RAW_DIR / f"{sample_id}_bg.png"
if bg_path.exists():
bg_img = Image.open(bg_path).convert("RGBA")
for i, pt in enumerate(points):
cx, cy = pt["x"], pt["y"]
# 裁剪 54x54 小图块
crop_box = (max(0, cx - 27), max(0, cy - 27), min(672, cx + 27), min(480, cy + 27))
crop_patch = bg_img.crop(crop_box)
crop_patch.save(CROPS_DIR / f"{sample_id}_target_{i+1}.png")
# 写入 labels.jsonl
record = {
"sample_id": sample_id,
"points": points,
"ans_format": [{"elem_id": i + 1, "type": "DynAnswerType_POS", "data": f"{p['x']},{p['y']}"} for i, p in enumerate(points)],
}
with open(LABEL_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
logger.info("✓ 成功保存样本标注: %s -> %s", sample_id, points)
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(b'{"status":"ok"}')
return
self.send_response(404)
self.end_headers()
def main():
parser = argparse.ArgumentParser(description="腾讯点选验证码轻量 Web 打标工具")
parser.add_argument("--port", type=int, default=8765, help="本地服务端口 (默认: 8765)")
args = parser.parse_args()
server = ThreadingHTTPServer(("127.0.0.1", args.port), LabelHandler)
logger.info("🚀 打标工作台已启动: http://127.0.0.1:%d", args.port)
logger.info("在浏览器打开上述地址即可开始打标,按 Ctrl+C 停止")
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("打标服务已停止")
if __name__ == "__main__":
main()