CoolFace
Apppublic

Bishall10/ipo

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
main.py1163 linesDownload Raw Back to root
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