Skip to content

Commit d67fa8d

Browse files
BubaVVBubaVV
BubaVV
authored and
BubaVV
committed
Update for PHPMyAdmin 5.2
Grab looks abandoned and not working anymore. Migrated it to lxml and tested a bit on recent phpmyadmin installation
1 parent 0ca3d37 commit d67fa8d

File tree

3 files changed

+96
-49
lines changed

3 files changed

+96
-49
lines changed

Diff for: README.md

+4-5
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ A Python 3 script to __automate the download of SQL backups via a
99
This is useful when your web hosting provider does not grant you access to a console (for `mysqldump`) but
1010
you want to automate the backup of your database (without having to manually use the browser).
1111

12-
It has been tested with Python 3.4+ on Linux and Windows and the following versions of phpMyAdmin:
13-
`4.3.x - 4.8.x, 5.0.0`
12+
It has been tested with Python 3.8 on Linux and the following versions of phpMyAdmin:
13+
`5.2`
1414

1515
_Note_: The web interface of phpMyAdmin may change in the future and break this script. Please file a bug report
1616
(including your version of phpMyAdmin) if you encounter this issue.
@@ -83,9 +83,8 @@ UTC date / time to the directory `/tmp`, e.g. `/tmp/2016-03-11--15-19-04-UTC_exa
8383

8484
## Requirements
8585

86-
- A [Python 3.4+](https://www.python.org/) installation on your system
87-
- [Grab - python web-scraping framework](http://grablib.org/): Install via `pip install -U Grab` or see
88-
the [installation instructions](http://docs.grablib.org/en/latest/usage/installation.html) if you run into problems.
86+
- A [Python 3.8+](https://www.python.org/) installation on your system
87+
- Requirements - `pip install -r requirements.txt`
8988

9089
__Note for Windows users__: while it is possible to install the requirements natively, it is often easier to use the
9190
[Windows Subsystem for Linux](https://docs.microsoft.com/en-us/windows/wsl/install-win10) if you are using Windows 10

Diff for: phpmyadmin_sql_backup.py

+86-44
Original file line numberDiff line numberDiff line change
@@ -28,64 +28,103 @@
2828
import os
2929
import re
3030
import sys
31+
from itertools import product
32+
from urllib.parse import urljoin
3133

32-
import grab
34+
import requests
35+
from lxml import html
3336

34-
__version__ = '2019-05-07.1'
37+
__version__ = '2024-12-01'
3538

3639
CONTENT_DISPOSITION_FILENAME_RE = re.compile(r'^.*filename="(?P<filename>[^"]+)".*$')
3740
DEFAULT_PREFIX_FORMAT = r'%Y-%m-%d--%H-%M-%S-UTC_'
3841

3942

40-
def is_login_successful(g):
41-
return g.doc.text_search("frame_content") or g.doc.text_search("server_export.php")
43+
def is_login_successful(tree):
44+
hrefs = tree.xpath("//a/@href")
45+
target_substrings = ["frame_content", "server_export.php", "index.php?route=/server/export"]
46+
combinations = product(target_substrings, hrefs)
4247

43-
44-
def open_frame_if_phpmyadmin_3(g):
45-
frame_url_selector = g.doc.select("id('frame_content')/@src")
46-
if frame_url_selector.exists():
47-
g.go(frame_url_selector.text())
48+
return any(substring in href for substring, href in combinations)
4849

4950

5051
def download_sql_backup(url, user, password, dry_run=False, overwrite_existing=False, prepend_date=True, basename=None,
5152
output_directory=os.getcwd(), exclude_dbs=None, compression='none', prefix_format=None,
5253
timeout=60, http_auth=None, server_name=None, **kwargs):
5354
prefix_format = prefix_format or DEFAULT_PREFIX_FORMAT
54-
exclude_dbs = exclude_dbs.split(',') or []
55-
encoding = '' if compression == 'gzip' else 'gzip'
56-
57-
g = grab.Grab(encoding=encoding, timeout=timeout)
58-
if http_auth:
59-
g.setup(userpwd=http_auth)
60-
else:
61-
g.doc.set_input_by_id('input_username', user)
62-
g.doc.set_input_by_id('input_password', password)
63-
g.submit()
64-
g.go(url)
65-
if server_name:
66-
g.doc.set_input_by_id('input_servername', server_name)
67-
68-
if not is_login_successful(g):
69-
raise ValueError('Could not login - did you provide the correct username / password?')
70-
71-
open_frame_if_phpmyadmin_3(g)
72-
73-
export_url = g.doc.select("id('topmenu')//a[contains(@href,'server_export.php')]/@href").text()
74-
g.go(export_url)
75-
76-
dbs_available = [option.attrib['value'] for option in g.doc.form.inputs['db_select[]']]
55+
exclude_dbs = exclude_dbs.split(',') if exclude_dbs else []
56+
session = requests.Session()
57+
58+
# Login
59+
response = session.get(url, timeout=timeout)
60+
if response.status_code != 200:
61+
raise ValueError("Failed to load the login page.")
62+
63+
tree = html.fromstring(response.content)
64+
form_action = tree.xpath("//form[@id='login_form']/@action")
65+
form_action = form_action[0] if form_action else url
66+
67+
form_data = {
68+
"pma_username": user,
69+
"pma_password": password,
70+
}
71+
72+
hidden_inputs = tree.xpath("//form[@id='login_form']//input[@type='hidden']")
73+
for hidden_input in hidden_inputs:
74+
name = hidden_input.get("name")
75+
value = hidden_input.get("value", "")
76+
if name:
77+
form_data[name] = value
78+
79+
login_response = session.post(urljoin(url,form_action), data=form_data, timeout=timeout)
80+
81+
if login_response.status_code != 200:
82+
raise ValueError("Could not log in. Please check your credentials.")
83+
84+
tree = html.fromstring(login_response.content)
85+
if not is_login_successful(tree):
86+
raise ValueError("Could not log in. Please check your credentials.")
87+
88+
# Extract export URL
89+
export_url = tree.xpath("id('topmenu')//a[contains(@href,'server_export.php')]/@href")
90+
if not export_url:
91+
export_url = tree.xpath("id('topmenu')//a[contains(@href,'index.php?route=/server/export')]/@href")
92+
if not export_url:
93+
raise ValueError("Could not find export URL.")
94+
export_url = export_url[0]
95+
96+
# Access export page
97+
export_response = session.get(urljoin(url,export_url), timeout=timeout)
98+
export_tree = html.fromstring(export_response.content)
99+
100+
101+
# Determine databases to dump
102+
dbs_available = export_tree.xpath("//select[@name='db_select[]']/option/@value")
77103
dbs_to_dump = [db_name for db_name in dbs_available if db_name not in exclude_dbs]
78104
if not dbs_to_dump:
79-
print('Warning: no databases to dump (databases available: "{}")'.format('", "'.join(dbs_available)),
80-
file=sys.stderr)
81-
82-
file_response = g.submit(
83-
extra_post=[('db_select[]', db_name) for db_name in dbs_to_dump] + [('compression', compression)])
84-
85-
re_match = CONTENT_DISPOSITION_FILENAME_RE.match(g.doc.headers['Content-Disposition'])
105+
print(f'Warning: no databases to dump (databases available: "{", ".join(dbs_available)}")',
106+
file=sys.stderr)
107+
108+
# Prepare form data
109+
dump_form_action = export_tree.xpath("//form[@name='dump']/@action")[0]
110+
form_data = {'db_select[]': dbs_to_dump}
111+
form_data['compression'] = compression
112+
form_data['what'] = 'sql'
113+
form_data['filename_template'] = '@SERVER@'
114+
form_data['sql_structure_or_data'] = 'structure_and_data'
115+
dump_hidden_inputs = export_tree.xpath("//form[@name='dump']//input[@type='hidden']")
116+
for hidden_input in dump_hidden_inputs:
117+
name = hidden_input.get("name")
118+
value = hidden_input.get("value", "")
119+
if name:
120+
form_data[name] = value
121+
122+
# Submit form and download file
123+
file_response = session.post(urljoin(url, dump_form_action), data=form_data, timeout=timeout, stream=True)
124+
content_disposition = file_response.headers.get('Content-Disposition', '')
125+
re_match = CONTENT_DISPOSITION_FILENAME_RE.match(content_disposition)
86126
if not re_match:
87-
raise ValueError(
88-
'Could not determine SQL backup filename from {}'.format(g.doc.headers['Content-Disposition']))
127+
raise ValueError(f"Could not determine SQL backup filename from {content_disposition}")
89128

90129
content_filename = re_match.group('filename')
91130
filename = content_filename if basename is None else basename + os.path.splitext(content_filename)[1]
@@ -97,16 +136,19 @@ def download_sql_backup(url, user, password, dry_run=False, overwrite_existing=F
97136
if os.path.isfile(out_filename) and not overwrite_existing:
98137
basename, ext = os.path.splitext(out_filename)
99138
n = 1
100-
print('File {} already exists, to overwrite it use --overwrite-existing'.format(out_filename), file=sys.stderr)
139+
print(f'File {out_filename} already exists, to overwrite it use --overwrite-existing', file=sys.stderr)
101140
while True:
102-
alternate_out_filename = '{}_({}){}'.format(basename, n, ext)
141+
alternate_out_filename = f'{basename}_({n}){ext}'
103142
if not os.path.isfile(alternate_out_filename):
104143
out_filename = alternate_out_filename
105144
break
106145
n += 1
107146

147+
# Save file if not dry run
108148
if not dry_run:
109-
file_response.save(out_filename)
149+
with open(out_filename, 'wb') as f:
150+
for chunk in file_response.iter_content(chunk_size=8192):
151+
f.write(chunk)
110152

111153
return out_filename
112154

Diff for: requirements.txt

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
certifi==2024.8.30
2+
charset-normalizer==3.4.0
3+
idna==3.10
4+
lxml==5.3.0
5+
requests==2.32.3
6+
urllib3==2.2.3

0 commit comments

Comments
 (0)