28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460 | class ShinhanBankScraper:
"""Scraper for Shinhan Bank account information."""
def __init__(
self,
headless: bool = False,
base_url: str | None = None,
) -> None:
self.base_url = base_url
self._session_manager = PlaywrightSessionManager(headless, base_url)
self._password_keyboard = MtkKeyboard("비밀번호")
self._account_keyboard = MtkKeyboard("계좌비밀번호")
self.browser = None
self.context = None
self.page: Page | None = None
async def __aenter__(self) -> Self:
"""Async context manager entry."""
self.browser, self.context, self.page = await self._session_manager.__aenter__()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
"""Async context manager exit."""
await self._session_manager.__aexit__(exc_type, exc_val, exc_tb)
self.browser = None
self.context = None
self.page = None
async def check_and_close_error_popup(self) -> bool:
"""Check for error popup with 확인 button and click it."""
try:
# Search all frames for various popup confirm buttons
frames = self.page.frames
for i, frame in enumerate(frames):
try:
# Check for alert layer button
confirm_button = await frame.query_selector("#btn_alertLayer_yes")
if confirm_button and await confirm_button.is_visible():
logger.debug("Found 확인 button (alertLayer) in frame %d, clicking...", i)
await confirm_button.tap()
logger.debug("확인 button clicked")
return True
except Exception:
pass
try:
# Check for warning popup button (in iframe with id pattern "warning_*_iframe")
warning_confirm = await frame.query_selector("#btn_warning_confirm")
if warning_confirm and await warning_confirm.is_visible():
logger.debug("Found 확인 button (warning) in frame %d, clicking...", i)
await warning_confirm.tap()
logger.debug("Warning 확인 button clicked")
return True
except Exception:
pass
return False
except Exception:
logger.exception("Error checking for popup")
return False
async def navigate_to_login(self) -> None:
"""Navigate to the Shinhan Bank login page."""
logger.debug("Navigating to Shinhan Bank...")
target_url = self.base_url or "https://bank.shinhan.com/rib/easy/index.jsp#210000010000"
await self.page.goto(target_url, wait_until="networkidle", timeout=60000)
logger.debug("Login page loaded successfully")
async def enter_user_id(self, user_id: str) -> None:
"""Enter user ID in the login form."""
logger.debug("Entering user ID: %s", user_id)
# Find the user ID input field
user_id_input = await self.page.query_selector('input[id="ibx_loginId"]')
if not user_id_input:
user_id_input = await self.page.query_selector('input[type="text"]')
if not user_id_input:
raise Exception("Could not find user ID input field")
# Clear and enter user ID
await user_id_input.click()
await user_id_input.fill(user_id)
logger.debug("User ID entered successfully")
async def enter_password_via_keyboard(self, password: str) -> None:
"""
Enter password using the on-screen keyboard.
Args:
password: The password to enter (alphanumeric and special characters)
"""
await self._password_keyboard.enter_password(self.page, password)
async def login(self, user_id: str, password: str) -> bool:
"""
Perform login to Shinhan Bank.
Args:
user_id: The user's ID
password: The user's password
"""
await self.navigate_to_login()
await self.enter_user_id(user_id)
await self.enter_password_via_keyboard(password)
# Click the login button
logger.debug("Looking for login button...")
# Wait a moment for keyboard to fully close
# Find the specific login button in the user ID login form
# It's the blue button right below the password field
logger.debug("Looking for the blue login button below password field...")
# Find the login button by ID
login_button = await self.page.query_selector("#btn_idLogin")
if login_button:
logger.debug("Clicking login button...")
try:
self.page.once("dialog", lambda dialog: asyncio.create_task(dialog.accept()))
await login_button.tap()
logger.debug("Login button clicked successfully")
except Exception as e:
logger.warning("Tap failed, trying regular click: %s", e)
await login_button.click()
else:
logger.error("Could not find login button!")
# Wait for popup or navigation
# Check for popup with "확인" button (limited service warning)
logger.debug("Checking for confirmation popup...")
try:
# Wait for the specific confirm button (ID: btn_alertLayer_yes)
confirm_button = await self.page.wait_for_selector("#btn_alertLayer_yes", timeout=5000, state="visible")
if confirm_button:
logger.debug("Found confirmation popup, clicking 확인...")
await confirm_button.tap()
logger.debug("Confirmation clicked successfully!")
except Exception:
logger.debug("No confirmation popup found or already dismissed")
# Wait for navigation after login
# Check for ARS authentication page
logger.debug("Checking for ARS authentication requirement...")
try:
# Look for ARS authentication page elements
# TODO: <tr id="grp_ARS" style="visibility: visible;" class="w2group" aria-hidden="false"><th id="wq_uuid_912" class="w2group w2tb_th"><div id="rdo_ARS" class="w2radio " title="ARS인증"><div class="w2radio_item w2radio_item_0"><input type="radio" class="w2radio_input" name="rdo_cert_gubn" index="0" id="rdo_ARS_input_0"><label class="w2radio_label " index="0" for="rdo_ARS_input_0">ARS인증</label></div></div></th><td id="wq_uuid_914" class="w2group w2tb_td" data-title="ARS인증"><p id="wq_uuid_915" class="w2textbox ">수신이 가능한 전화번호를 선택하신 후 [ARS인증요청] 버튼을 선택하시면, 선택한 전화번호로 전화연결 후 ARS 안내에 따라 본인여부를 확인합니다.</p><div id="wq_uuid_916" class="w2group boxTyGray03 mt10"><label id="wq_uuid_917" class="w2textbox " for="cbo_telNo">전화번호</label><div id="cbo_telNo" style="width: 165px; visibility: visible;" class="w2selectbox_native w2selectbox_native_mobile fixedWidth w2selectbox_disabled" aria-hidden="false"><div class="w2selectbox_native_innerDiv"><select class="w2selectbox_native_select" id="cbo_telNo_input_0" title="전화번호" disabled="disabled"><option>010-99**-49**</option><option>010-99**-49**</option></select></div></div><input id="tbx_telNo" style="width: 165px; display: none; visibility: hidden;" class="w2input" type="text" title="전화번호" aria-hidden="true"><a id="btn_requestArs" class="w2anchor2 btnTyGray01 medium w2anchor_disabled" href="javascript:void(null);" disabled="disabled">ARS인증요청</a></div><div id="wq_uuid_921" class="w2group listDash mt15"><ul id="wq_uuid_922" class="w2group "><li id="tbx_arsText" style="display:none" class="w2textbox ">ARS시스템 재기동 작업시간(한국시간 기준 오전 04시 ~ 오전 05시)에는 ARS 인증이 지연될 수 있습니다.</li><li id="wq_uuid_924" class="w2textbox ">로밍을 이용하시는 고객의 경우 ARS 인증절차를 위한 전화연결 시 해당 통신사의 로밍요금 정책에 의해 통신요금이 청구되오니, 이점 양지하시기 바랍니다.</li><li id="tbx_arsAbroad" style="display: block; visibility: visible;" class="w2textbox " aria-hidden="false">해외고객으로 ARS인증이나 로밍이 불가능 하신 경우 고객센터 (해외 82-2-3449-8000)로 연락하여 본인인증절차를 진행해 주시기 바랍니다. (통신요금 별도청구)</li><li id="grp_changeCust" class="w2group" aria-hidden="true" style="display: none; visibility: hidden;"><span id="wq_uuid_927" class="w2textbox ">전화번호 변경은 고객정보조회/변경화면에서 ARS인증 후 변경이 가능합니다.</span><a id="btn_searchCusInfo" class="w2anchor2 btnTyGray01 medium w2anchor_disabled" href="javascript:void(null);" disabled="disabled">고객정보조회/변경</a></li></ul></div></td></tr>
ars_text = await self.page.query_selector("text=ARS인증")
if ars_text:
logger.debug("ARS authentication required!")
# Click the ARS radio button first to enable the ARS authentication button
logger.debug("Selecting ARS authentication method...")
# Element: <input type="radio" class="w2radio_input" name="rdo_cert_gubn" index="0" id="rdo_ARS_input_0">
ars_radio = await self.page.query_selector("#rdo_ARS_input_0")
if ars_radio:
logger.debug("Clicking ARS radio button...")
await ars_radio.click()
logger.debug("ARS radio button selected")
else:
logger.warning("Could not find ARS radio button, continuing anyway...")
# Find and select phone number
logger.debug("Looking for phone number selection...")
# Select element: <select class="w2selectbox_native_select" id="cbo_telNo_input_0" title="전화번호"><option>010-99**-49**</option><option>010-99**-49**</option></select>
phone_select = await self.page.query_selector("#cbo_telNo_input_0")
if phone_select:
logger.debug("Found phone number selector, selecting first option...")
await phone_select.select_option(index=0)
logger.debug("Phone number selected")
else:
logger.warning("Could not find phone selection dropdown, continuing anyway...")
# Click the ARS authentication request button
logger.debug("Looking for ARS authentication button...")
# Element: <a id="btn_requestArs" class="w2anchor2 btnTyGray01 medium" href="javascript:void(null);">ARS인증요청</a>
ars_button = await self.page.query_selector("#btn_requestArs")
if ars_button:
logger.debug("Clicking ARS authentication button...")
await ars_button.tap()
logger.info("=" * 60)
logger.info("ARS authentication requested! Phone should ring soon...")
logger.info("Please answer the phone call and follow the ARS instructions.")
logger.info("Checking for completion every 5 seconds...")
logger.info("Will keep clicking 승인완료 until verification completes!")
logger.info("=" * 60)
# Element: <div id="wq_uuid_40" class="w2group btnAreaBot"><a id="btn_confirm" class="w2anchor2 btnTyBlue01 large" href="javascript:void(null);">승인완료</a></div>
# Poll for the "승인완료" button every 5 seconds and click it repeatedly
# Also check for btnConfirm which appears after ARS completes
max_wait_time = 120 # Wait up to 2 minutes
elapsed_time = 0
approval_completed = False
while elapsed_time < max_wait_time:
await asyncio.sleep(5) # Wait 5 seconds between checks
elapsed_time += 5
logger.debug("Checking ARS status... (%ds elapsed)", elapsed_time)
# Check for warning/alert popups and close them
await self.check_and_close_error_popup()
# First, check for final confirmation button (btnConfirm) - if found, ARS is complete
final_confirm = None
for i, frame in enumerate(self.page.frames):
try:
button = await frame.query_selector("#btnConfirm")
if button and await button.is_visible():
logger.debug(" ✓ Found final 확인 button (btnConfirm) in frame %d", i)
final_confirm = button
break
except Exception:
continue
# Also check main page
if not final_confirm:
try:
button = await self.page.query_selector("#btnConfirm")
if button and await button.is_visible():
logger.debug(" ✓ Found final 확인 button (btnConfirm) on main page")
final_confirm = button
except Exception:
pass
if final_confirm:
logger.debug("Checking if final 확인 button is clickable...")
try:
# Use short timeout (2s) to quickly detect if button is blocked by modal
await final_confirm.tap(timeout=2000)
logger.debug(" ✓ Final 확인 button clicked! ARS authentication complete")
approval_completed = True
await asyncio.sleep(2)
break
except Exception:
logger.debug(" btnConfirm blocked (modal overlay), continuing with 승인완료...")
# If btnConfirm not found, check for and click 승인완료 button
approval_button = None
for i, frame in enumerate(self.page.frames):
try:
button = await frame.query_selector("#btn_confirm")
if button and await button.is_visible():
logger.debug(" ✓ Found 승인완료 button in frame %d", i)
approval_button = button
break
except Exception:
continue
if approval_button:
logger.debug("Clicking 승인완료 button...")
try:
await approval_button.tap()
logger.debug(" ✓ 승인완료 button clicked")
except Exception as e:
logger.debug("Failed to click 승인완료: %s", e)
else:
logger.debug(" Neither btnConfirm nor 승인완료 found, waiting...")
if not approval_completed:
logger.warning("ARS authentication timeout. Continuing anyway...")
else:
logger.warning("Could not find ARS authentication button")
else:
logger.debug("No ARS authentication required")
except Exception:
logger.exception("Error during ARS authentication")
# Check for any error popups after login and close them
await self.check_and_close_error_popup()
# Check if login was successful
current_url = self.page.url
logger.debug("Current URL after login: %s", current_url)
# Check for error messages
error_selectors = [
'div:has-text("오류")',
'div:has-text("실패")',
'div:has-text("확인")',
]
for selector in error_selectors:
error_element = await self.page.query_selector(selector)
if error_element:
error_text = await error_element.inner_text()
logger.debug("Possible error message: %s", error_text)
logger.debug("Login process completed")
return True
async def navigate_to_transaction_history(self) -> bool:
"""
Navigate to the transaction history page by clicking the menu.
"""
logger.debug("Navigating to transaction history menu...")
try:
# Use JavaScript to click the menu item directly (it may be hidden in collapsed menu)
logger.debug("Clicking account inquiry menu (data-code=210101000000) via JavaScript...")
clicked = await self.page.evaluate("""() => {
const button = document.querySelector('a[data-code="210101000000"]');
if (button) {
button.click();
return true;
}
return false;
}""")
if clicked:
logger.debug("✓ Clicked the account inquiry menu!")
# Wait for page to load
await asyncio.sleep(2)
logger.debug("Successfully navigated to transaction history!")
return True
else:
logger.error("Could not find account inquiry menu button")
return False
except Exception:
logger.exception("Failed to navigate")
return False
async def get_transactions(
self, account_index: int = 0, account_password: str = None
) -> list[ShinhanBankTransaction]:
"""
Retrieve transaction history for an account.
Args:
account_index: Index of account in dropdown (0 = first, 1 = second, etc.)
account_password: 4-digit password for the account
Returns:
List of transaction dictionaries
"""
logger.debug("Retrieving transaction history for account #%d...", account_index)
if not account_password:
raise ValueError("account_password is required")
# Navigate to transaction history page if not already there
await self.navigate_to_transaction_history()
# Step 1: Select account from dropdown
logger.debug("Selecting account #%d from dropdown...", account_index)
account_select = await self.page.query_selector("#sbx_accno_input_0")
if not account_select:
raise Exception("Could not find account select element")
# Select by index: 0 = "선택하세요", 1 = first account, 2 = second, 3 = third
select_index = account_index + 1
await account_select.select_option(index=select_index)
logger.debug("✓ Selected account #%d", account_index)
# Step 2: Enter account password via numeric keypad
logger.debug("Entering account password...")
await self._account_keyboard.enter_pin(self.page, account_password)
logger.debug("✓ Entered account password")
# Step 3: Select 1개월 period
logger.debug("Selecting 1개월 period...")
radio_labels = await self.page.query_selector_all(".w2radio_label")
if len(radio_labels) > 3:
await radio_labels[3].click() # Index 3 = 1개월
logger.debug("✓ Selected 1개월 period")
# Step 4: Click 조회 button
logger.debug("Clicking 조회 button...")
submit_button = await self.page.query_selector("#btn_조회")
if not submit_button:
raise Exception("Could not find 조회 button")
# Some fixture pages raise alert dialogs on submit; auto-accept once
self.page.once("dialog", lambda dialog: asyncio.create_task(dialog.accept()))
await submit_button.click()
logger.debug("✓ Query submitted")
# Step 5: Extract transaction data from table
logger.debug("Extracting transaction data...")
transaction_table = await self.page.query_selector("#F01_grd_list_body_table")
transactions = []
if transaction_table:
rows = await transaction_table.query_selector_all("tr")
logger.debug("Found %d rows in transaction table", len(rows))
for row in rows:
cells = await row.query_selector_all("td")
row_data = []
for cell in cells:
text = await cell.text_content()
row_data.append(text.strip())
# Skip empty rows and header rows
if row_data and any(row_data) and len(row_data) >= 8:
# Parse transaction data
# Format: [date, time, datetime, type, withdrawal, deposit, description, balance, branch, ...]
transaction = {
"transacted_at": (row_data[2] if len(row_data) > 2 and row_data[2] else "").strip()
or f"{(row_data[0] if len(row_data) > 0 else '').strip()} {(row_data[1] if len(row_data) > 1 else '').strip()}".strip(),
"transaction_kind": row_data[3] if len(row_data) > 3 else "",
"withdrawal_amount": row_data[4] if len(row_data) > 4 else "0",
"deposit_amount": row_data[5] if len(row_data) > 5 else "0",
"description": row_data[6] if len(row_data) > 6 else "",
"balance": row_data[7] if len(row_data) > 7 else "",
"branch": row_data[8] if len(row_data) > 8 else "",
}
transactions.append(transaction)
logger.debug("✓ Extracted %d transactions", len(transactions))
else:
logger.warning("Could not find transaction table")
return transactions
|