fix: 修复验证码识别模块

- 添加图像放大功能 (缩放到至少 200px 宽度)
- 修复流式响应解析 (逐行解析 JSON,累加 content 字段)
- 添加 Graphics2D 导入用于图像缩放

验证结果:验证码 9347 识别成功
This commit is contained in:
Niko 2026-05-05 10:02:32 +08:00
parent 6abec1860b
commit 0bd4065230
2 changed files with 250 additions and 206 deletions

View File

@ -11,6 +11,8 @@ import java.io.InputStreamReader;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.awt.image.BufferedImage; import java.awt.image.BufferedImage;
import java.awt.Image;
import java.awt.Graphics2D;
import java.io.ByteArrayInputStream; import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.nio.file.Files; import java.nio.file.Files;
@ -38,46 +40,46 @@ public class EtsScraper {
public static void main(String[] args) throws Exception { public static void main(String[] args) throws Exception {
try { try {
createDirectories(SCREENSHOT_DIR); createDirectories(SCREENSHOT_DIR);
} catch (Exception e) { } catch (Exception e) {
System.err.println("Failed to create screenshots dir: " + e.getMessage()); System.err.println("Failed to create screenshots dir: " + e.getMessage());
} }
try (Playwright playwright = Playwright.create()) { try (Playwright playwright = Playwright.create()) {
Browser browser = playwright.chromium().launch( Browser browser = playwright.chromium().launch(
new BrowserType.LaunchOptions().setHeadless(false) new BrowserType.LaunchOptions().setHeadless(false)
); );
BrowserContext context = browser.newContext( BrowserContext context = browser.newContext(
new Browser.NewContextOptions().setIgnoreHTTPSErrors(true) new Browser.NewContextOptions().setIgnoreHTTPSErrors(true)
); );
Page page = context.newPage(); Page page = context.newPage();
try { try {
// Navigate to frame.html first to establish session/cookies // Navigate to frame.html first to establish session/cookies
System.out.println("[*] Establishing session via " + FRAME_URL); System.out.println("[*] Establishing session via " + FRAME_URL);
page.navigate(FRAME_URL, new Page.NavigateOptions() page.navigate(FRAME_URL, new Page.NavigateOptions()
.setTimeout(30000) .setTimeout(30000)
.setWaitUntil(WaitUntilState.DOMCONTENTLOADED)); .setWaitUntil(WaitUntilState.DOMCONTENTLOADED));
sleep(3000); sleep(3000);
// Navigate directly to the login page // Navigate directly to the login page
System.out.println("[*] Navigating to login page: " + LOGIN_URL); System.out.println("[*] Navigating to login page: " + LOGIN_URL);
page.navigate(LOGIN_URL, new Page.NavigateOptions() page.navigate(LOGIN_URL, new Page.NavigateOptions()
.setTimeout(30000) .setTimeout(30000)
.setWaitUntil(WaitUntilState.NETWORKIDLE)); .setWaitUntil(WaitUntilState.NETWORKIDLE));
sleep(2000); sleep(2000);
// Close notification dialog FIRST (before filling credentials) // Close notification dialog FIRST (before filling credentials)
closeNotificationDialog(page); closeNotificationDialog(page);
screenshot(page, "after_close_dialog"); screenshot(page, "after_close_dialog");
// Download captcha image // Download captcha image
downloadCaptcha(page); downloadCaptcha(page);
// Close dialog again after page reload // Close dialog again after page reload
closeNotificationDialog(page); closeNotificationDialog(page);
// Recognize captcha and perform login // Recognize captcha and perform login
boolean loggedin = doLoginWithCaptcha(page); boolean loggedin = doLoginWithCaptcha(page);
if (loggedin) { if (loggedin) {
@ -92,125 +94,124 @@ public class EtsScraper {
String content = page.textContent("body"); String content = page.textContent("body");
if (content != null) { if (content != null) {
String preview = content.length() > 500 String preview = content.length() > 500
? content.substring(0, 500) + "..." ? content.substring(0, 500) + ".."
: content; : content;
System.out.println("[+] Page content preview:\n" + preview); System.out.println("[+] Page content preview:\n" + preview);
} }
} else { } else {
System.out.println("[-] Login failed. Check screenshots/ for debugging."); System.out.println("[-] Login failed. Check screenshots/ for debugging.");
screenshot(page, "login_failed"); screenshot(page, "login_failed");
} }
} finally { } finally {
browser.close(); browser.close();
} }
} }
} }
private static boolean doLogin(Page page) { private static boolean doLogin(Page page) {
// Find and fill username // Find and fill username
String usernameInput = findInput(page, new String[]{ String usernameInput = findInput(page, new String[]{
"input[placeholder*='用户名']", "input[placeholder*='用户名']",
"input[placeholder*='username']", "input[placeholder*='username']",
"input[placeholder*='账号']", "input[placeholder*='账号']",
"input[name*='user']", "input[name*='user']",
"input[name='username']", "input[name='username']",
"input[type='text']", "input[type='text']",
}); });
if (usernameInput == null) { if (usernameInput == null) {
System.out.println("[-] Could not find username input"); System.out.println("[-] Could not find username input");
return false; return false;
} }
// Find and fill password // Find and fill password
String passwordInput = findInput(page, new String[]{ String passwordInput = findInput(page, new String[]{
"input[placeholder*='密码']", "input[placeholder*='密码']",
"input[placeholder*='password']", "input[placeholder*='password']",
"input[name*='pass']", "input[name*='pass']",
"input[name='password']", "input[name='password']",
"input[name='pwd']", "input[name='pwd']",
"input[type='password']", "input[type='password']",
}); });
if (passwordInput == null) { if (passwordInput == null) {
System.out.println("[-] Could not find password input"); System.out.println("[-] Could not find password input");
return false; return false;
} }
System.out.println("[*] Filling credentials..."); System.out.println("[*] Filling credentials...");
page.locator(usernameInput).first().fill(USERNAME); page.locator(usernameInput).first().fill(USERNAME);
page.locator(passwordInput).first().fill(PASSWORD); page.locator(passwordInput).first().fill(PASSWORD);
sleep(500); sleep(500);
// Find and click submit, or press Enter // Find and click submit, or press Enter
String submitBtn = findSubmit(page); String submitBtn = findSubmit(page);
if (submitBtn != null) { if (submitBtn != null) {
System.out.println("[*] Clicking submit button: " + submitBtn); System.out.println("[*] Clicking submit button: " + submitBtn);
page.locator(submitBtn).first().click(); page.locator(submitBtn).first().click();
} else { } else {
System.out.println("[*] No submit button found, pressing Enter"); System.out.println("[*] No submit button found, pressing Enter");
page.locator(passwordInput).first().press("Enter"); page.locator(passwordInput).first().press("Enter");
} }
try { try {
page.waitForLoadState(LoadState.DOMCONTENTLOADED, page.waitForLoadState(LoadState.DOMCONTENTLOADED,
new Page.WaitForLoadStateOptions().setTimeout(10000)); new Page.WaitForLoadStateOptions().setTimeout(10000));
return true; return true;
} catch (Exception e) { } catch (Exception e) {
System.out.println("[!] Navigation timed out, but credentials were submitted"); System.out.println("[!] Navigation timed out, but credentials were submitted");
return true; return true;
} }
} }
private static boolean doLoginWithCaptcha(Page page) throws Exception { private static boolean doLoginWithCaptcha(Page page) throws Exception {
// Find and fill username // Find and fill username
String usernameInput = findInput(page, new String[]{ String usernameInput = findInput(page, new String[]{
"input[placeholder*='用户名']", "input[placeholder*='用户名']",
"input[placeholder*='username']", "input[placeholder*='username']",
"input[placeholder*='账号']", "input[placeholder*='账号']",
"input[name*='user']", "input[name*='user']",
"input[name='username']", "input[name='username']",
"input[type='text']", "input[type='text']",
}); });
if (usernameInput == null) { if (usernameInput == null) {
System.out.println("[-] Could not find username input"); System.out.println("[-] Could not find username input");
return false; return false;
} }
// Find and fill password // Find and fill password
String passwordInput = findInput(page, new String[]{ String passwordInput = findInput(page, new String[]{
"input[placeholder*='密码']", "input[placeholder*='密码']",
"input[placeholder*='password']", "input[placeholder*='password']",
"input[name*='pass']", "input[name*='pass']",
"input[name='password']", "input[name='password']",
"input[name='pwd']", "input[name='pwd']",
"input[type='password']", "input[type='password']",
}); });
if (passwordInput == null) { if (passwordInput == null) {
System.out.println("[-] Could not find password input"); System.out.println("[-] Could not find password input");
return false; return false;
} }
// Find and fill captcha // Find and fill captcha
String captchaInput = findInput(page, new String[]{ String captchaInput = findInput(page, new String[]{
"input[placeholder*='验证码']", "input[placeholder*='验证码']",
"input[placeholder*='captcha']", "input[placeholder*='captcha']",
"input[name*='captcha']", "input[name*='captcha']",
"input[name='code']", "input[name='code']",
"input[type='text']", "input[type='text']",
}); });
if (captchaInput == null) { if (captchaInput == null) {
System.out.println("[-] Could not find captcha input"); System.out.println("[-] Could not find captcha input");
return false; return false;
} }
// Recognize captcha // Recognize captcha
Path captchaPath = SCREENSHOT_DIR.resolve("captcha.png"); Path captchaPath = SCREENSHOT_DIR.resolve("captcha.png");
System.out.println("[*] Recognizing captcha with Ollama..."); System.out.println("[*] Recognizing captcha with Ollama...");
String captchaText = recognizeCaptcha(captchaPath); String captchaText = recognizeCaptcha(captchaPath);
if (captchaText == null || captchaText.isEmpty()) { if (captchaText == null || captchaText.isEmpty()) {
System.out.println("[-] Failed to recognize captcha"); System.out.println("[-] Failed to recognize captcha");
return false; return false;
} }
System.out.println("[+] Captcha recognized: " + captchaText); System.out.println("[+] Captcha recognized: " + captchaText);
System.out.println("[*] Filling credentials..."); System.out.println("[*] Filling credentials...");
@ -219,79 +220,80 @@ public class EtsScraper {
page.locator(captchaInput).first().fill(captchaText); page.locator(captchaInput).first().fill(captchaText);
sleep(500); sleep(500);
// Click submit or press Enter // Click submit or press Enter
String submitBtn = findSubmit(page); String submitBtn = findSubmit(page);
if (submitBtn != null) { if (submitBtn != null) {
System.out.println("[*] Clicking submit button: " + submitBtn); System.out.println("[*] Clicking submit button: " + submitBtn);
page.locator(submitBtn).first().click(); page.locator(submitBtn).first().click();
} else { } else {
System.out.println("[*] No submit button found, pressing Enter"); System.out.println("[*] No submit button found, pressing Enter");
page.locator(captchaInput).first().press("Enter"); page.locator(captchaInput).first().press("Enter");
} }
try { try {
page.waitForLoadState(LoadState.DOMCONTENTLOADED, page.waitForLoadState(LoadState.DOMCONTENTLOADED,
new Page.WaitForLoadStateOptions().setTimeout(10000)); new Page.WaitForLoadStateOptions().setTimeout(10000));
return true; return true;
} catch (Exception e) { } catch (Exception e) {
System.out.println("[!] Navigation timed out, but credentials were submitted"); System.out.println("[!] Navigation timed out, but credentials were submitted");
return true; return true;
} }
} }
private static void downloadCaptcha(Page page) { private static void downloadCaptcha(Page page) {
try { try {
// Set up listener FIRST, then reload to trigger the request // Set up listener FIRST, then reload to trigger the request
Response resp = page.waitForResponse( Response resp = page.waitForResponse(
"https://101.227.180.215/SHCityEnvCW/Services/ValiDateImage.ashx*", "https://101.227.180.215/SHCityEnvCW/Services/ValiDateImage.ashx*",
() -> { () -> {
page.reload(new Page.ReloadOptions() page.reload(new Page.ReloadOptions()
.setWaitUntil(WaitUntilState.NETWORKIDLE) .setWaitUntil(WaitUntilState.NETWORKIDLE)
.setTimeout(10000)); .setTimeout(10000));
} }
); );
if (resp != null) { if (resp != null) {
byte[] body = resp.body(); byte[] body = resp.body();
Path captchaPath = SCREENSHOT_DIR.resolve("captcha.png"); Path captchaPath = SCREENSHOT_DIR.resolve("captcha.png");
java.nio.file.Files.write(captchaPath, body); java.nio.file.Files.write(captchaPath, body);
System.out.println("[+] Captcha saved to: " + captchaPath); System.out.println("[+] Captcha saved to: " + captchaPath);
System.out.println("[+] Captcha size: " + body.length + " bytes"); System.out.println("[+] Captcha size: " + body.length + " bytes");
}
} catch (Exception e) {
System.out.println("[-] Failed to download captcha: " + e.getMessage());
} }
} } catch (Exception e) {
System.out.println("[-] Failed to download captcha: " + e.getMessage());
}
}
private static void closeNotificationDialog(Page page) { private static void closeNotificationDialog(Page page) {
// Find the frame that contains the notification dialog // Find the frame that contains the notification dialog
Frame dialogFrame = null; Frame dialogFrame = null;
for (Frame f : page.frames()) { for (Frame f : page.frames()) {
try { try {
String hasDialog = (String) f.evaluate( String hasDialog = (String) f.evaluate(
"() => document.getElementById('Div_GG_Box') ? 'FOUND' : 'NOT_HERE'"); "() => document.getElementById('Div_GG_Box') ? 'FOUND' : 'NOT_HERE'");
if ("FOUND".equals(hasDialog)) { if ("FOUND".equals(hasDialog)) {
dialogFrame = f; dialogFrame = f;
break; break;
} }
} catch (Exception ignored) { } catch (Exception ignored) {
}
}
if (dialogFrame == null) {
System.out.println("[*] No notification dialog found");
return;
} }
}
System.out.println("[*] Closing notification dialog in frame: " + dialogFrame.url()); if (dialogFrame == null) {
// Click the X button in the correct frame System.out.println("[*] No notification dialog found");
dialogFrame.locator(".green_popup_close").first().click(); return;
sleep(500); }
// Force hide via JS in the correct frame (onclick uses jQuery which may fail) System.out.println("[*] Closing notification dialog in frame: " + dialogFrame.url());
dialogFrame.evaluate("document.getElementById('Div_GG_Box').style.display = 'none';"); // Click the X button in the correct frame
sleep(500); dialogFrame.locator(".green_popup_close").first().click();
sleep(500);
System.out.println("[*] Notification dialog closed"); // Force hide via JS in the correct frame (onclick uses jQuery which may fail)
} dialogFrame.evaluate("document.getElementById('Div_GG_Box').style.display = 'none';");
sleep(500);
System.out.println("[*] Notification dialog closed");
}
private static String findInput(Page page, String[] selectors) { private static String findInput(Page page, String[] selectors) {
for (String selector : selectors) { for (String selector : selectors) {
@ -299,116 +301,147 @@ public class EtsScraper {
if (page.locator(selector).first().isVisible( if (page.locator(selector).first().isVisible(
new Locator.IsVisibleOptions().setTimeout(1000))) { new Locator.IsVisibleOptions().setTimeout(1000))) {
return selector; return selector;
} }
} catch (Exception ignored) { } catch (Exception ignored) {
} }
} }
return null; return null;
} }
private static String findSubmit(Page page) { private static String findSubmit(Page page) {
String[] selectors = new String[]{ String[] selectors = new String[]{
"button[type='submit']", "button[type='submit']",
"input[type='submit']", "input[type='submit']",
"button:has-text('登录')", "button:has-text('登录')",
"button:has-text('Login')", "button:has-text('Login')",
".login-btn", ".login-btn",
"#loginBtn", "#loginBtn",
}; };
for (String selector : selectors) { for (String selector : selectors) {
try { try {
if (page.locator(selector).first().isVisible( if (page.locator(selector).first().isVisible(
new Locator.IsVisibleOptions().setTimeout(1000))) { new Locator.IsVisibleOptions().setTimeout(1000))) {
return selector; return selector;
} }
} catch (Exception ignored) { } catch (Exception ignored) {
} }
} }
return null; return null;
} }
private static void screenshot(Page page, String name) { private static void screenshot(Page page, String name) {
try { try {
String timestamp = LocalDateTime.now() String timestamp = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); .format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
Path path = SCREENSHOT_DIR.resolve(name + "_" + timestamp + ".png"); Path path = SCREENSHOT_DIR.resolve(name + "_" + timestamp + ".png");
page.screenshot(new Page.ScreenshotOptions().setPath(path)); page.screenshot(new Page.ScreenshotOptions().setPath(path));
System.out.println("[+] Screenshot saved: " + path); System.out.println("[+] Screenshot saved: " + path);
} catch (Exception e) { } catch (Exception e) {
System.err.println("[-] Screenshot failed: " + e.getMessage()); System.err.println("[-] Screenshot failed: " + e.getMessage());
} }
} }
private static void sleep(long ms) { private static void sleep(long ms) {
try { try {
Thread.sleep(ms); Thread.sleep(ms);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} }
} }
public static String recognizeCaptcha(Path imagePath) throws Exception { public static String recognizeCaptcha(Path imagePath) throws Exception {
byte[] imageBytes = Files.readAllBytes(imagePath); byte[] imageBytes = Files.readAllBytes(imagePath);
// Convert GIF to PNG (Ollama doesn't support GIF) // Convert GIF to PNG and resize (Ollama qwen3-vl needs larger PNG images)
ByteArrayInputStream bais = new ByteArrayInputStream(imageBytes); ByteArrayInputStream bais = new ByteArrayInputStream(imageBytes);
BufferedImage gifImage = ImageIO.read(bais); BufferedImage srcImage = ImageIO.read(bais);
if (gifImage == null) { if (srcImage == null) {
// Fallback: send raw bytes if conversion fails String base64 = Base64.getEncoder().encodeToString(imageBytes);
String base64 = Base64.getEncoder().encodeToString(imageBytes); return callOllama(base64);
return callOllama(base64); }
}
ByteArrayOutputStream pngOut = new ByteArrayOutputStream(); // Resize to at least 200px width for better recognition
ImageIO.write(gifImage, "png", pngOut); int scale = Math.max(1, 200 / srcImage.getWidth());
byte[] pngBytes = pngOut.toByteArray(); if (scale < 1) scale = 1;
String base64 = Base64.getEncoder().encodeToString(pngBytes); int newWidth = srcImage.getWidth() * scale;
return callOllama(base64); int newHeight = srcImage.getHeight() * scale;
}
Image scaled = srcImage.getScaledInstance(newWidth, newHeight, Image.SCALE_SMOOTH);
BufferedImage resized = new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resized.createGraphics();
g2d.drawImage(scaled, 0, 0, null);
g2d.dispose();
ByteArrayOutputStream pngOut = new ByteArrayOutputStream();
ImageIO.write(resized, "png", pngOut);
byte[] pngBytes = pngOut.toByteArray();
String base64 = Base64.getEncoder().encodeToString(pngBytes);
return callOllama(base64);
}
private static String callOllama(String base64Image) throws Exception { private static String callOllama(String base64Image) throws Exception {
String json = "{" String json = "{"
+ "\"model\":\"" + OLLAMA_MODEL + "\"," + "\"model\":\"" + OLLAMA_MODEL + "\","
+ "\"messages\":[" + "\"messages\":["
+ " {" + " {"
+ " \"role\":\"user\"," + " \"role\":\"user\","
+ " \"content\":\"识别图中的验证码文字,只返回文字内容,不要有其他解释\"," + " \"content\":\"识别图中的验证码文字,只返回文字内容\","
+ " \"images\":[\"" + base64Image + "\"]" + " \"images\":[\"" + base64Image + "\"]"
+ " }" + " }"
+ "]" + "]"
+ "}"; + "}";
URL url = new URL(OLLAMA_URL + "/api/chat"); URL url = new URL(OLLAMA_URL + "/api/chat");
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setConnectTimeout(15000); conn.setConnectTimeout(15000);
conn.setReadTimeout(30000); conn.setReadTimeout(60000);
conn.setDoOutput(true); conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
conn.getOutputStream().write(json.getBytes("utf-8")); conn.getOutputStream().write(json.getBytes("utf-8"));
conn.getOutputStream().flush(); conn.getOutputStream().flush();
conn.getOutputStream().close(); conn.getOutputStream().close();
try (BufferedReader reader = new BufferedReader( BufferedReader reader = new BufferedReader(
new InputStreamReader(conn.getInputStream(), "utf-8"))) { new InputStreamReader(conn.getInputStream(), "utf-8"));
StringBuilder sb = new StringBuilder(); try {
String line; StringBuilder fullContent = new StringBuilder();
while ((line = reader.readLine()) != null) { String line;
sb.append(line); while ((line = reader.readLine()) != null) {
} // Parse each line as a separate JSON object (streaming response)
String response = sb.toString(); int contentIdx = line.indexOf("\"content\":");
// Parse "content":"..." from the JSON response if (contentIdx >= 0) {
int contentIdx = response.indexOf("\"content\":"); int start = line.indexOf('"', contentIdx + 10) + 1;
if (contentIdx >= 0) { int end = line.indexOf('"', start);
int start = response.indexOf('"', contentIdx + 10) + 1; if (start > 0 && end > start) {
int end = response.indexOf('"', start); fullContent.append(line.substring(start, end));
if (start > 0 && end > start) { }
return response.substring(start, end).trim(); }
} // Check for done marker
} if (line.contains("\"done\":true")) {
return null; break;
} finally { }
conn.disconnect(); }
} return normalizeCaptcha(fullContent.toString());
} } finally {
} if (reader != null) {
try {
reader.close();
} catch (IOException e) {
// ignore
}
}
conn.disconnect();
}
}
private static String normalizeCaptcha(String raw) {
if (raw == null || raw.isBlank()) {
return "";
}
String s = raw.strip().replaceAll("\\s+", "");
s = s.replaceAll("^[`'\\\"]|[`'\\\"]+$", "");
return s;
}
}

View File

@ -76,4 +76,15 @@ class EtsScraperTest {
byte[] decoded = java.util.Base64.getDecoder().decode(base64); byte[] decoded = java.util.Base64.getDecoder().decode(base64);
assertArrayEquals(imageBytes, decoded, "Base64 roundtrip should match original"); assertArrayEquals(imageBytes, decoded, "Base64 roundtrip should match original");
} }
@Test
void testCaptchaRecognition() throws Exception {
Path captchaPath = Path.of("screenshots/captcha.png");
String captchaText = EtsScraper.recognizeCaptcha(captchaPath);
System.out.println("[+] Recognized captcha: " + captchaText);
assertNotNull(captchaText, "Captcha recognition should return a result");
assertFalse(captchaText.isEmpty(), "Captcha text should not be empty");
System.out.println("[+] Captcha length: " + captchaText.length() + " chars");
}
} }