Bishall10/ipo
0
1from playwright.sync_api import sync_playwright2from dotenv import load_dotenv3import os4import re5import time6import json7import random8import string9import secrets10 11from notifications import send_email_notification, send_push_notification, get_fcm_tokens_for_user12from expiry_handler import (13 detect_account_expiry,14 check_account_expiry_warning,15 handle_expired_account,16)17 18# Load environment variables19load_dotenv()20 21 22def generate_new_password(length=12):23 """24 Generates a secure random password satisfying MeroShare requirements:25 - Uppercase, Lowercase, Number, and Special Character26 """27 alphabet = string.ascii_letters + string.digits + "@#$!%*?&"28 while True:29 password = ''.join(secrets.choice(alphabet) for i in range(length))30 if (any(c.islower() for c in password)31 and any(c.isupper() for c in password)32 and sum(c.isdigit() for c in password) >= 133 and any(c in "@#$!%*?&" for c in password)):34 return password35 36def update_local_account_password(username, new_password):37 """38 Updates the password for a specific user in the local accounts.json file.39 """40 if not os.path.exists("accounts.json"):41 return False42 43 try:44 with open("accounts.json", "r") as f:45 accounts = json.load(f)46 47 updated = False48 for acc in accounts:49 if acc.get("MEROSHARE_USER") == username:50 acc["MEROSHARE_PASS"] = new_password51 updated = True52 53 if updated:54 with open("accounts.json", "w") as f:55 json.dump(accounts, f, indent=4)56 print(f"Successfully updated local accounts.json for {username}")57 return True58 except Exception as e:59 print(f"Warning: Failed to update local accounts.json: {e}")60 return False61 62def handle_password_reset(page, account):63 """64 Handles the password change process when an expiry is detected.65 """66 username = account['MEROSHARE_USER']67 old_password = account['MEROSHARE_PASS']68 new_password = generate_new_password()69 70 print(f"[{username}] Starting automatic password reset...")71 try:72 # MeroShare change password page usually has these fields73 # Using flexible selectors in case they change74 page.wait_for_selector("input[placeholder='Old Password'], #oldPassword", timeout=10000)75 76 page.fill("input[placeholder='Old Password'], #oldPassword", old_password)77 page.fill("input[placeholder='New Password'], #newPassword", new_password)78 page.fill("input[placeholder='Confirm Password'], #confirmPassword", new_password)79 80 page.click("button:has-text('Change'), button:has-text('Update')")81 82 # Wait for toast message or redirection83 try:84 toast = page.wait_for_selector(".toast-success, .toast-message", timeout=10000)85 toast_text = toast.inner_text().strip()86 print(f"[{username}] Reset Result: {toast_text}")87 88 if "success" in toast_text.lower() or "successfully" in toast_text.lower():89 # Notify User90 msg = f"Your MeroShare password for {username} has been automatically reset because it expired.\n\nNew Password: {new_password}\n\nPlease update your GitHub secrets or local config if the automatic update failed."91 send_email_notification(account.get('EMAIL'), f"[MeroShare] Password Reset Successful", msg)92 93 # Update local file94 update_local_account_password(username, new_password)95 return True96 else:97 print(f"[{username}] Password reset reported failure: {toast_text}")98 except:99 # Fallback check: if we are no longer on change-password page and see dashboard100 page.wait_for_timeout(3000)101 if "change-password" not in page.url and (page.locator("text=My ASBA").is_visible() or "dashboard" in page.url):102 print(f"[{username}] Password reset appears successful (redirected).")103 msg = f"Your MeroShare password for {username} has been automatically reset.\n\nNew Password: {new_password}"104 send_email_notification(account.get('EMAIL'), f"[MeroShare] Password Reset Successful", msg)105 update_local_account_password(username, new_password)106 return True107 108 except Exception as e:109 print(f"[{username}] Error during password reset: {e}")110 page.screenshot(path=f"debug_reset_fail_{username}.png")111 112 return False113def fill_and_submit_form(page, account, company_name=None):114 """115 Fills the IPO application form and submits it with TPIN.116 Can be called from initial application or status check (Edit mode).117 """118 username = account['MEROSHARE_USER']119 tpin = account.get('TPIN')120 bank_name = account.get('BANK_NAME')121 122 print(f"[{username}] Filling application form...")123 # Wait for the form to actually be visible124 page.wait_for_timeout(2000)125 126 print(f"Selecting Bank: {bank_name}...")127 try:128 page.wait_for_selector("#selectBank", timeout=20000)129 130 # BRUTE FORCE JS SELECTION131 selected_bank = page.evaluate(f"""132 (bankName) => {{133 const select = document.querySelector('#selectBank');134 if (!select) return "NOT_FOUND";135 const options = Array.from(select.options);136 const target = bankName.toLowerCase().trim();137 const match = options.find(o => o.innerText.toLowerCase().trim().includes(target));138 if (match) {{139 select.value = match.value;140 select.dispatchEvent(new Event('change', {{ bubbles: true }}));141 select.dispatchEvent(new Event('input', {{ bubbles: true }}));142 return match.innerText.trim();143 }}144 return "FAIL: " + options.map(o => o.innerText.trim()).join(', ');145 }}146 """, bank_name)147 148 if "FAIL" in selected_bank:149 raise Exception(f"Bank selection failed: {selected_bank}")150 print(f"[{username}] Selected Bank: {selected_bank}")151 152 page.wait_for_timeout(1500) # Wait for Branch to populate153 154 print(f"[{username}] Selecting Branch...")155 selected_branch = page.evaluate("""156 () => {157 const el = document.querySelector('#selectBranch');158 if (!el) return "NOT_FOUND";159 if (el.tagName === 'SELECT') {160 const options = Array.from(el.options);161 const validOptions = options.filter(o => !o.innerText.toLowerCase().includes('choose') && o.innerText.trim() !== '');162 if (validOptions.length > 0) {163 el.value = validOptions[0].value;164 el.dispatchEvent(new Event('change', { bubbles: true }));165 el.dispatchEvent(new Event('input', { bubbles: true }));166 return "SELECT: " + validOptions[0].innerText.trim();167 }168 return "SELECT: NONE_FOUND";169 }170 if (el.tagName === 'INPUT') return "INPUT_FIELD";171 return "UNKNOWN_TAG: " + el.tagName;172 }173 """)174 175 if selected_branch == "INPUT_FIELD":176 page.click("#selectBranch")177 page.wait_for_timeout(500)178 page.keyboard.press("ArrowDown")179 page.wait_for_timeout(500)180 page.keyboard.press("Enter")181 print(f"[{username}] Selected Branch via keyboard interaction")182 elif "NOT_FOUND" in selected_branch or "NONE_FOUND" in selected_branch:183 print(f"[{username}] Branch selection auto-skipped: {selected_branch}")184 else:185 print(f"[{username}] {selected_branch}")186 187 page.wait_for_timeout(1000)188 189 print(f"[{username}] Selecting Bank Account Number...")190 page.wait_for_selector("#accountNumber", timeout=10000)191 account_selected = page.evaluate("""192 () => {193 const select = document.querySelector('#accountNumber');194 if (!select) return "NOT_FOUND";195 const options = Array.from(select.options);196 const validOptions = options.filter(o => o.innerText.trim() !== '' && !o.innerText.toLowerCase().includes('choose'));197 if (validOptions.length > 0) {198 select.value = validOptions[0].value;199 select.dispatchEvent(new Event('change', { bubbles: true }));200 select.dispatchEvent(new Event('input', { bubbles: true }));201 return validOptions[0].innerText.trim();202 }203 return "NONE_FOUND";204 }205 """)206 print(f"[{username}] Selected Account: {account_selected}")207 208 except Exception as e:209 print(f"[{username}] Bank/Branch/Account selection failed. Diagnostics:")210 page.screenshot(path=f"debug_bank_fail_{username}.png")211 return False, "Bank/Branch/Account selection failed"212 213 print(f"[{username}] Filling Kitta and CRN with validation triggers...")214 detected_min_kitta = 10215 if not company_name:216 company_name = "Unknown"217 try:218 company_elem = page.locator(".company-name, .issue-name, h4.modal-title").first219 if company_elem.is_visible():220 company_name = company_elem.inner_text().strip()221 print(f"[{username}] Company (Detected): {company_name}")222 except: pass223 224 try:225 min_kitta_value = page.evaluate("""226 () => {227 const labels = Array.from(document.querySelectorAll('label, span, td, th, div'));228 const minLabel = labels.find(el => {229 const text = el.innerText.toLowerCase().trim();230 return text === 'minimum unit' || text === 'minimum quantity' || text === 'min unit' || 231 text.includes('minimum unit:') || text.includes('minimum quantity:');232 });233 if (minLabel) {234 let parent = minLabel.parentElement;235 let textContent = parent.innerText;236 let matches = textContent.match(/\\d+/g);237 if (matches && matches.length > 0) return parseInt(matches[matches.length - 1]);238 if (minLabel.nextElementSibling) {239 const nextText = minLabel.nextElementSibling.innerText;240 const matchNext = nextText.match(/\\d+/);241 if (matchNext) return parseInt(matchNext[0]);242 }243 }244 return null;245 }246 """)247 if min_kitta_value:248 detected_min_kitta = int(min_kitta_value)249 print(f"[{username}] Detected Minimum Kitta (on page): {detected_min_kitta}")250 251 if "RELIANCE" in company_name.upper() or "NIFRA" in company_name.upper():252 if detected_min_kitta < 50:253 detected_min_kitta = max(detected_min_kitta, 50)254 except Exception as e:255 print(f"Warning: [{username}] Could not detect minimum kitta: {e}")256 257 user_kitta = int(account.get('KITTA', '10'))258 final_kitta = max(user_kitta, detected_min_kitta)259 if final_kitta != user_kitta:260 print(f"[{username}] Adjusting Kitta from {user_kitta} to {final_kitta} based on requirements.")261 262 kitta_loc = page.locator("#appliedKitta")263 kitta_loc.clear()264 kitta_loc.type(str(final_kitta))265 page.keyboard.press("Tab")266 page.wait_for_timeout(500)267 268 crn_loc = page.locator("#crnNumber")269 crn_loc.clear()270 crn_loc.type(account['CRN'])271 page.keyboard.press("Tab")272 page.wait_for_timeout(500)273 274 print(f"[{username}] Waiting for amount calculation...")275 try:276 page.wait_for_function("document.querySelector('#amount') && document.querySelector('#amount').value !== '' && document.querySelector('#amount').value !== '0'", timeout=5000)277 amount = page.locator("#amount").input_value()278 print(f"[{username}] Calculated Amount: {amount}")279 except:280 print(f"Warning: [{username}] Amount was not calculated.")281 282 page.uncheck("#disclaimer")283 page.wait_for_timeout(300)284 page.check("#disclaimer")285 page.mouse.click(0, 0)286 page.wait_for_timeout(1000)287 288 print(f"Form filled. Checking Proceed button state...")289 proceed_btn = page.locator("button:has-text('Proceed')")290 try:291 page.wait_for_function("document.querySelector('button:has-text(\"Proceed\")').disabled === false", timeout=5000)292 except: pass293 proceed_btn.click()294 295 if tpin:296 print(f"[{username}] Entering TPIN...")297 page.wait_for_selector("#transactionPIN", timeout=10000)298 page.locator("#transactionPIN").click()299 page.locator("#transactionPIN").clear()300 page.locator("#transactionPIN").type(tpin)301 page.keyboard.press("Tab")302 page.wait_for_timeout(1000)303 print(f"[{username}] Submitting application...")304 305 apply_btn = page.locator(".modal-footer button:has-text('Apply')").first306 if not apply_btn.is_visible():307 apply_btn = page.locator("button:has-text('Apply')").first308 apply_btn.click()309 310 try:311 toast = page.wait_for_selector(".toast-success, .toast-message", timeout=10000)312 toast_text = toast.inner_text().strip()313 print(f"[{username}] Result: {toast_text}")314 315 if "success" in toast_text.lower() or "successfully" in toast_text.lower():316 print(f"Application SUCCESS!")317 msg = f"{company_name} has been applied successfully."318 owner_id = account.get('owner_id')319 tokens = get_fcm_tokens_for_user(username, owner_id)320 if tokens:321 send_push_notification(tokens, 'IPO Applied: Success', f"{company_name} applied successfully for {username}.")322 return True, company_name323 else:324 error_msg = toast_text325 if "balance" in error_msg.lower() or "insufficient" in error_msg.lower():326 msg = f"Your IPO has not been applied due to insufficient balance. Please topup amount and try again."327 owner_id = account.get('owner_id')328 tokens = get_fcm_tokens_for_user(username, owner_id)329 if tokens:330 send_push_notification(tokens, 'IPO Failed: Insufficient Balance', f"{company_name}: {msg}")331 return False, "Insufficient balance"332 else:333 msg = f"❌ FAILED: {error_msg} - {username}"334 owner_id = account.get('owner_id')335 tokens = get_fcm_tokens_for_user(username, owner_id)336 if tokens:337 send_push_notification(tokens, 'IPO Application Failed', f"{company_name}: {error_msg}")338 return False, error_msg339 except:340 if not page.is_visible("#transactionPIN"):341 print(f"[{username}] Application submitted successfully (modal closed).")342 return True, company_name343 else:344 print(f"Error: [{username}] Application submission failed (modal still open).")345 return False, "Application submission failed (modal still open)"346 else:347 print(f"Warning: [{username}] No TPIN provided. Skipping submission.")348 return False, "No TPIN provided"349 350 351def login(page, username, password, dp_name):352 """353 Attempts to login a specific user.354 """355 print(f"Logging in as {username}...")356 357 # Clean/extract search query for DP358 match = re.search(r'\((\d{5})\)', dp_name)359 if match:360 dp_search = match.group(1)361 else:362 clean = dp_name.strip()363 clean = re.sub(r'\(.*?\)', '', clean)364 for suffix in ['LTD.', 'LIMITED', 'PVT.', 'LTD', 'CO.', 'CORP.', 'BANK']:365 clean = re.sub(rf'\b{suffix}\b', '', clean, flags=re.IGNORECASE)366 dp_search = clean[:15].strip()367 368 print(f"Selecting DP: {dp_name} (searching for '{dp_search}')...")369 try:370 page.wait_for_selector(".select2-selection", timeout=15000)371 page.click(".select2-selection")372 page.wait_for_timeout(1000)373 374 # Wait for search field375 page.wait_for_selector("input.select2-search__field", timeout=5000)376 page.fill("input.select2-search__field", dp_search)377 page.wait_for_timeout(1500)378 379 # Select matched option380 page.keyboard.press("Enter")381 page.wait_for_timeout(1500)382 except Exception as e:383 print(f"Warning: DP Selection issue: {e}")384 page.screenshot(path=f"debug_login_dp_{username}.png")385 386 # Try to blur to ensure fields are interactable387 page.mouse.click(0, 0) 388 page.wait_for_timeout(500)389 390 try:391 # MeroShare uses #username and #password IDs now392 username_selectors = ["#username", "#txtUserName", "input[name='username']", "input[placeholder='Username']"]393 found = False394 for selector in username_selectors:395 if page.locator(selector).is_visible():396 page.fill(selector, username)397 found = True398 break399 400 if not found:401 page.wait_for_selector("#username", timeout=20000)402 page.fill("#username", username)403 404 page.wait_for_timeout(500)405 406 password_selectors = ["#password", "#txtPassword", "input[name='password']", "input[placeholder='Password']"]407 p_found = False408 for selector in password_selectors:409 if page.locator(selector).is_visible():410 page.fill(selector, password)411 p_found = True412 break413 414 if not p_found:415 page.wait_for_selector("#password", timeout=10000)416 page.fill("#password", password)417 418 except Exception as e:419 print(f"[{username}] Could not find form fields. State at failure:")420 page.screenshot(path=f"debug_login_fields_{username}.png")421 return False422 423 print(f"Clicking Login button for {username}...")424 page.click("button:has-text('Login')")425 426 try:427 page.wait_for_load_state('networkidle', timeout=15000)428 page.wait_for_timeout(2000) 429 430 # Check for Password Expiry Redirect431 if "change-password" in page.url or "changepassword" in page.url or page.locator("text=Change Password").is_visible():432 print(f"[{username}] ⚠️ Password Expired / Change required detected.")433 return "EXPIRED"434 435 # Check for DEMAT or MeroShare account expiry436 expiry_result = detect_account_expiry(page, username)437 if expiry_result:438 return expiry_result439 440 if page.locator("text=My ASBA").is_visible():441 return True442 elif page.locator(".toast-message").is_visible():443 error_msg = page.locator(".toast-message").inner_text()444 print(f"⚠️ Login Failed: {error_msg}")445 return False446 else:447 if "dashboard" in page.url or "dashboard" in page.content().lower():448 return True449 return False450 except Exception as e:451 print(f"Warning: Login Check Error: {e}")452 return False453 454def apply_ipo(page, account):455 """456 Applies for IPO for a logged-in session.457 """458 username = account['MEROSHARE_USER']459 print(f"[{username}] Navigating to My ASBA...")460 page.wait_for_selector(".nav-link:has-text('My ASBA')")461 page.click(".nav-link:has-text('My ASBA')")462 463 try:464 page.wait_for_selector("a:has-text('Apply for Issue')", timeout=10000)465 page.click("a:has-text('Apply for Issue')")466 page.wait_for_load_state('networkidle')467 except Exception as e:468 print(f"Warning: [{username}] Could not find 'Apply for Issue' tab: {e}")469 470 print(f"[{username}] Waiting for IPO list to load...")471 page.wait_for_timeout(5000) # Increased wait for MeroShare's slow table472 473 # Try up to 2 times with a refresh in between if nothing found474 for attempt in range(2):475 clicked_ipo = page.evaluate("""476 () => {477 // Find all possible row containers478 const containers = Array.from(document.querySelectorAll('tr, .row, .list-item, .entry-list-item'));479 480 for (const row of containers) {481 const text = row.innerText.toLowerCase();482 // Find any clickable 'Apply' element (button or link)483 const clickable = row.querySelector('button, a.btn, a[class*="btn"]');484 if (!clickable) continue;485 486 const label = clickable.innerText.toLowerCase().trim();487 if (!label.includes('apply')) continue;488 489 // Keywords for Ordinary Shares490 const isOrdinary = text.includes('ordinary') || text.includes('equity') || text.includes('public issue');491 492 // Keywords to exclude493 const isExclude = text.includes('debenture') || 494 text.includes('bond') || 495 text.includes('mutual fund') || 496 text.includes('preference') ||497 text.includes('right') ||498 text.includes('promoter');499 500 if (isOrdinary && !isExclude) {501 // Extract company name (first line or before the first dash)502 const rawName = row.innerText.split(/[\\n-]/)[0].trim();503 // Clean up if it grabbed headers504 if (rawName.toLowerCase().includes('company') || rawName.length < 3) continue;505 506 clickable.click();507 return rawName;508 }509 }510 return null;511 }512 """)513 514 if clicked_ipo:515 break516 517 if attempt == 0:518 print(f"[{username}] No 'Ordinary Shares' found on first pass. Refreshing list...")519 page.reload(wait_until='networkidle')520 page.wait_for_timeout(4000)521 522 if clicked_ipo:523 print(f"[{username}] Targeted IPO: {clicked_ipo}")524 return fill_and_submit_form(page, account, company_name=clicked_ipo)525 else:526 print(f"[{username}] No 'Ordinary Shares' found to apply. Skipping silently.")527 page.screenshot(path=f"debug_asba_{username}.png")528 return False, "No ordinary shares found"529 530def get_accounts():531 """532 Retrieves accounts from environment variable (JSON) or local file.533 """534 accounts = []535 accounts_env = os.getenv("ACCOUNTS_JSON")536 if accounts_env:537 try:538 accounts = json.loads(accounts_env)539 except json.JSONDecodeError:540 print("Error: Error decoding ACCOUNTS_JSON environment variable.")541 542 if not accounts and os.path.exists("accounts.json"):543 try:544 with open("accounts.json", "r") as f:545 accounts = json.load(f)546 except json.JSONDecodeError:547 print("Error: Error decoding local accounts.json file.")548 549 if not accounts and os.getenv("MEROSHARE_USER"):550 accounts = [{551 "MEROSHARE_USER": os.getenv("MEROSHARE_USER"),552 "MEROSHARE_PASS": os.getenv("MEROSHARE_PASS"),553 "DP_NAME": os.getenv("DP_NAME"),554 "CRN": os.getenv("CRN"),555 "TPIN": os.getenv("TPIN"),556 "BANK_NAME": os.getenv("BANK_NAME"),557 "KITTA": os.getenv("KITTA", "10")558 }]559 560 return accounts561 562def check_status(page, account):563 """564 Refined Status Watchdog:565 1. Scrapes available IPO names from 'Apply for Issue'.566 2. Only checks the status for those specific names in 'Application Report'.567 """568 username = account['MEROSHARE_USER']569 print(f"[{username}] Starting targeted Status Watchdog...")570 571 try:572 # Directly navigate to Application Report page573 try:574 page.goto("https://meroshare.cdsc.com.np/#/asba/report", wait_until='networkidle')575 page.wait_for_timeout(2000)576 except Exception as e:577 print(f"[{username}] Failed to open Application Report via direct URL: {e}")578 return579 580 page.wait_for_selector("a:has-text('Apply for Issue')", timeout=10000)581 page.click("a:has-text('Apply for Issue')")582 page.wait_for_load_state('networkidle')583 page.wait_for_timeout(3000)584 585 active_ipo_names = page.evaluate("""586 () => {587 const items = Array.from(document.querySelectorAll('.company-name, .issue-name, h4, .d-flex b, strong'));588 const names = [];589 for (const el of items) {590 let text = el.innerText.trim();591 if (text.length > 5) {592 // Clean up: Take only the first part before any '-' or newline593 // This usually captures the core "Super Khudi Hydropower Limited"594 const cleanName = text.split(/[\\n-]/)[0].trim();595 if (cleanName.length > 5) names.push(cleanName);596 }597 }598 return [...new Set(names)];599 }600 """)601 602 if not active_ipo_names:603 print(f"[{username}] No active IPOs found in 'Apply for Issue'. Skipping status check.")604 return605 606 print(f"[{username}] Monitoring status for: {', '.join(active_ipo_names)}")607 608 # Step 2: Switch to 'Application Report'609 report_link_selector = "a:has-text('Application Report')"610 page.click(report_link_selector)611 612 # Robust wait for the list to load - handle 'loading' spinner613 print(f"[{username}] Waiting for Application Report to populate...")614 615 for attempt in range(2):616 try:617 # Wait for loading text/spinner to DISAPPEAR618 page.wait_for_selector("text=loading", state="detached", timeout=10000)619 # Then wait for actual buttons to appear620 page.wait_for_selector("button:has-text('Report'), a:has-text('Report')", timeout=15000)621 break622 except:623 if attempt == 0:624 print(f"[{username}] ΓÅ│ Report list still loading or empty. Proactively re-clicking...")625 page.click(report_link_selector)626 page.wait_for_timeout(3000)627 else:628 print(f"[{username}] ΓÜá∩╕Å 'Report' buttons didn't appear after retry. Saving debug screenshot.")629 page.screenshot(path=f"debug_timeout_report_{username}.png")630 return631 632 for target_ipo in active_ipo_names:633 print(f"[{username}] Checking report for: {target_ipo}")634 try:635 # Identify and click 'Report' or 'Edit' for the specific IPO636 clicked_info = page.evaluate(f"""637 (targetName) => {{638 const targetLow = targetName.toLowerCase().trim();639 const searchWords = targetLow.split(' ').filter(w => w.length > 2).slice(0, 3);640 641 // Look for common row containers642 const allRows = Array.from(document.querySelectorAll('tr, .d-flex-row, .application-item, .card, div[class*="row"]'))643 .filter(el => el.querySelector('button, a'));644 645 for (const row of allRows) {{646 const text = row.innerText.toLowerCase();647 const hasFull = text.includes(targetLow);648 const hasWords = searchWords.length > 0 && searchWords.every(w => text.includes(w));649 650 if (hasFull || hasWords) {{651 // Find buttons inside this row652 const btn = Array.from(row.querySelectorAll('button, a'))653 .find(el => {{654 const t = el.innerText.trim().toLowerCase();655 return t === 'report' || t === 'edit' || t.includes('view');656 }});657 if (btn) {{658 btn.click();659 return {{ success: true, mode: btn.innerText.trim() }};660 }}661 }}662 }}663 return {{ success: false }};664 }}665 """, target_ipo)666 667 if not clicked_info.get('success'):668 print(f"[{username}] ΓÅ│ {target_ipo} not found or has no available action.")669 continue670 671 if clicked_info.get('mode', '').lower() == 'edit':672 print(f"[{username}] 'Edit' mode detected from list view. Filling form...")673 fill_and_submit_form(page, account, company_name=target_ipo)674 page.goto("https://meroshare.cdsc.com.np/#/asba/report", wait_until='networkidle')675 continue676 677 page.wait_for_load_state('networkidle')678 page.wait_for_timeout(4000)679 680 # Read status from the detail page (robust extraction)681 detail_status = page.evaluate("""682 () => {683 const bodyText = document.body.innerText.toLowerCase();684 const labels = Array.from(document.querySelectorAll('label, th, td, b, span, p, div'));685 686 const findValue = (searchText) => {687 const label = labels.find(el => {688 const t = el.innerText.toLowerCase().trim();689 return t === searchText || t.startsWith(searchText + ':') || t.includes(searchText + ' ');690 });691 if (!label) return null;692 693 let val = null;694 if (label.nextElementSibling) val = label.nextElementSibling.innerText.trim();695 else if (label.parentElement && label.parentElement.nextElementSibling) {696 val = label.parentElement.nextElementSibling.innerText.trim();697 } else if (label.innerText.includes(':')) {698 val = label.innerText.split(':')[1].trim();699 }700 701 // Filter out garbage (dates, times, too short)702 if (val && (val.toLowerCase().includes('date') || val.toLowerCase().includes('time') || val.length < 3)) return null;703 704 return val;705 };706 707 // Prioritize specific status fields708 const statusKeys = ['block amount status', 'verification status', 'bank status', 'status'];709 let statusLine = null;710 for (const k of statusKeys) {711 statusLine = findValue(k);712 if (statusLine) break;713 }714 715 // Fallback: Check if common status words are present in the body716 if (!statusLine || statusLine.length < 3) {717 if (bodyText.includes('verified') && !bodyText.includes('unverified')) statusLine = 'verified';718 else if (bodyText.includes('rejected')) statusLine = 'rejected';719 else if (bodyText.includes('unverified')) statusLine = 'unverified';720 }721 722 return { 723 status: statusLine, 724 remark: findValue('remark') || findValue('reason') 725 };726 }727 """)728 729 status_val = (detail_status.get('status') or "").lower()730 remark_val = (detail_status.get('remark') or "").lower()731 print(f"[{username}] {target_ipo} -> Status: {status_val}, Remark: {remark_val}")732 733 # Notification logic for final results734 if "verified" in status_val and "unverified" not in status_val:735 print(f"[{username}] ✅ SUCCESS: {target_ipo} is Verified. (Email skipped as per configuration)")736 # send_email_notification(account.get('EMAIL'), f"[MeroShare] Status: Verified!", f"Hi {username},\n\n{target_ipo} has been applied successfully.")737 elif "rejected" in status_val or "insufficient" in remark_val or "balance" in remark_val:738 notif_msg = f"Your IPO ({target_ipo}) application was rejected due to insufficient balance. Please top up and try again."739 print(f"[{username}] ❌ REJECTED: {notif_msg}")740 741 auto_reapply_enabled = os.getenv("AUTO_REAPPLY", "false").lower() == "true"742 743 if auto_reapply_enabled:744 print(f"[{username}] Auto-reapply enabled. Looking for button...")745 reapply_btn = page.locator("button:has-text('Edit'), button:has-text('Re-Apply'), button:has-text('Reapply')").first746 if reapply_btn.is_visible():747 print(f"[{username}] Found Reapply/Edit button. Clicking...")748 reapply_btn.click()749 page.wait_for_load_state('networkidle')750 # fill_and_submit_form handles its own success/failure notifications751 fill_and_submit_form(page, account, company_name=target_ipo)752 page.goto("https://meroshare.cdsc.com.np/#/asba/report", wait_until='networkidle')753 continue754 else:755 print(f"[{username}] No reapply button found for rejected IPO. Sending notification.")756 757 # Send push notification to FCM tokens758 owner_id = account.get('owner_id')759 tokens = get_fcm_tokens_for_user(username, owner_id)760 if tokens:761 send_push_notification(tokens, 'IPO Failed: Insufficient Balance', notif_msg)762 763 # Save to DB so it shows in the app's notification tab764 db_url = os.getenv("DATABASE_URL")765 account_id = account.get('ID') or account.get('id')766 if db_url and account_id:767 try:768 import psycopg2, datetime as _dt769 conn = psycopg2.connect(db_url)770 cur = conn.cursor()771 cur.execute(772 """773 INSERT INTO automation_applicationlog774 (account_id, company_name, status, remark, timestamp, is_read, is_listed)775 VALUES (%s, %s, %s, %s, %s, %s, %s)776 """,777 (account_id, target_ipo, 'Failed', notif_msg,778 _dt.datetime.utcnow(), False, False)779 )780 conn.commit()781 cur.close()782 conn.close()783 print(f"[{username}] Saved insufficient balance failure log for {target_ipo}")784 except Exception as db_err:785 print(f"[{username}] DB log error for insufficient balance: {db_err}")786 else:787 print(f"[{username}] ⏳ {target_ipo} still pending ({status_val}).")788 789 # Return to list790 page.go_back()791 page.wait_for_load_state('networkidle')792 page.wait_for_timeout(2000)793 794 except Exception as e:795 print(f"[{username}] Error checking {target_ipo}: {e}")796 page.goto("https://meroshare.cdsc.com.np/#/asba/report", wait_until='networkidle')797 798 except Exception as e:799 print(f"[{username}] Fatal error in check_status: {e}")800 801 802def check_allotment_results(page, account):803 """804 Checks the allotment status of applied companies for this account.805 """806 import psycopg2807 import datetime808 809 db_url = os.getenv("DATABASE_URL")810 if not db_url:811 print("DATABASE_URL not set, skipping allotment check.")812 return813 814 username = account.get('MEROSHARE_USER')815 account_id = account.get('ID')816 if not account_id:817 account_id = account.get('id')818 819 if not account_id:820 print(f"[{username}] No account ID found in data, skipping allotment check.")821 return822 823 owner_id = account.get('owner_id')824 if not owner_id:825 try:826 conn = psycopg2.connect(db_url)827 cur = conn.cursor()828 cur.execute("SELECT owner_id FROM automation_account WHERE id = %s", (account_id,))829 row = cur.fetchone()830 if row:831 owner_id = row[0]832 cur.close()833 conn.close()834 except Exception:835 pass836 837 # 1. Fetch companies successfully applied ('Success', 'Skipped', 'NotFound') but missing final status in DB for this account838 try:839 conn = psycopg2.connect(db_url)840 cur = conn.cursor()841 cur.execute("""842 SELECT DISTINCT company_name 843 FROM automation_applicationlog 844 WHERE account_id = %s AND status IN ('Success', 'Skipped', 'NotFound')845 AND company_name NOT IN (846 SELECT DISTINCT company_name 847 FROM automation_applicationlog 848 WHERE account_id = %s AND status IN ('Allotted', 'Not Allotted', 'Rejected')849 )850 """, (account_id, account_id))851 unchecked_companies = [row[0] for row in cur.fetchall() if row[0] and row[0] != "Auto-Check"]852 cur.close()853 conn.close()854 except Exception as e:855 print(f"[{username}] Error fetching unchecked companies: {e}")856 return857 858 if not unchecked_companies:859 print(f"[{username}] No unchecked IPO results in database.")860 return861 862 print(f"[{username}] Found unchecked companies for allotment check: {unchecked_companies}")863 864 # Navigate to My ASBA -> Application Report865 try:866 page.wait_for_selector(".nav-link:has-text('My ASBA')", timeout=15000)867 page.click(".nav-link:has-text('My ASBA')")868 page.wait_for_timeout(2000)869 870 report_link_selector = "a:has-text('Application Report')"871 page.click(report_link_selector)872 page.wait_for_timeout(3000)873 874 # Wait for Report buttons to appear875 page.wait_for_selector("button:has-text('Report'), a:has-text('Report')", timeout=15000)876 print(f"[{username}] Navigated to Application Report page.")877 except Exception as e:878 print(f"[{username}] Failed to open Application Report: {e}")879 return880 881 882 for target_company in unchecked_companies:883 print(f"[{username}] Checking allotment status for {target_company}...")884 885 886 # Skip entries that are not actual company names (e.g., Balance Check)887 ignore_keywords = ["balance", "check"]888 lowered = target_company.lower()889 if any(kw in lowered for kw in ignore_keywords):890 print(f"[{username}] Skipping non-company entry: {target_company}")891 # No DB update; just continue to next company892 continue893 894 # Click Report button for target company using Playwright's native locator API895 # Find the <li> row that contains the company name, then click the Report button inside it896 clicked = False897 try:898 # Use first 3 significant words for robust matching against display text like "Company - For General Public (SYMBOL)"899 match_words = [w for w in target_company.split() if len(w) > 2][:3]900 match_text = ' '.join(match_words)901 print(f"[{username}] Searching for row matching: '{match_text}'")902 903 # Find the div row containing the company name904 row = page.locator('.company-list').filter(has_text=match_text).first905 row.wait_for(timeout=8000)906 907 # Find and click the Report button inside that row908 btn = row.locator('button', has_text='Report').first909 btn.scroll_into_view_if_needed()910 btn.click()911 clicked = True912 print(f"[{username}] Clicked Report for {target_company}")913 except Exception as e:914 print(f"[{username}] Could not click Report button for {target_company}: {e}")915 clicked = False916 917 if not clicked:918 print(f"[{username}] Report button not found for {target_company}")919 continue920 921 # Wait for the details page to load922 page.wait_for_load_state('networkidle', timeout=15000)923 page.wait_for_timeout(2000)924 925 926 # Extract status and remark using regex on full page text927 try:928 import re929 full_text = page.inner_text("body")930 931 status_match = re.search(r'Status\s*\n\s*([^\n]+)', full_text, re.IGNORECASE)932 status_val = status_match.group(1).strip() if status_match else ''933 934 remark_match = re.search(r'Remarks?\s*\n\s*([^\n]+)', full_text, re.IGNORECASE)935 final_remark = remark_match.group(1).strip() if remark_match else ''936 except Exception:937 status_val = ''938 final_remark = ''939 940 final_status = status_val.strip().lower()941 942 # Try to parse exact status out of the block of text943 if 'allotted' in final_status or 'alloted' in final_status:944 if 'not allotted' in final_status or 'not alloted' in final_status:945 parsed_status = 'Not Allotted'946 else:947 parsed_status = 'Allotted'948 elif 'rejected' in final_status:949 parsed_status = 'Rejected'950 else:951 parsed_status = final_status952 953 if parsed_status.lower() in ('allotted', 'not allotted', 'rejected'):954 # Compose notification messages955 if parsed_status == 'Allotted':956 notif_body = f"Congratulations!! {target_company} has been allotted."957 elif parsed_status == 'Not Allotted':958 notif_body = f"{target_company} has not been allotted."959 elif parsed_status == 'Rejected':960 notif_body = f"Sorry!! your application for {target_company} has been rejected due to insufficient balance."961 else:962 notif_body = f"{target_company}: {parsed_status}"963 964 # Update the existing 'Success' row status in the database965 try:966 conn = psycopg2.connect(db_url)967 cur = conn.cursor()968 cur.execute(969 """970 UPDATE automation_applicationlog971 SET status = %s, remark = %s, timestamp = %s972 WHERE account_id = %s AND company_name = %s AND status IN ('Success', 'Skipped', 'NotFound')973 """,974 (parsed_status.title(), final_remark, datetime.datetime.utcnow(), account_id, target_company)975 )976 conn.commit()977 978 # INSERT a NEW log entry for the result so it appears in the app notification tab979 cur.execute(980 """981 INSERT INTO automation_applicationlog982 (account_id, company_name, status, remark, timestamp, is_read, is_listed)983 VALUES (%s, %s, %s, %s, %s, %s, %s)984 """,985 (account_id, target_company, parsed_status.title(), notif_body,986 datetime.datetime.utcnow(), False, False)987 )988 conn.commit()989 print(f"[{username}] Inserted result log entry: {parsed_status} for {target_company}")990 except Exception as e:991 print(f"[{username}] DB update/insert error for {target_company}: {e}")992 finally:993 if 'cur' in locals():994 cur.close()995 if 'conn' in locals():996 conn.close()997 998 # Fetch FCM tokens999 tokens = get_fcm_tokens_for_user(username, owner_id)1000 1001 # Send push notification (shows in notification bar AND is stored in app notification tab via the new log)1002 if tokens:1003 send_push_notification(tokens, 'IPO Result', notif_body)1004 else:1005 print(f"[{username}] Status '{status_val}' is not finalized yet. Skipping.")1006 1007 # Reliable navigation back to the Application Report list1008 try:1009 # First try window.history.back() as it works best for SPA1010 page.evaluate("window.history.back()")1011 page.wait_for_timeout(2000)1012 1013 # Check if we are back by looking for the Report buttons (using valid standard CSS)1014 has_buttons = page.evaluate("() => document.querySelectorAll('.btn-issue').length > 0")1015 if not has_buttons:1016 # Fallback: re-navigate from side menu1017 page.click(".nav-link:has-text('My ASBA')", timeout=5000)1018 page.wait_for_timeout(1000)1019 page.click("a:has-text('Application Report')", timeout=5000)1020 1021 page.wait_for_selector("button:has-text('Report'), a:has-text('Report'), .btn-issue", timeout=15000)1022 except Exception as e:1023 print(f"[{username}] Error returning to report list: {e}")1024 # Ultimate fallback: reload and re-navigate1025 page.goto("https://meroshare.cdsc.com.np", wait_until='networkidle')1026 page.wait_for_timeout(2000)1027 page.click(".nav-link:has-text('My ASBA')", timeout=5000)1028 page.wait_for_timeout(1000)1029 page.click("a:has-text('Application Report')", timeout=5000)1030 page.wait_for_selector("button:has-text('Report'), a:has-text('Report')", timeout=15000)1031 1032 1033 1034 1035 1036def run_automation():1037 accounts = get_accounts()1038 if not accounts:1039 print("Error: No accounts found. Check accounts.json, ACCOUNTS_JSON secret, or .env file.")1040 return1041 1042 count = len(accounts)1043 print(f"Found {count} account(s) to process.")1044 1045 with sync_playwright() as p:1046 headless = os.getenv("HEADLESS", "true").lower() == "true"1047 browser = p.chromium.launch(headless=headless)1048 1049 for i, account in enumerate(accounts):1050 username = account.get('MEROSHARE_USER')1051 print(f"\n=============================================")1052 print(f"Processing Account {i+1}/{count}: {username}")1053 print(f"=============================================")1054 1055 page = browser.new_page()1056 try:1057 page.goto("https://meroshare.cdsc.com.np", timeout=60000)1058 MAX_RETRIES = 31059 logged_in = False1060 for attempt in range(1, MAX_RETRIES + 1):1061 login_result = login(page, username, account['MEROSHARE_PASS'], account['DP_NAME'])1062 if login_result is True:1063 print(f"Login Successful!")1064 logged_in = True1065 break1066 elif login_result == "EXPIRED":1067 if handle_password_reset(page, account):1068 print(f"[{username}] Password successfully reset and logged in.")1069 logged_in = True1070 else:1071 print(f"[{username}] Password reset failed.")1072 break # Don't retry login if expired/reset attempted1073 elif login_result in ("DEMAT_EXPIRED", "MEROSHARE_EXPIRED"):1074 handle_expired_account(account, login_result)1075 break1076 else:1077 print(f"Error: [{username}] Login failed (Attempt {attempt}). Retrying...")1078 page.reload()1079 page.wait_for_load_state('networkidle')1080 time.sleep(2)1081 1082 if logged_in:1083 check_account_expiry_warning(page, account)1084 apply_ipo(page, account)1085 check_allotment_results(page, account)1086 else:1087 print(f"Error: [{username}] Failed to login after {MAX_RETRIES} attempts.")1088 1089 except Exception as e:1090 print(f"Error: [{username}] Error processing account: {e}")1091 finally:1092 page.close()1093 1094 browser.close()1095 print("\nAll accounts processed.")1096 1097 1098def run_status_check():1099 """1100 Watchdog: Logs in to each account and checks Application Report.1101 Only sends notification when a FINAL status is found.1102 Runs silently if still in process (bank/holiday delay).1103 """1104 accounts = get_accounts()1105 if not accounts:1106 print("Error: No accounts found.")1107 return1108 1109 count = len(accounts)1110 print(f"≡ƒöì Status Watchdog: Checking {count} account(s)...")1111 1112 with sync_playwright() as p:1113 headless = os.getenv("HEADLESS", "true").lower() == "true"1114 browser = p.chromium.launch(headless=headless)1115 1116 for i, account in enumerate(accounts):1117 username = account.get('MEROSHARE_USER')1118 print(f"\n=============================================")1119 print(f"Status Check {i+1}/{count}: {username}")1120 print(f"=============================================")1121 1122 page = browser.new_page()1123 try:1124 page.goto("https://meroshare.cdsc.com.np", timeout=60000)1125 MAX_RETRIES = 31126 logged_in = False1127 for attempt in range(1, MAX_RETRIES + 1):1128 login_result = login(page, username, account['MEROSHARE_PASS'], account['DP_NAME'])1129 if login_result is True:1130 logged_in = True1131 break1132 elif login_result in ("EXPIRED", "DEMAT_EXPIRED", "MEROSHARE_EXPIRED"):1133 print(f"[{username}] Login blocked ({login_result}). Skipping check in status mode.")1134 break1135 else:1136 page.reload()1137 page.wait_for_load_state('networkidle')1138 time.sleep(2)1139 1140 if logged_in:1141 check_status(page, account)1142 check_allotment_results(page, account)1143 else:1144 print(f"Error: [{username}] Could not log in for status check (or checks restricted to apply mode).")1145 1146 except Exception as e:1147 print(f"Error: [{username}] {e}")1148 finally:1149 page.close()1150 1151 browser.close()1152 print("\nStatus check run complete.")1153 1154 1155if __name__ == "__main__":1156 # RUN_MODE=check_status ΓåÆ runs the status watchdog1157 # RUN_MODE=apply (default) ΓåÆ applies for IPOs1158 mode = os.getenv("RUN_MODE", "apply").lower()1159 if mode == "check_status":1160 run_status_check()1161 else:1162 run_automation()1163 