Created
July 31, 2026 20:03
-
-
Save me-suzy/14237dc7483fe7de06455102d74b2872 to your computer and use it in GitHub Desktop.
Biblioteca Digitala - GENERIC BUN FINAL.txt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Generic PDF downloader for biblioteca-digitala.ro | |
| Works with any publication page. | |
| Usage: | |
| python download_biblioteca_digitala.py "https://biblioteca-digitala.ro/?pub=7758-revista-romana-de-sociologie" | |
| python download_biblioteca_digitala.py "https://biblioteca-digitala.ro/?pub=6464-studii-si-cercetari-de-chimie" | |
| """ | |
| import requests | |
| from bs4 import BeautifulSoup | |
| import atexit | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import socket | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import time | |
| import unicodedata | |
| from urllib.parse import urljoin, urlparse, parse_qs, unquote | |
| try: | |
| import websocket | |
| except Exception: | |
| websocket = None | |
| try: | |
| from selenium import webdriver | |
| from selenium.webdriver.chrome.options import Options as ChromeOptions | |
| from selenium.webdriver.common.by import By | |
| except Exception: | |
| webdriver = None | |
| ChromeOptions = None | |
| By = None | |
| HEADERS = { | |
| 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', | |
| 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', | |
| 'Accept-Language': 'ro-RO,ro;q=0.9,en;q=0.8', | |
| } | |
| BASE_OUTPUT_DIR = r"G:\DOWNLOAD BIBLIOTECA" | |
| BROWSER_TIMEOUT_SECONDS = 300 | |
| REQUEST_TIMEOUT = (15, 45) | |
| BROWSER_DRIVER = None | |
| BROWSER_NOTICE_SHOWN = False | |
| CDP_BROWSER = { | |
| 'process': None, | |
| 'port': None, | |
| 'ws': None, | |
| 'msg_id': 0, | |
| 'profile_dir': None, | |
| } | |
| TITLE_MATCH_STOPWORDS = { | |
| 'anul', 'cercetari', 'cercetare', 'colectia', 'de', 'din', 'jurnal', | |
| 'periodic', 'publicatie', 'revista', 'roman', 'romana', 'romane', | |
| 'romanesc', 'romanesti', 'si', 'studii' | |
| } | |
| def is_cloudflare_challenge_text(text): | |
| """Detect Cloudflare challenge pages returned instead of the real site.""" | |
| if not text: | |
| return False | |
| lower = text.lower() | |
| return ( | |
| 'just a moment' in lower | |
| or 'doar un moment' in lower | |
| or 'cf-chl' in lower | |
| or 'verificarii de securitate' in strip_diacritics(lower) | |
| or 'cloudflare' in lower and 'bot' in lower | |
| ) | |
| def is_real_biblioteca_page(html, body_text=''): | |
| """Detect the real biblioteca-digitala page, even if old challenge text remains in DOM.""" | |
| combined = f"{html or ''}\n{body_text or ''}" | |
| normalized = strip_diacritics(combined).lower() | |
| return ( | |
| 'datatable-default' in combined | |
| or 'natura si omul' in normalized | |
| or 'numere de revista' in normalized | |
| or ('tip publicatie' in normalized and 'biblioteca digitala' in normalized) | |
| or ('publicata de' in normalized and 'issn' in normalized) | |
| ) | |
| def is_cloudflare_response(response): | |
| """Return True when a response looks like Cloudflare anti-bot HTML.""" | |
| content_type = response.headers.get('Content-Type', '').lower() | |
| server = response.headers.get('Server', '').lower() | |
| if response.status_code in (403, 429, 503) or 'text/html' in content_type: | |
| try: | |
| text = response.text[:20000] | |
| except Exception: | |
| text = '' | |
| return ( | |
| is_cloudflare_challenge_text(text) | |
| or (response.status_code == 403 and 'cloudflare' in server) | |
| ) | |
| return False | |
| def find_chrome_executable(): | |
| """Find Chrome executable on Windows.""" | |
| candidates = [] | |
| env_path = os.environ.get('BIBLIOTECA_CHROME_EXE') | |
| if env_path: | |
| candidates.append(env_path) | |
| program_files = os.environ.get('PROGRAMFILES', r'C:\Program Files') | |
| program_files_x86 = os.environ.get('PROGRAMFILES(X86)', r'C:\Program Files (x86)') | |
| local_app_data = os.environ.get('LOCALAPPDATA', '') | |
| candidates.extend([ | |
| os.path.join(program_files, 'Google', 'Chrome', 'Application', 'chrome.exe'), | |
| os.path.join(program_files_x86, 'Google', 'Chrome', 'Application', 'chrome.exe'), | |
| os.path.join(local_app_data, 'Google', 'Chrome', 'Application', 'chrome.exe'), | |
| os.path.join(program_files, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), | |
| os.path.join(program_files_x86, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), | |
| ]) | |
| for path in candidates: | |
| if path and os.path.exists(path): | |
| return path | |
| return None | |
| def get_free_port(): | |
| """Return an available localhost port for Chrome DevTools.""" | |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: | |
| sock.bind(('127.0.0.1', 0)) | |
| return sock.getsockname()[1] | |
| def cdp_http_json(port, path, timeout=3): | |
| """Read JSON from Chrome DevTools HTTP endpoint.""" | |
| response = requests.get(f'http://127.0.0.1:{port}{path}', timeout=timeout) | |
| response.raise_for_status() | |
| return response.json() | |
| def cdp_send(method, params=None): | |
| """Send one Chrome DevTools Protocol command.""" | |
| ws = CDP_BROWSER.get('ws') | |
| if not ws: | |
| raise RuntimeError('CDP websocket is not connected') | |
| CDP_BROWSER['msg_id'] += 1 | |
| msg_id = CDP_BROWSER['msg_id'] | |
| ws.send(json.dumps({ | |
| 'id': msg_id, | |
| 'method': method, | |
| 'params': params or {}, | |
| })) | |
| while True: | |
| message = json.loads(ws.recv()) | |
| if message.get('id') == msg_id: | |
| if 'error' in message: | |
| raise RuntimeError(message['error']) | |
| return message.get('result', {}) | |
| def cdp_eval(expression): | |
| """Evaluate JavaScript in the active Chrome page.""" | |
| result = cdp_send('Runtime.evaluate', { | |
| 'expression': expression, | |
| 'returnByValue': True, | |
| 'awaitPromise': True, | |
| }) | |
| remote = result.get('result', {}) | |
| return remote.get('value') | |
| def close_cdp_browser(): | |
| """Close Chrome launched through CDP.""" | |
| ws = CDP_BROWSER.get('ws') | |
| if ws: | |
| try: | |
| ws.close() | |
| except Exception: | |
| pass | |
| CDP_BROWSER['ws'] = None | |
| process = CDP_BROWSER.get('process') | |
| if process and process.poll() is None: | |
| try: | |
| process.terminate() | |
| except Exception: | |
| pass | |
| CDP_BROWSER['process'] = None | |
| def get_cdp_browser(start_url): | |
| """Launch a normal Chrome window with DevTools enabled, avoiding Selenium.""" | |
| if CDP_BROWSER.get('ws'): | |
| return True | |
| if websocket is None: | |
| print(" [ERROR] Modulul websocket nu este disponibil pentru fallback CDP.", flush=True) | |
| return False | |
| chrome_path = find_chrome_executable() | |
| if not chrome_path: | |
| print(" [ERROR] Nu gasesc chrome.exe sau msedge.exe.", flush=True) | |
| return False | |
| port = get_free_port() | |
| profile_dir = os.environ.get('BIBLIOTECA_CDP_PROFILE') | |
| if not profile_dir: | |
| profile_dir = tempfile.mkdtemp(prefix='biblioteca_digitala_cdp_') | |
| os.makedirs(profile_dir, exist_ok=True) | |
| args = [ | |
| chrome_path, | |
| f'--remote-debugging-port={port}', | |
| '--remote-allow-origins=*', | |
| f'--user-data-dir={profile_dir}', | |
| '--no-first-run', | |
| '--no-default-browser-check', | |
| '--disable-popup-blocking', | |
| '--lang=ro-RO', | |
| '--new-window', | |
| start_url, | |
| ] | |
| print(" [INFO] Pornesc Chrome normal cu DevTools/CDP...", flush=True) | |
| try: | |
| process = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) | |
| except Exception as e: | |
| print(f" [ERROR] Nu pot porni Chrome: {e}", flush=True) | |
| return False | |
| CDP_BROWSER['process'] = process | |
| CDP_BROWSER['port'] = port | |
| CDP_BROWSER['profile_dir'] = profile_dir | |
| atexit.register(close_cdp_browser) | |
| deadline = time.time() + 30 | |
| last_error = None | |
| pages = [] | |
| while time.time() < deadline: | |
| try: | |
| pages = cdp_http_json(port, '/json') | |
| if pages: | |
| break | |
| except Exception as e: | |
| last_error = e | |
| time.sleep(0.5) | |
| if not pages: | |
| print(f" [ERROR] Chrome DevTools nu a pornit: {last_error}", flush=True) | |
| try: | |
| process.terminate() | |
| except Exception: | |
| pass | |
| return False | |
| page = next((item for item in pages if item.get('type') == 'page'), pages[0]) | |
| ws_url = page.get('webSocketDebuggerUrl') | |
| if not ws_url: | |
| print(" [ERROR] Nu am gasit webSocketDebuggerUrl pentru tabul Chrome.", flush=True) | |
| try: | |
| process.terminate() | |
| except Exception: | |
| pass | |
| return False | |
| try: | |
| CDP_BROWSER['ws'] = websocket.create_connection(ws_url, timeout=60) | |
| cdp_send('Page.enable') | |
| cdp_send('Runtime.enable') | |
| cdp_send('Network.enable') | |
| print(" [OK] Chrome CDP pornit.", flush=True) | |
| return True | |
| except Exception as e: | |
| print(f" [ERROR] Nu ma pot conecta la Chrome DevTools: {e}", flush=True) | |
| try: | |
| process.terminate() | |
| except Exception: | |
| pass | |
| return False | |
| def cdp_sync_cookies_to_session(session): | |
| """Copy cookies and user-agent from the CDP browser into requests.""" | |
| try: | |
| user_agent = cdp_eval('navigator.userAgent') | |
| if user_agent: | |
| HEADERS['User-Agent'] = user_agent | |
| session.headers.update(HEADERS) | |
| except Exception: | |
| pass | |
| try: | |
| result = cdp_send('Network.getAllCookies') | |
| for cookie in result.get('cookies', []): | |
| domain = cookie.get('domain') | |
| if not domain or 'biblioteca-digitala.ro' not in domain: | |
| continue | |
| session.cookies.set( | |
| cookie.get('name'), | |
| cookie.get('value'), | |
| domain=domain, | |
| path=cookie.get('path') or '/', | |
| ) | |
| except Exception: | |
| pass | |
| def cdp_page_snapshot(): | |
| """Return current Chrome page title/text/html through CDP.""" | |
| return cdp_eval(r""" | |
| (() => { | |
| const body = document.body; | |
| return { | |
| title: document.title || '', | |
| url: location.href || '', | |
| text: body ? body.innerText || '' : '', | |
| html: document.documentElement ? document.documentElement.outerHTML || '' : '' | |
| }; | |
| })() | |
| """) or {} | |
| def cdp_click_cloudflare_checkbox(): | |
| """Click the visible Cloudflare checkbox using real mouse events through CDP.""" | |
| rect = cdp_eval(r""" | |
| (() => { | |
| const input = document.querySelector('input[type="checkbox"]'); | |
| if (!input) return null; | |
| const target = input.closest('label') || input; | |
| const r = target.getBoundingClientRect(); | |
| if (!r || !r.width || !r.height) return null; | |
| return { | |
| x: r.left + Math.min(24, Math.max(8, r.width / 2)), | |
| y: r.top + r.height / 2, | |
| width: r.width, | |
| height: r.height, | |
| checked: input.checked | |
| }; | |
| })() | |
| """) | |
| if not rect: | |
| return False | |
| x = float(rect['x']) | |
| y = float(rect['y']) | |
| print(" [ACTION] Am gasit checkbox Cloudflare; dau click...", flush=True) | |
| cdp_send('Input.dispatchMouseEvent', { | |
| 'type': 'mouseMoved', | |
| 'x': x, | |
| 'y': y, | |
| 'button': 'none', | |
| }) | |
| cdp_send('Input.dispatchMouseEvent', { | |
| 'type': 'mousePressed', | |
| 'x': x, | |
| 'y': y, | |
| 'button': 'left', | |
| 'clickCount': 1, | |
| }) | |
| cdp_send('Input.dispatchMouseEvent', { | |
| 'type': 'mouseReleased', | |
| 'x': x, | |
| 'y': y, | |
| 'button': 'left', | |
| 'clickCount': 1, | |
| }) | |
| return True | |
| def browser_fetch_html_cdp(url, session): | |
| """Use normal Chrome + CDP to pass the checkbox page and read HTML.""" | |
| if os.environ.get('BIBLIOTECA_SKIP_CDP') == '1': | |
| return None | |
| if not get_cdp_browser(url): | |
| return None | |
| print(f" [INFO] Chrome CDP incarca: {url}", flush=True) | |
| try: | |
| cdp_send('Page.navigate', {'url': url}) | |
| except Exception as e: | |
| print(f" [WARN] Nu am putut naviga prin CDP: {e}", flush=True) | |
| deadline = time.time() + BROWSER_TIMEOUT_SECONDS | |
| next_status_at = 0 | |
| next_click_at = 0 | |
| next_reload_at = 0 | |
| manual_prompt_shown = False | |
| while time.time() < deadline: | |
| try: | |
| snap = cdp_page_snapshot() | |
| title = snap.get('title', '') | |
| text = snap.get('text', '') | |
| html = snap.get('html', '') | |
| if html and is_real_biblioteca_page(html, text): | |
| print(" [OK] Chrome CDP a incarcat pagina reala.", flush=True) | |
| cdp_sync_cookies_to_session(session) | |
| return html | |
| now = time.time() | |
| normalized_text = strip_diacritics(text).lower() | |
| if ( | |
| now >= next_reload_at | |
| and ('error code 522' in normalized_text or 'connection timed out' in normalized_text) | |
| ): | |
| print(" [WARN] Cloudflare 522 detectat; reincarc pagina...", flush=True) | |
| try: | |
| cdp_send('Page.navigate', {'url': url}) | |
| except Exception: | |
| pass | |
| next_reload_at = now + 20 | |
| if now >= next_click_at and ('Verify you are human' in text or 'input type="checkbox"' in html): | |
| if cdp_click_cloudflare_checkbox(): | |
| next_click_at = now + 8 | |
| if now >= next_status_at: | |
| short_text = strip_diacritics(text).replace('\n', ' ')[:100] | |
| print(f" [WAIT] Chrome/CDP: {title or '(fara titlu)'} | {short_text}", flush=True) | |
| next_status_at = now + 10 | |
| if ( | |
| sys.stdin.isatty() | |
| and not manual_prompt_shown | |
| and now >= deadline - BROWSER_TIMEOUT_SECONDS + 45 | |
| ): | |
| manual_prompt_shown = True | |
| print("\n [ACTION] Daca vezi checkbox-ul in Chrome, bifeaza-l manual.", flush=True) | |
| print(" Dupa ce apare pagina reala, apasa ENTER aici.", flush=True) | |
| try: | |
| input(" ENTER dupa verificare: ") | |
| except EOFError: | |
| print(" Input indisponibil; continui automat.", flush=True) | |
| snap = cdp_page_snapshot() | |
| html = snap.get('html', '') | |
| text = snap.get('text', '') | |
| if html and is_real_biblioteca_page(html, text): | |
| cdp_sync_cookies_to_session(session) | |
| return html | |
| except Exception as e: | |
| print(f" [WARN] CDP asteapta pagina: {e}", flush=True) | |
| time.sleep(1) | |
| print(f" [ERROR] Chrome CDP nu a trecut de verificare in {BROWSER_TIMEOUT_SECONDS} secunde.", flush=True) | |
| return None | |
| def get_browser_driver(): | |
| """Open a visible Chrome window used only when Cloudflare blocks requests.""" | |
| global BROWSER_DRIVER | |
| if BROWSER_DRIVER: | |
| return BROWSER_DRIVER | |
| if os.environ.get('BIBLIOTECA_SKIP_CHROME') == '1': | |
| print(" [ERROR] BIBLIOTECA_SKIP_CHROME=1, deci fallback-ul Chrome este dezactivat.", flush=True) | |
| return None | |
| if webdriver is None or ChromeOptions is None: | |
| print(" [ERROR] Selenium/Chrome nu este disponibil pentru fallback Cloudflare.", flush=True) | |
| return None | |
| print(" [INFO] Pornesc Chrome pentru fallback Cloudflare...", flush=True) | |
| options = ChromeOptions() | |
| options.page_load_strategy = 'eager' | |
| options.add_argument('--window-size=1400,1000') | |
| options.add_argument('--lang=ro-RO') | |
| options.add_argument('--disable-blink-features=AutomationControlled') | |
| options.add_experimental_option('excludeSwitches', ['enable-automation']) | |
| options.add_experimental_option('useAutomationExtension', False) | |
| profile_dir = os.environ.get('BIBLIOTECA_CHROME_PROFILE') | |
| if profile_dir: | |
| options.add_argument(f'--user-data-dir={profile_dir}') | |
| profile_name = os.environ.get('BIBLIOTECA_CHROME_PROFILE_DIRECTORY') | |
| if profile_name: | |
| options.add_argument(f'--profile-directory={profile_name}') | |
| try: | |
| BROWSER_DRIVER = webdriver.Chrome(options=options) | |
| BROWSER_DRIVER.set_page_load_timeout(60) | |
| BROWSER_DRIVER.set_script_timeout(30) | |
| print(" [OK] Chrome fallback pornit.", flush=True) | |
| except Exception as e: | |
| print(f" [ERROR] Nu pot porni Chrome pentru fallback: {e}", flush=True) | |
| if profile_dir: | |
| print(" Daca folosesti BIBLIOTECA_CHROME_PROFILE, inchide toate ferestrele Chrome", flush=True) | |
| print(" si verifica in Task Manager sa nu mai existe chrome.exe, apoi ruleaza din nou.", flush=True) | |
| return None | |
| atexit.register(close_browser_driver) | |
| return BROWSER_DRIVER | |
| def close_browser_driver(): | |
| """Close the fallback browser at script exit.""" | |
| global BROWSER_DRIVER | |
| if BROWSER_DRIVER: | |
| try: | |
| BROWSER_DRIVER.quit() | |
| except Exception: | |
| pass | |
| BROWSER_DRIVER = None | |
| def sync_browser_cookies_to_session(driver, session): | |
| """Copy Chrome cookies and user-agent into the requests session.""" | |
| try: | |
| browser_user_agent = driver.execute_script('return navigator.userAgent') | |
| if browser_user_agent: | |
| HEADERS['User-Agent'] = browser_user_agent | |
| session.headers.update(HEADERS) | |
| except Exception: | |
| pass | |
| for cookie in driver.get_cookies(): | |
| name = cookie.get('name') | |
| value = cookie.get('value') | |
| if not name or value is None: | |
| continue | |
| domain = cookie.get('domain') or urlparse(driver.current_url).hostname | |
| path = cookie.get('path') or '/' | |
| session.cookies.set(name, value, domain=domain, path=path) | |
| def browser_fetch_html(url, session): | |
| """Load a page in Chrome, wait for Cloudflare to pass, and return HTML.""" | |
| global BROWSER_NOTICE_SHOWN | |
| if os.environ.get('BIBLIOTECA_USE_SELENIUM') != '1': | |
| html = browser_fetch_html_cdp(url, session) | |
| if html: | |
| return html | |
| print(" [WARN] Fallback-ul Chrome/CDP nu a reusit.", flush=True) | |
| print(" Incerc vechiul fallback Selenium doar daca BIBLIOTECA_USE_SELENIUM=1.", flush=True) | |
| return None | |
| driver = get_browser_driver() | |
| if not driver: | |
| return None | |
| if not BROWSER_NOTICE_SHOWN: | |
| print(" [INFO] Cloudflare a blocat request-ul Python.", flush=True) | |
| print(" Am deschis Chrome. Daca vezi verificarea Cloudflare, asteapta sau rezolv-o in fereastra deschisa.", flush=True) | |
| print(" Scriptul continua automat dupa ce apare pagina reala.", flush=True) | |
| BROWSER_NOTICE_SHOWN = True | |
| try: | |
| print(f" [INFO] Chrome incarca: {url}", flush=True) | |
| driver.get(url) | |
| except Exception as e: | |
| print(f" [WARN] Chrome a intrerupt/expirat incarcarea paginii: {e}", flush=True) | |
| print(" Verific totusi HTML-ul deja incarcat...", flush=True) | |
| deadline = time.time() + BROWSER_TIMEOUT_SECONDS | |
| last_title = '' | |
| next_status_at = 0 | |
| manual_prompt_shown = False | |
| while time.time() < deadline: | |
| try: | |
| html = driver.page_source or '' | |
| last_title = driver.title or '' | |
| body_text = '' | |
| try: | |
| body_text = driver.find_element(By.TAG_NAME, 'body').text | |
| except Exception: | |
| pass | |
| if html and is_real_biblioteca_page(html, body_text): | |
| print(" [OK] Chrome a incarcat pagina reala.", flush=True) | |
| sync_browser_cookies_to_session(driver, session) | |
| return html | |
| if html and not is_cloudflare_challenge_text(html): | |
| print(" [OK] Chrome a iesit din pagina Cloudflare.", flush=True) | |
| sync_browser_cookies_to_session(driver, session) | |
| return html | |
| now = time.time() | |
| if now >= next_status_at: | |
| print(f" [WAIT] Astept Chrome... titlu curent: {last_title or '(fara titlu)'}", flush=True) | |
| next_status_at = now + 10 | |
| if ( | |
| sys.stdin.isatty() | |
| and not manual_prompt_shown | |
| and now < deadline - 10 | |
| and now >= deadline - BROWSER_TIMEOUT_SECONDS + 20 | |
| ): | |
| manual_prompt_shown = True | |
| print("\n [ACTION] Daca pagina este deja incarcata corect in Chrome,", flush=True) | |
| print(" apasa ENTER aici in consola ca sa continui.", flush=True) | |
| print(" Daca in Chrome inca vezi verificarea Cloudflare, asteapta sa treaca si apoi ENTER.", flush=True) | |
| try: | |
| input(" ENTER dupa ce pagina este incarcata: ") | |
| except EOFError: | |
| print(" Input indisponibil; continui automat.", flush=True) | |
| html = driver.page_source or '' | |
| try: | |
| body_text = driver.find_element(By.TAG_NAME, 'body').text | |
| except Exception: | |
| body_text = '' | |
| if html and is_real_biblioteca_page(html, body_text): | |
| sync_browser_cookies_to_session(driver, session) | |
| return html | |
| if html: | |
| print(" [WARN] Pagina nu este inca pagina reala; continui asteptarea.", flush=True) | |
| except Exception: | |
| pass | |
| time.sleep(1) | |
| print(f" [ERROR] Chrome nu a trecut de Cloudflare in {BROWSER_TIMEOUT_SECONDS} secunde.", flush=True) | |
| if last_title: | |
| print(f" Ultimul titlu vazut: {last_title}", flush=True) | |
| return None | |
| def get_full_datatable_soup_with_browser(url, session, expected_count=None): | |
| """Use Chrome/CDP to extract every DataTables row, not only the visible page.""" | |
| html = browser_fetch_html_cdp(url, session) | |
| if not html: | |
| return None | |
| print(" [INFO] Extrag toate randurile din DataTables prin browser...", flush=True) | |
| expected_count_js = int(expected_count or 0) | |
| result = cdp_eval(rf""" | |
| (async () => {{ | |
| const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); | |
| const tableSelector = '#datatable-default'; | |
| const tableElement = document.querySelector(tableSelector); | |
| if (!tableElement) {{ | |
| return {{ | |
| ok: false, | |
| error: 'Nu exista #datatable-default', | |
| html: document.documentElement ? document.documentElement.outerHTML : '' | |
| }}; | |
| }} | |
| const getVisibleRows = () => Array | |
| .from(document.querySelectorAll(`${{tableSelector}} tbody tr`)) | |
| .filter(row => !row.querySelector('td.dataTables_empty')) | |
| .map(row => row.outerHTML); | |
| const addRows = (target, seen, rows) => {{ | |
| for (const rowHtml of rows) {{ | |
| const key = rowHtml.replace(/\s+/g, ' ').trim(); | |
| if (!key || seen.has(key)) continue; | |
| seen.add(key); | |
| target.push(rowHtml); | |
| }} | |
| }}; | |
| let hasDataTables = false; | |
| let info = null; | |
| let allRows = []; | |
| const seen = new Set(); | |
| if (window.jQuery && window.jQuery.fn && window.jQuery.fn.dataTable) {{ | |
| try {{ | |
| hasDataTables = window.jQuery.fn.dataTable.isDataTable(tableSelector); | |
| }} catch (e) {{ | |
| hasDataTables = false; | |
| }} | |
| }} | |
| if (hasDataTables) {{ | |
| const dt = window.jQuery(tableSelector).DataTable(); | |
| await sleep(500); | |
| info = dt.page && dt.page.info ? dt.page.info() : null; | |
| const recordsTotal = info ? Math.max(info.recordsDisplay || 0, info.recordsTotal || 0) : {expected_count_js}; | |
| try {{ | |
| dt.page.len(100).draw(false); | |
| }} catch (e) {{}} | |
| const waitDeadline = Date.now() + 15000; | |
| while (Date.now() < waitDeadline) {{ | |
| await sleep(250); | |
| info = dt.page && dt.page.info ? dt.page.info() : info; | |
| const visibleRows = getVisibleRows(); | |
| const neededNow = recordsTotal ? Math.min(recordsTotal, 100) : Math.min({expected_count_js} || 100, 100); | |
| if (!neededNow || visibleRows.length >= neededNow) break; | |
| }} | |
| info = dt.page && dt.page.info ? dt.page.info() : info; | |
| const pages = info && info.pages ? info.pages : 1; | |
| const addCurrentPage = () => addRows(allRows, seen, getVisibleRows()); | |
| addCurrentPage(); | |
| if (pages > 1) {{ | |
| for (let pageIndex = 0; pageIndex < pages; pageIndex++) {{ | |
| await new Promise(resolve => {{ | |
| let done = false; | |
| const finish = () => {{ | |
| if (done) return; | |
| done = true; | |
| setTimeout(resolve, 150); | |
| }}; | |
| try {{ | |
| dt.one('draw', finish); | |
| dt.page(pageIndex).draw('page'); | |
| }} catch (e) {{ | |
| finish(); | |
| }} | |
| setTimeout(finish, 4000); | |
| }}); | |
| addCurrentPage(); | |
| }} | |
| }} | |
| if (allRows.length < (recordsTotal || {expected_count_js} || 0)) {{ | |
| try {{ | |
| const nodeRows = Array.from(dt.rows({{ search: 'applied' }}).nodes()).map(row => row.outerHTML); | |
| addRows(allRows, seen, nodeRows); | |
| }} catch (e) {{}} | |
| }} | |
| if (allRows.length < (recordsTotal || {expected_count_js} || 0)) {{ | |
| try {{ | |
| const dataRows = dt.rows({{ search: 'applied' }}).data().toArray().map(row => {{ | |
| if (Array.isArray(row)) {{ | |
| return '<tr>' + row.map(cell => '<td>' + (cell == null ? '' : String(cell)) + '</td>').join('') + '</tr>'; | |
| }} | |
| return ''; | |
| }}).filter(Boolean); | |
| addRows(allRows, seen, dataRows); | |
| }} catch (e) {{}} | |
| }} | |
| }} else {{ | |
| addRows(allRows, seen, getVisibleRows()); | |
| }} | |
| return {{ | |
| ok: true, | |
| hasDataTables, | |
| info, | |
| rowCount: allRows.length, | |
| url: location.href, | |
| html: '<html><body><table id="datatable-default"><tbody>' + allRows.join('\n') + '</tbody></table></body></html>' | |
| }}; | |
| }})() | |
| """) | |
| if not isinstance(result, dict) or not result.get('ok'): | |
| error = result.get('error') if isinstance(result, dict) else result | |
| print(f" [WARN] Nu am putut extrage tabelul complet prin browser: {error}", flush=True) | |
| return None | |
| row_count = result.get('rowCount') or 0 | |
| info = result.get('info') or {} | |
| records_total = max(info.get('recordsDisplay') or 0, info.get('recordsTotal') or 0) | |
| if expected_count: | |
| print(f" [INFO] Randuri DataTables extrase: {row_count} / asteptate: {expected_count}", flush=True) | |
| elif records_total: | |
| print(f" [INFO] Randuri DataTables extrase: {row_count} / raportate: {records_total}", flush=True) | |
| else: | |
| print(f" [INFO] Randuri DataTables extrase: {row_count}", flush=True) | |
| html = result.get('html') or '' | |
| if not html: | |
| return None | |
| return BeautifulSoup(html, 'html.parser') | |
| def get_soup(url, session): | |
| """Fetch page and return BeautifulSoup object""" | |
| try: | |
| print(f" [GET] {url}", flush=True) | |
| response = session.get(url, headers=HEADERS, timeout=REQUEST_TIMEOUT) | |
| print( | |
| f" [HTTP] {response.status_code} {response.headers.get('Content-Type', '')}", | |
| flush=True | |
| ) | |
| if is_cloudflare_response(response): | |
| print(" [INFO] Raspuns Cloudflare detectat.", flush=True) | |
| html = browser_fetch_html(url, session) | |
| if html: | |
| return BeautifulSoup(html, 'html.parser') | |
| response.raise_for_status() | |
| response.raise_for_status() | |
| return BeautifulSoup(response.content, 'html.parser') | |
| except Exception as e: | |
| print(f" [ERROR] Failed to fetch {url}: {e}") | |
| return None | |
| def sanitize_filename(name): | |
| """Clean filename for filesystem""" | |
| name = re.sub(r'[<>:"/\\|?*]', '_', name) | |
| name = re.sub(r'\s+', '_', name) | |
| name = name.strip('._') | |
| return name[:200] | |
| def strip_diacritics(text): | |
| """Convert Romanian/European diacritics to plain ASCII.""" | |
| return unicodedata.normalize('NFKD', text or '').encode('ascii', 'ignore').decode('ascii') | |
| def sanitize_folder_name(name): | |
| """Clean publication title for a readable Windows folder name.""" | |
| name = strip_diacritics(name) | |
| name = re.sub(r'[<>:"/\\|?*]', ' ', name) | |
| name = re.sub(r'[_-]+', ' ', name) | |
| name = re.sub(r'\s+', ' ', name) | |
| name = name.strip(' ._') | |
| if not name: | |
| return "Unknown Publication" | |
| return name.title()[:200] | |
| def slugify_text(text): | |
| """Create a lowercase ASCII key for matching PDF filename prefixes.""" | |
| text = strip_diacritics(text).lower() | |
| text = re.sub(r'[^a-z0-9]+', '-', text) | |
| return text.strip('-') | |
| def get_publication_slug(pub_url): | |
| """Extract URL slug from ?pub=10323-some-title.""" | |
| query_params = parse_qs(urlparse(pub_url).query) | |
| pub_value = query_params.get('pub', [''])[0] | |
| match = re.match(r'\d+-(.+)', pub_value) | |
| if match: | |
| return match.group(1) | |
| return pub_value | |
| def get_significant_tokens(text): | |
| """Return useful tokens for checking that the fetched page matches the requested URL.""" | |
| return [ | |
| token for token in slugify_text(text).split('-') | |
| if len(token) >= 4 and token not in TITLE_MATCH_STOPWORDS | |
| ] | |
| def publication_title_matches_url(pub_title, pub_url): | |
| """Avoid downloading into the wrong folder when the fetched page does not match the URL slug.""" | |
| pub_slug = get_publication_slug(pub_url) | |
| if not pub_slug or not pub_title or pub_title == "Unknown_Publication": | |
| return True | |
| url_key = slugify_text(pub_slug) | |
| title_key = slugify_text(pub_title) | |
| # Some publication URLs append the institution name after the actual title. | |
| # Accept them when the page title is the beginning of the URL slug. | |
| if ( | |
| url_key == title_key | |
| or url_key.startswith(title_key + '-') | |
| or title_key.startswith(url_key + '-') | |
| ): | |
| return True | |
| url_tokens = set(get_significant_tokens(pub_slug)) | |
| title_tokens = set(get_significant_tokens(pub_title)) | |
| if not url_tokens or not title_tokens: | |
| return True | |
| shared_tokens = url_tokens & title_tokens | |
| title_coverage = len(shared_tokens) / len(title_tokens) | |
| return title_coverage >= 0.75 | |
| def get_collection_dir(base_output_dir, pub_title, pub_url=None): | |
| """Return the per-publication download directory.""" | |
| folder_source = pub_title | |
| if not folder_source or folder_source == "Unknown_Publication": | |
| folder_source = get_publication_slug(pub_url or '') or "Unknown Publication" | |
| return os.path.join(base_output_dir, sanitize_folder_name(folder_source)) | |
| def infer_collection_prefix(filename): | |
| """Infer collection prefix from known PDF filenames.""" | |
| stem = os.path.splitext(os.path.basename(filename or ''))[0] | |
| key = slugify_text(stem) | |
| if not key: | |
| return None | |
| match = re.match( | |
| r'^(.+?)(?:-(?:no|nr|numar|numarul|tom|vol|volum)-?\d|-(?:1[5-9]\d{2}|20\d{2})(?:$|-))', | |
| key | |
| ) | |
| if match: | |
| return match.group(1).strip('-') | |
| return key | |
| def build_existing_pdf_prefixes(pub_title, pub_url, pdf_links): | |
| """Build specific prefixes used to move PDFs left in the base folder.""" | |
| prefixes = set() | |
| title_slug = slugify_text(pub_title) | |
| if title_slug: | |
| prefixes.add(title_slug) | |
| for title_part in re.split(r'\s*/\s*', pub_title or ''): | |
| title_part_slug = slugify_text(title_part) | |
| if title_part_slug: | |
| prefixes.add(title_part_slug) | |
| pub_slug = slugify_text(get_publication_slug(pub_url)) | |
| if pub_slug: | |
| prefixes.add(pub_slug) | |
| for pdf_url, suggested_name in pdf_links: | |
| filename = extract_filename_from_url(pdf_url) or suggested_name | |
| prefix = infer_collection_prefix(filename) | |
| if prefix: | |
| prefixes.add(prefix) | |
| return sorted(prefix for prefix in prefixes if len(prefix) >= 8) | |
| def make_unique_path(path): | |
| """Avoid overwriting an existing file while moving old downloads.""" | |
| if not os.path.exists(path): | |
| return path | |
| root, ext = os.path.splitext(path) | |
| counter = 1 | |
| while True: | |
| candidate = f"{root}_{counter}{ext}" | |
| if not os.path.exists(candidate): | |
| return candidate | |
| counter += 1 | |
| def get_existing_collection_dir_for_prefix(base_output_dir, prefix): | |
| """Find an existing collection folder that already matches a PDF prefix.""" | |
| matches = [] | |
| for entry in os.scandir(base_output_dir): | |
| if not entry.is_dir(): | |
| continue | |
| folder_key = slugify_text(entry.name) | |
| if ( | |
| folder_key == prefix | |
| or folder_key.startswith(prefix + '-') | |
| or folder_key.endswith('-' + prefix) | |
| or prefix.startswith(folder_key + '-') | |
| ): | |
| matches.append(entry.path) | |
| if matches: | |
| matches.sort(key=lambda path: (slugify_text(os.path.basename(path)) != prefix, len(os.path.basename(path)))) | |
| return matches[0] | |
| return os.path.join(base_output_dir, sanitize_folder_name(prefix.replace('-', ' '))) | |
| def organize_existing_pdf_groups(base_output_dir): | |
| """Group any PDFs left directly in the base folder into collection folders.""" | |
| if not os.path.isdir(base_output_dir): | |
| return 0 | |
| groups = {} | |
| for entry in os.scandir(base_output_dir): | |
| if not entry.is_file() or not entry.name.lower().endswith('.pdf'): | |
| continue | |
| prefix = infer_collection_prefix(entry.name) | |
| if prefix and len(prefix) >= 8: | |
| groups.setdefault(prefix, []).append(entry.path) | |
| moved_count = 0 | |
| for prefix, file_paths in sorted(groups.items()): | |
| destination_dir = get_existing_collection_dir_for_prefix(base_output_dir, prefix) | |
| os.makedirs(destination_dir, exist_ok=True) | |
| for file_path in file_paths: | |
| filename = os.path.basename(file_path) | |
| destination = make_unique_path(os.path.join(destination_dir, filename)) | |
| shutil.move(file_path, destination) | |
| print(f" [MOVE] Existing orphan PDF moved to {os.path.basename(destination_dir)}: {filename}") | |
| moved_count += 1 | |
| return moved_count | |
| def move_existing_collection_folders_to_collection(base_output_dir, collection_dir, pub_title, pub_url, pdf_links): | |
| """Move PDFs from compatible short-name folders into the current collection folder.""" | |
| if not os.path.isdir(base_output_dir): | |
| return 0 | |
| prefixes = build_existing_pdf_prefixes(pub_title, pub_url, pdf_links) | |
| if not prefixes: | |
| return 0 | |
| collection_dir = os.path.abspath(collection_dir) | |
| moved_count = 0 | |
| for entry in os.scandir(base_output_dir): | |
| if not entry.is_dir(): | |
| continue | |
| source_dir = os.path.abspath(entry.path) | |
| if os.path.normcase(source_dir) == os.path.normcase(collection_dir): | |
| continue | |
| folder_key = slugify_text(entry.name) | |
| if not any( | |
| folder_key == prefix | |
| or folder_key.startswith(prefix + '-') | |
| or folder_key.endswith('-' + prefix) | |
| or prefix.startswith(folder_key + '-') | |
| for prefix in prefixes | |
| ): | |
| continue | |
| os.makedirs(collection_dir, exist_ok=True) | |
| for file_entry in os.scandir(source_dir): | |
| if not file_entry.is_file() or not file_entry.name.lower().endswith('.pdf'): | |
| continue | |
| destination = make_unique_path(os.path.join(collection_dir, file_entry.name)) | |
| shutil.move(file_entry.path, destination) | |
| print(f" [MOVE] Existing PDF moved from {entry.name}: {file_entry.name}") | |
| moved_count += 1 | |
| try: | |
| os.rmdir(source_dir) | |
| except OSError: | |
| pass | |
| return moved_count | |
| def move_existing_pdfs_to_collection(base_output_dir, collection_dir, pub_title, pub_url, pdf_links): | |
| """Move matching PDFs from the base folder into the current collection folder.""" | |
| if not os.path.isdir(base_output_dir): | |
| return 0 | |
| prefixes = build_existing_pdf_prefixes(pub_title, pub_url, pdf_links) | |
| if not prefixes: | |
| return 0 | |
| os.makedirs(collection_dir, exist_ok=True) | |
| moved_count = 0 | |
| for entry in os.scandir(base_output_dir): | |
| if not entry.is_file() or not entry.name.lower().endswith('.pdf'): | |
| continue | |
| file_key = slugify_text(os.path.splitext(entry.name)[0]) | |
| if not any(file_key == prefix or file_key.startswith(prefix + '-') for prefix in prefixes): | |
| continue | |
| destination = make_unique_path(os.path.join(collection_dir, entry.name)) | |
| shutil.move(entry.path, destination) | |
| print(f" [MOVE] Existing PDF moved: {entry.name}") | |
| moved_count += 1 | |
| return moved_count | |
| def extract_filename_from_url(url): | |
| """Extract filename from dl.asp?filename=... URL or from path""" | |
| parsed = urlparse(url) | |
| # Try to get from query parameter 'filename' | |
| query_params = parse_qs(parsed.query) | |
| if 'filename' in query_params: | |
| filename = query_params['filename'][0] | |
| return unquote(filename) | |
| # Try to get from onclick attribute (track_pdf URL) | |
| # This is handled separately | |
| # Fallback: get from path | |
| path = parsed.path | |
| if path: | |
| filename = os.path.basename(path) | |
| if filename and filename.lower() != 'dl.asp': | |
| return unquote(filename) | |
| return None | |
| def extract_filename_from_response(response): | |
| """Extract filename from Content-Disposition header""" | |
| cd = response.headers.get('Content-Disposition', '') | |
| if cd: | |
| # Try standard filename | |
| match = re.search(r'filename[^;=\n]*=([\'"]?)([^\'";\n]+)\1', cd, re.IGNORECASE) | |
| if match: | |
| filename = unquote(match.group(2)) | |
| if filename and filename.lower() != 'dl.asp' and not filename.lower().startswith('dl.asp'): | |
| return filename | |
| # Try filename* (RFC 5987) | |
| match = re.search(r"filename\*=(?:UTF-8''|utf-8'')([^;\n]+)", cd, re.IGNORECASE) | |
| if match: | |
| filename = unquote(match.group(1)) | |
| if filename and filename.lower() != 'dl.asp': | |
| return filename | |
| return None | |
| def get_publication_title(soup): | |
| """Extract publication title from page""" | |
| h2 = soup.find('h2', class_='text-color-light') | |
| if h2: | |
| return h2.get_text(strip=True) | |
| return "Unknown_Publication" | |
| def get_expected_publication_item_count(soup): | |
| """Extract the publication issue count displayed by the site.""" | |
| if not soup: | |
| return None | |
| text = strip_diacritics(soup.get_text(' ', strip=True)).lower() | |
| patterns = [ | |
| r'numere\s+de\s+revista\s*:\s*(\d+)', | |
| r'numere\s+de\s+periodic\s*:\s*(\d+)', | |
| r'numere\s*:\s*(\d+)', | |
| ] | |
| for pattern in patterns: | |
| match = re.search(pattern, text) | |
| if match: | |
| return int(match.group(1)) | |
| return None | |
| def dedupe_link_tuples(items): | |
| """Deduplicate (url, name) tuples while preserving order.""" | |
| seen = set() | |
| result = [] | |
| for url, name in items: | |
| key = (url or '').strip() | |
| if not key or key in seen: | |
| continue | |
| seen.add(key) | |
| result.append((url, name)) | |
| return result | |
| def make_duplicate_pdf_filenames_unique(pdf_links): | |
| """Keep duplicate PDF URLs as separate rows by assigning unique filenames.""" | |
| counts = {} | |
| result = [] | |
| for url, suggested_name in pdf_links: | |
| filename = suggested_name or extract_filename_from_url(url) | |
| if filename: | |
| if not filename.lower().endswith('.pdf'): | |
| filename += '.pdf' | |
| filename = sanitize_filename(filename) | |
| key = filename.lower() | |
| counts[key] = counts.get(key, 0) + 1 | |
| if counts[key] > 1: | |
| root, ext = os.path.splitext(filename) | |
| filename = f"{root}_{counts[key]}{ext or '.pdf'}" | |
| result.append((url, filename)) | |
| return result | |
| def complete_datatable_links_if_needed(soup, pub_url, session, pdf_links, volume_links): | |
| """Load all DataTables pages when the first HTML page contains only 25/50 rows.""" | |
| expected_count = get_expected_publication_item_count(soup) | |
| if pdf_links: | |
| current_count = len(pdf_links) | |
| elif volume_links: | |
| current_count = len(volume_links) | |
| else: | |
| current_count = 0 | |
| if not expected_count: | |
| return make_duplicate_pdf_filenames_unique(pdf_links), volume_links, expected_count | |
| print(f" Site reports publication items: {expected_count}", flush=True) | |
| if current_count >= expected_count: | |
| return make_duplicate_pdf_filenames_unique(pdf_links), volume_links, expected_count | |
| print( | |
| f" [INFO] Initial HTML has only {current_count}/{expected_count} rows; " | |
| "checking all DataTables pages...", | |
| flush=True | |
| ) | |
| full_table_soup = get_full_datatable_soup_with_browser(pub_url, session, expected_count) | |
| if not full_table_soup: | |
| print(" [WARN] Nu am putut completa lista din DataTables; folosesc lista initiala.", flush=True) | |
| return pdf_links, volume_links, expected_count | |
| full_pdf_links = get_pdf_links_from_page(full_table_soup, pub_url) | |
| full_volume_links = dedupe_link_tuples(get_volume_links(full_table_soup, pub_url)) | |
| if len(full_pdf_links) > len(pdf_links): | |
| pdf_links = full_pdf_links | |
| else: | |
| pdf_links = dedupe_link_tuples(pdf_links) | |
| if len(full_volume_links) > len(volume_links): | |
| volume_links = full_volume_links | |
| else: | |
| volume_links = dedupe_link_tuples(volume_links) | |
| completed_count = max(len(pdf_links), len(volume_links)) | |
| if completed_count < expected_count: | |
| print( | |
| f" [WARN] Dupa verificarea tuturor paginilor am gasit doar " | |
| f"{completed_count}/{expected_count} elemente.", | |
| flush=True | |
| ) | |
| else: | |
| print(f" [OK] Lista completa: {completed_count}/{expected_count} elemente.", flush=True) | |
| return make_duplicate_pdf_filenames_unique(pdf_links), volume_links, expected_count | |
| def get_pdf_links_from_page(soup, base_url): | |
| """Extract all PDF download links from page""" | |
| pdf_links = [] | |
| # Find download links in the table | |
| table = soup.find('table', {'id': 'datatable-default'}) | |
| if not table: | |
| print("[WARNING] Could not find data table, searching entire page...") | |
| search_area = soup | |
| else: | |
| search_area = table | |
| for link in search_area.find_all('a', href=True): | |
| href = link['href'] | |
| # Check for PDF links (dl.asp?filename=... or direct .pdf) | |
| if 'dl.asp' in href.lower() or '.pdf' in href.lower(): | |
| # PRIORITY: Check onclick for track_pdf which has the DIRECT PDF URL | |
| onclick = link.get('onclick', '') | |
| if 'track_pdf' in onclick: | |
| # Extract URL from track_pdf('...') | |
| match = re.search(r"track_pdf\(['\"]([^'\"]+)['\"]", onclick) | |
| if match: | |
| direct_pdf_url = match.group(1) | |
| # This is the real PDF URL, not the dl.asp wrapper | |
| filename = os.path.basename(urlparse(direct_pdf_url).path) | |
| pdf_links.append((direct_pdf_url, filename)) | |
| continue | |
| # Fallback: use href (dl.asp URL) | |
| full_url = urljoin(base_url, href) | |
| filename = extract_filename_from_url(full_url) | |
| pdf_links.append((full_url, filename)) | |
| return pdf_links | |
| def get_volume_links(soup, base_url): | |
| """Extract volume links if PDFs are not directly on publication page""" | |
| volume_links = [] | |
| table = soup.find('table', {'id': 'datatable-default'}) | |
| if not table: | |
| return volume_links | |
| for link in table.find_all('a', href=True): | |
| href = link['href'] | |
| if 'volum=' in href: | |
| full_url = urljoin(base_url, href) | |
| volume_name = link.get_text(strip=True) | |
| volume_links.append((full_url, volume_name)) | |
| return volume_links | |
| def download_pdf(url, output_dir, suggested_filename, session): | |
| """Download PDF file""" | |
| try: | |
| response = session.get(url, headers=HEADERS, timeout=60, stream=True) | |
| if is_cloudflare_response(response): | |
| try: | |
| response.close() | |
| except Exception: | |
| pass | |
| browser_fetch_html(url, session) | |
| response = session.get(url, headers=HEADERS, timeout=60, stream=True) | |
| response.raise_for_status() | |
| # Determine filename: prepared suggestion > URL param > Content-Disposition | |
| filename = suggested_filename | |
| if not filename or filename.lower() == 'dl.asp': | |
| filename = extract_filename_from_url(url) | |
| if not filename or filename.lower() == 'dl.asp': | |
| filename = extract_filename_from_response(response) | |
| if not filename: | |
| filename = suggested_filename | |
| if not filename: | |
| filename = f"download_{int(time.time())}.pdf" | |
| if not filename.lower().endswith('.pdf'): | |
| filename += '.pdf' | |
| filename = sanitize_filename(filename) | |
| output_path = os.path.join(output_dir, filename) | |
| # Check if already exists | |
| if os.path.exists(output_path): | |
| print(f" [SKIP] Already exists: {filename}") | |
| return True, filename | |
| with open(output_path, 'wb') as f: | |
| for chunk in response.iter_content(chunk_size=8192): | |
| f.write(chunk) | |
| return True, filename | |
| except Exception as e: | |
| print(f" [ERROR] Download failed: {e}") | |
| return False, None | |
| def main(): | |
| if len(sys.argv) >= 2: | |
| pub_url = sys.argv[1] | |
| else: | |
| print("=" * 70) | |
| print("Biblioteca Digitală - PDF Downloader") | |
| print("=" * 70) | |
| print("\nExemple de URL-uri:") | |
| print(" https://biblioteca-digitala.ro/?pub=7758-revista-romana-de-sociologie") | |
| print(" https://biblioteca-digitala.ro/?pub=6464-studii-si-cercetari-de-chimie") | |
| print() | |
| pub_url = input("Introdu URL-ul publicației: ").strip() | |
| if not pub_url: | |
| print("[ERROR] Nu ai introdus niciun URL!") | |
| sys.exit(1) | |
| print("=" * 70) | |
| print("Biblioteca Digitală - PDF Downloader") | |
| print("=" * 70) | |
| print(f"Publication URL: {pub_url}") | |
| session = requests.Session() | |
| # Step 1: Get publication page | |
| print(f"\n[1] Fetching publication page...") | |
| soup = get_soup(pub_url, session) | |
| if not soup: | |
| print("[FATAL] Could not load publication page") | |
| return | |
| pub_title = get_publication_title(soup) | |
| print(f" Publication: {pub_title}") | |
| if pub_title == "Unknown_Publication" and not soup.find('table', {'id': 'datatable-default'}): | |
| print("[FATAL] Pagina reala nu a fost incarcata corect; nu creez folder si nu mut fisiere.") | |
| print(" Probabil Cloudflare/522 a returnat o pagina temporara.") | |
| return | |
| if not publication_title_matches_url(pub_title, pub_url): | |
| expected_slug = get_publication_slug(pub_url) | |
| print("[FATAL] Pagina primita nu pare sa corespunda URL-ului introdus.") | |
| print(f" URL slug: {expected_slug}") | |
| print(f" Titlu pagina: {pub_title}") | |
| print(" Oprire ca sa nu descarce in folderul gresit.") | |
| return | |
| # Create per-publication output directory | |
| base_output_dir = BASE_OUTPUT_DIR | |
| output_dir = get_collection_dir(base_output_dir, pub_title, pub_url) | |
| os.makedirs(output_dir, exist_ok=True) | |
| print(f" Base output directory: {base_output_dir}") | |
| print(f" Collection directory: {output_dir}") | |
| # Step 2: Check if PDFs are directly on page or need to go to volumes | |
| pdf_links = get_pdf_links_from_page(soup, pub_url) | |
| volume_links = dedupe_link_tuples(get_volume_links(soup, pub_url)) | |
| pdf_links, volume_links, expected_count = complete_datatable_links_if_needed( | |
| soup, pub_url, session, pdf_links, volume_links | |
| ) | |
| total_pdfs = 0 | |
| if pdf_links: | |
| # PDFs are directly on the publication page | |
| print(f"\n[2] Found {len(pdf_links)} PDF links directly on page") | |
| moved_count = move_existing_pdfs_to_collection( | |
| base_output_dir, output_dir, pub_title, pub_url, pdf_links | |
| ) | |
| print(f" Existing PDFs moved from base folder: {moved_count}") | |
| folder_moved_count = move_existing_collection_folders_to_collection( | |
| base_output_dir, output_dir, pub_title, pub_url, pdf_links | |
| ) | |
| print(f" Existing PDFs moved from matching folders: {folder_moved_count}") | |
| orphan_moved_count = organize_existing_pdf_groups(base_output_dir) | |
| print(f" Other orphan PDFs grouped from base folder: {orphan_moved_count}") | |
| for i, (pdf_url, suggested_name) in enumerate(pdf_links, 1): | |
| print(f"\n [{i}/{len(pdf_links)}] Downloading...") | |
| success, filename = download_pdf(pdf_url, output_dir, suggested_name, session) | |
| if success and filename: | |
| print(f" Saved as: {filename}") | |
| total_pdfs += 1 | |
| time.sleep(0.5) | |
| else: | |
| # Need to check volume pages for PDFs | |
| print(f"\n[2] No direct PDF links found, checking volume pages...") | |
| print(f" Found {len(volume_links)} volumes") | |
| pdf_links = [] | |
| for i, (vol_url, vol_name) in enumerate(volume_links, 1): | |
| print(f"\n[{i}/{len(volume_links)}] Scanning: {vol_name}") | |
| vol_soup = get_soup(vol_url, session) | |
| if not vol_soup: | |
| continue | |
| vol_pdf_links = get_pdf_links_from_page(vol_soup, vol_url) | |
| for pdf_url, suggested_name in vol_pdf_links: | |
| if not suggested_name: | |
| suggested_name = f"{sanitize_filename(vol_name)}.pdf" | |
| pdf_links.append((pdf_url, suggested_name)) | |
| time.sleep(0.5) | |
| print(f"\n[3] Found {len(pdf_links)} PDF links in volume pages") | |
| moved_count = move_existing_pdfs_to_collection( | |
| base_output_dir, output_dir, pub_title, pub_url, pdf_links | |
| ) | |
| print(f" Existing PDFs moved from base folder: {moved_count}") | |
| folder_moved_count = move_existing_collection_folders_to_collection( | |
| base_output_dir, output_dir, pub_title, pub_url, pdf_links | |
| ) | |
| print(f" Existing PDFs moved from matching folders: {folder_moved_count}") | |
| orphan_moved_count = organize_existing_pdf_groups(base_output_dir) | |
| print(f" Other orphan PDFs grouped from base folder: {orphan_moved_count}") | |
| for i, (pdf_url, suggested_name) in enumerate(pdf_links, 1): | |
| print(f"\n [{i}/{len(pdf_links)}] Downloading: {suggested_name[:50]}...") | |
| success, filename = download_pdf(pdf_url, output_dir, suggested_name, session) | |
| if success and filename: | |
| print(f" Saved as: {filename}") | |
| total_pdfs += 1 | |
| time.sleep(0.5) | |
| # Summary | |
| if total_pdfs == 0: | |
| print("\n[INFO] Nu am gasit linkuri PDF descarcabile pentru aceasta publicatie.") | |
| print(" Daca site-ul afiseaza stelute negre in coloana de download,") | |
| print(" publicația are doar metadate/articole, fara fisiere PDF disponibile.") | |
| try: | |
| if os.path.isdir(output_dir) and not os.listdir(output_dir): | |
| os.rmdir(output_dir) | |
| print(f"\n[INFO] No PDFs found; empty collection directory removed: {output_dir}") | |
| except OSError: | |
| pass | |
| print("\n" + "=" * 70) | |
| print("DOWNLOAD COMPLETE") | |
| print("=" * 70) | |
| print(f"Total PDFs downloaded: {total_pdfs}") | |
| if expected_count and total_pdfs < expected_count: | |
| print(f"[WARNING] Site reports {expected_count} items, but only {total_pdfs} PDFs were downloaded/found.") | |
| print(f"Output directory: {os.path.abspath(output_dir)}") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment