Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save me-suzy/14237dc7483fe7de06455102d74b2872 to your computer and use it in GitHub Desktop.

Select an option

Save me-suzy/14237dc7483fe7de06455102d74b2872 to your computer and use it in GitHub Desktop.
Biblioteca Digitala - GENERIC BUN FINAL.txt
#!/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