Skip to content

Shinhan Card Scraper

Shinhan Card Scraper - Main implementation This scraper logs into Shinhan Card and retrieves transaction data.

ShinhanCardScraper

Scraper for Shinhan Card transaction information.

Source code in src/libeunhaeng/shinhan_card_scraper.py
 21
 22
 23
 24
 25
 26
 27
 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
class ShinhanCardScraper:
    """Scraper for Shinhan Card transaction 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._keyboard = NppfsKeyboard("pwd")
        self.browser = None
        self.context = None
        self.page = 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 navigate_to_login(self) -> None:
        """Navigate to the Shinhan Card login page."""
        logger.debug("Navigating to Shinhan Card...")
        target_url = self.base_url or "https://www.shinhancard.com/mob/MOBFM045N/MOBFM045R01.shc?crustMenuId=ms117"
        await self.page.goto(target_url, wait_until="networkidle", timeout=60000)
        logger.debug("Login page loaded successfully")

    async def open_id_login_form(self) -> None:
        """Open the ID/password login form."""
        logger.debug("Opening ID login form...")

        # Click "다른 로그인 방식 선택"
        other_login = await self.page.query_selector("text=다른 로그인 방식 선택")
        if not other_login:
            raise Exception("Could not find 'other login method' link")

        await other_login.tap()
        logger.debug("  ✓ Opened login method selector")

        # Click "아이디" option (pesn-choice-btn05)
        logger.debug("  Selecting ID login option...")
        id_option = await self.page.query_selector("button.pesn-choice-btn05")
        if not id_option:
            raise Exception("Could not find ID login option button")

        await id_option.click()
        logger.debug("  ✓ ID login form opened")

    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="t01.memid"]')
        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()

        # Clear the field first
        await user_id_input.fill("")

        # Type the user ID character by character to trigger input events
        await user_id_input.type(user_id, delay=100)

        # Trigger input and change events to ensure form validation runs
        await user_id_input.evaluate("""(element) => {
            element.dispatchEvent(new Event('input', { bubbles: true }));
            element.dispatchEvent(new Event('change', { bubbles: true }));
            element.dispatchEvent(new Event('blur', { bubbles: true }));
        }""")

        logger.debug("User ID entered successfully")

    async def click_next_button(self) -> None:
        """Click the 'next' button to proceed to password entry."""
        logger.debug("Clicking '다음' button...")

        # Try to find button by ID starting with "next"
        next_button = await self.page.query_selector('button[id^="next"]')

        if not next_button:
            # Fallback to text-based search
            next_button = await self.page.query_selector('button:has-text("다음")')

        if not next_button:
            raise Exception("Could not find '다음' button")

        await next_button.tap()
        logger.debug("Moved to password entry screen")

    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._keyboard.enter_password(self.page, password)

    async def click_login_button(self) -> None:
        """Click the final login button."""
        logger.debug("Looking for login button...")

        # Find the login button by ID
        login_button = await self.page.query_selector("#loginBtn")

        if not login_button:
            # Fallback to text-based search
            login_button = await self.page.query_selector('button:has-text("로그인")')

        if not login_button:
            # Try finding by wider search
            all_buttons = await self.page.query_selector_all("button, a")
            for btn in all_buttons:
                try:
                    text = await btn.text_content()
                    if text and "로그인" in text.strip():
                        is_visible = await btn.is_visible()
                        if is_visible:
                            login_button = btn
                            break
                except Exception:
                    continue

        if not login_button:
            raise Exception("Could not find login button")

        logger.debug("Clicking login button (#loginBtn)...")
        await login_button.tap()
        logger.debug("Login button clicked")

    async def login(self, user_id: str, password: str) -> bool:
        """
        Perform login to Shinhan Card.

        Args:
            user_id: The user's ID
            password: The user's password
        """
        await self.navigate_to_login()
        await self.open_id_login_form()
        await self.enter_user_id(user_id)
        await self.click_next_button()
        await self.enter_password_via_keyboard(password)
        await self.click_login_button()

        # Check if login was successful
        current_url = self.page.url
        logger.debug("Current URL after login: %s", current_url)

        logger.debug("Login process completed")
        return True

    async def get_recent_transactions(self) -> list[ShinhanCardTransaction]:
        """
        Extract recent transaction information from the card usage history page.

        Returns:
            List of transaction dictionaries
        """
        logger.debug("Waiting for transactions to load...")

        # Wait for transactions to load (JavaScript populates the list)
        await asyncio.sleep(3)

        # Check if payList div is visible
        pay_list = await self.page.query_selector("#payList")
        if not pay_list:
            logger.debug("Transaction list not found")
            return []

        pay_list_style = await pay_list.get_attribute("style")
        if pay_list_style and "display: none" in pay_list_style or "display:none" in pay_list_style:
            logger.debug("Transaction list not loaded yet or no transactions available")
            return []

        logger.debug("Extracting transaction data...")

        # Get the transaction list container
        list_container = await pay_list.query_selector(".usagelist-wrap")
        if not list_container:
            logger.debug("Transaction list container not found")
            return []

        # Get all direct li children
        transaction_items = await list_container.query_selector_all("> li")
        transactions = []

        for item in transaction_items:
            # Find the button containing transaction info
            button = await item.query_selector(".btn-usagelist-view")
            if not button:
                continue

            # Extract date
            date_el = await button.query_selector(".txt-usage-date")
            date = await date_el.text_content() if date_el else ""
            date = date.strip() if date else ""

            # Extract merchant name
            merchant_el = await button.query_selector(".usagebox-list-tit")
            merchant = await merchant_el.text_content() if merchant_el else ""
            merchant = merchant.strip() if merchant else ""

            # Extract amount
            amount_el = await button.query_selector(".usagelist-list-amount")
            amount = await amount_el.text_content() if amount_el else ""
            amount = amount.strip() if amount else ""

            # Extract detail information
            card_owner = ""
            installment = ""
            status = ""

            detail_div = await button.query_selector(".usagelist-detail")
            if detail_div:
                # Get all spans
                all_spans = await detail_div.query_selector_all("span")
                detail_items = []

                for span in all_spans:
                    # The first span in 'usagelist-detail' sometimes contains a 'paytime' class
                    # with redundant date/time information that would incorrectly be parsed
                    # as 'card_owner'. Skip this span based on actual HTML observation.
                    class_attr = await span.get_attribute("class")
                    if class_attr and "paytime" in class_attr:
                        continue

                    text = await span.text_content()
                    text = text.strip() if text else ""

                    if text:
                        detail_items.append(text)

                # Map to meaningful names
                if len(detail_items) > 0:
                    card_owner = detail_items[0]
                if len(detail_items) > 1:
                    installment = detail_items[1]

                # Check remaining items for status keywords
                for item in detail_items[2:]:
                    if "취소" in item:
                        status = "취소"

            # Only add if we have date AND (merchant OR amount)
            if date and (merchant or amount):
                transactions.append(
                    {
                        "transacted_at": date,  # 'transacted_at': '2025.11.16 13:02',
                        "merchant": merchant,
                        "amount": amount,
                        "card_owner": card_owner,
                        "installment": installment,
                        "status": status,
                    }
                )

        logger.debug("✓ Extracted %d transactions", len(transactions))

        return transactions

__aenter__() async

Async context manager entry.

Source code in src/libeunhaeng/shinhan_card_scraper.py
32
33
34
35
async def __aenter__(self) -> Self:
    """Async context manager entry."""
    self.browser, self.context, self.page = await self._session_manager.__aenter__()
    return self

__aexit__(exc_type, exc_val, exc_tb) async

Async context manager exit.

Source code in src/libeunhaeng/shinhan_card_scraper.py
37
38
39
40
41
42
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

click_login_button() async

Click the final login button.

Source code in src/libeunhaeng/shinhan_card_scraper.py
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
async def click_login_button(self) -> None:
    """Click the final login button."""
    logger.debug("Looking for login button...")

    # Find the login button by ID
    login_button = await self.page.query_selector("#loginBtn")

    if not login_button:
        # Fallback to text-based search
        login_button = await self.page.query_selector('button:has-text("로그인")')

    if not login_button:
        # Try finding by wider search
        all_buttons = await self.page.query_selector_all("button, a")
        for btn in all_buttons:
            try:
                text = await btn.text_content()
                if text and "로그인" in text.strip():
                    is_visible = await btn.is_visible()
                    if is_visible:
                        login_button = btn
                        break
            except Exception:
                continue

    if not login_button:
        raise Exception("Could not find login button")

    logger.debug("Clicking login button (#loginBtn)...")
    await login_button.tap()
    logger.debug("Login button clicked")

click_next_button() async

Click the 'next' button to proceed to password entry.

Source code in src/libeunhaeng/shinhan_card_scraper.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
async def click_next_button(self) -> None:
    """Click the 'next' button to proceed to password entry."""
    logger.debug("Clicking '다음' button...")

    # Try to find button by ID starting with "next"
    next_button = await self.page.query_selector('button[id^="next"]')

    if not next_button:
        # Fallback to text-based search
        next_button = await self.page.query_selector('button:has-text("다음")')

    if not next_button:
        raise Exception("Could not find '다음' button")

    await next_button.tap()
    logger.debug("Moved to password entry screen")

enter_password_via_keyboard(password) async

Enter password using the on-screen keyboard.

Parameters:

Name Type Description Default
password str

The password to enter (alphanumeric and special characters)

required
Source code in src/libeunhaeng/shinhan_card_scraper.py
119
120
121
122
123
124
125
126
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._keyboard.enter_password(self.page, password)

enter_user_id(user_id) async

Enter user ID in the login form.

Source code in src/libeunhaeng/shinhan_card_scraper.py
 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
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="t01.memid"]')
    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()

    # Clear the field first
    await user_id_input.fill("")

    # Type the user ID character by character to trigger input events
    await user_id_input.type(user_id, delay=100)

    # Trigger input and change events to ensure form validation runs
    await user_id_input.evaluate("""(element) => {
        element.dispatchEvent(new Event('input', { bubbles: true }));
        element.dispatchEvent(new Event('change', { bubbles: true }));
        element.dispatchEvent(new Event('blur', { bubbles: true }));
    }""")

    logger.debug("User ID entered successfully")

get_recent_transactions() async

Extract recent transaction information from the card usage history page.

Returns:

Type Description
list[ShinhanCardTransaction]

List of transaction dictionaries

Source code in src/libeunhaeng/shinhan_card_scraper.py
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
async def get_recent_transactions(self) -> list[ShinhanCardTransaction]:
    """
    Extract recent transaction information from the card usage history page.

    Returns:
        List of transaction dictionaries
    """
    logger.debug("Waiting for transactions to load...")

    # Wait for transactions to load (JavaScript populates the list)
    await asyncio.sleep(3)

    # Check if payList div is visible
    pay_list = await self.page.query_selector("#payList")
    if not pay_list:
        logger.debug("Transaction list not found")
        return []

    pay_list_style = await pay_list.get_attribute("style")
    if pay_list_style and "display: none" in pay_list_style or "display:none" in pay_list_style:
        logger.debug("Transaction list not loaded yet or no transactions available")
        return []

    logger.debug("Extracting transaction data...")

    # Get the transaction list container
    list_container = await pay_list.query_selector(".usagelist-wrap")
    if not list_container:
        logger.debug("Transaction list container not found")
        return []

    # Get all direct li children
    transaction_items = await list_container.query_selector_all("> li")
    transactions = []

    for item in transaction_items:
        # Find the button containing transaction info
        button = await item.query_selector(".btn-usagelist-view")
        if not button:
            continue

        # Extract date
        date_el = await button.query_selector(".txt-usage-date")
        date = await date_el.text_content() if date_el else ""
        date = date.strip() if date else ""

        # Extract merchant name
        merchant_el = await button.query_selector(".usagebox-list-tit")
        merchant = await merchant_el.text_content() if merchant_el else ""
        merchant = merchant.strip() if merchant else ""

        # Extract amount
        amount_el = await button.query_selector(".usagelist-list-amount")
        amount = await amount_el.text_content() if amount_el else ""
        amount = amount.strip() if amount else ""

        # Extract detail information
        card_owner = ""
        installment = ""
        status = ""

        detail_div = await button.query_selector(".usagelist-detail")
        if detail_div:
            # Get all spans
            all_spans = await detail_div.query_selector_all("span")
            detail_items = []

            for span in all_spans:
                # The first span in 'usagelist-detail' sometimes contains a 'paytime' class
                # with redundant date/time information that would incorrectly be parsed
                # as 'card_owner'. Skip this span based on actual HTML observation.
                class_attr = await span.get_attribute("class")
                if class_attr and "paytime" in class_attr:
                    continue

                text = await span.text_content()
                text = text.strip() if text else ""

                if text:
                    detail_items.append(text)

            # Map to meaningful names
            if len(detail_items) > 0:
                card_owner = detail_items[0]
            if len(detail_items) > 1:
                installment = detail_items[1]

            # Check remaining items for status keywords
            for item in detail_items[2:]:
                if "취소" in item:
                    status = "취소"

        # Only add if we have date AND (merchant OR amount)
        if date and (merchant or amount):
            transactions.append(
                {
                    "transacted_at": date,  # 'transacted_at': '2025.11.16 13:02',
                    "merchant": merchant,
                    "amount": amount,
                    "card_owner": card_owner,
                    "installment": installment,
                    "status": status,
                }
            )

    logger.debug("✓ Extracted %d transactions", len(transactions))

    return transactions

login(user_id, password) async

Perform login to Shinhan Card.

Parameters:

Name Type Description Default
user_id str

The user's ID

required
password str

The user's password

required
Source code in src/libeunhaeng/shinhan_card_scraper.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
async def login(self, user_id: str, password: str) -> bool:
    """
    Perform login to Shinhan Card.

    Args:
        user_id: The user's ID
        password: The user's password
    """
    await self.navigate_to_login()
    await self.open_id_login_form()
    await self.enter_user_id(user_id)
    await self.click_next_button()
    await self.enter_password_via_keyboard(password)
    await self.click_login_button()

    # Check if login was successful
    current_url = self.page.url
    logger.debug("Current URL after login: %s", current_url)

    logger.debug("Login process completed")
    return True

navigate_to_login() async

Navigate to the Shinhan Card login page.

Source code in src/libeunhaeng/shinhan_card_scraper.py
44
45
46
47
48
49
async def navigate_to_login(self) -> None:
    """Navigate to the Shinhan Card login page."""
    logger.debug("Navigating to Shinhan Card...")
    target_url = self.base_url or "https://www.shinhancard.com/mob/MOBFM045N/MOBFM045R01.shc?crustMenuId=ms117"
    await self.page.goto(target_url, wait_until="networkidle", timeout=60000)
    logger.debug("Login page loaded successfully")

open_id_login_form() async

Open the ID/password login form.

Source code in src/libeunhaeng/shinhan_card_scraper.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
async def open_id_login_form(self) -> None:
    """Open the ID/password login form."""
    logger.debug("Opening ID login form...")

    # Click "다른 로그인 방식 선택"
    other_login = await self.page.query_selector("text=다른 로그인 방식 선택")
    if not other_login:
        raise Exception("Could not find 'other login method' link")

    await other_login.tap()
    logger.debug("  ✓ Opened login method selector")

    # Click "아이디" option (pesn-choice-btn05)
    logger.debug("  Selecting ID login option...")
    id_option = await self.page.query_selector("button.pesn-choice-btn05")
    if not id_option:
        raise Exception("Could not find ID login option button")

    await id_option.click()
    logger.debug("  ✓ ID login form opened")