Completed
Push — master ( ce03d6...32fc37 )
by John
03:28
created

getcode()   A

Complexity

Conditions 2

Size

Total Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 2

Importance

Changes 0
Metric Value
cc 2
c 0
b 0
f 0
dl 0
loc 18
rs 9.4285
ccs 9
cts 9
cp 1
crap 2
1
#!/usr/bin/env python3
2 5
"""This module is used for network connections; APIs, downloading, etc."""
3
4 5
import os  # filesystem read
5 5
try:
6 5
    from defusedxml import ElementTree  # safer XML parsing
7
except (ImportError, AttributeError):
8
    from xml.etree import ElementTree  # XML parsing
9 5
import re  # regexes
10 5
import concurrent.futures  # multiprocessing/threading
11 5
import glob  # pem file lookup
12 5
import hashlib  # salt
13 5
import time  # salt
14 5
import random  # salt
15 5
import requests  # downloading
16 5
from bs4 import BeautifulSoup  # scraping
17 5
from bbarchivist import utilities  # parse filesize
18 5
from bbarchivist.bbconstants import SERVERS  # lookup servers
19
20 5
__author__ = "Thurask"
21 5
__license__ = "WTFPL v2"
22 5
__copyright__ = "2015-2017 Thurask"
23
24
25 5
def grab_pem():
26
    """
27
    Work with either local cacerts or system cacerts.
28
    """
29 5
    try:
30 5
        pemfile = glob.glob(os.path.join(os.getcwd(), "cacert.pem"))[0]
31 5
    except IndexError:
32 5
        return requests.certs.where()  # no local cacerts
33
    else:
34 5
        return os.path.abspath(pemfile)  # local cacerts
35
36
37 5
def pem_wrapper(method):
38
    """
39
    Decorator to set REQUESTS_CA_BUNDLE.
40
41
    :param method: Method to use.
42
    :type method: function
43
    """
44 5
    def wrapper(*args, **kwargs):
45
        """
46
        Set REQUESTS_CA_BUNDLE before doing function.
47
        """
48 5
        os.environ["REQUESTS_CA_BUNDLE"] = grab_pem()
49 5
        return method(*args, **kwargs)
50 5
    return wrapper
51
52
53 5
def generic_session(session=None):
54
    """
55
    Create a Requests session object on the fly, if need be.
56
57
    :param session: Requests session object, created if this is None.
58
    :type session: requests.Session()
59
    """
60 5
    sess = requests.Session() if session is None else session
61 5
    return sess
62
63
64 5
def generic_soup_parser(url, session=None):
65
    """
66
    Get a BeautifulSoup HTML parser for some URL.
67
68
    :param url: The URL to check.
69
    :type url: str
70
71
    :param session: Requests session object, default is created on the fly.
72
    :type session: requests.Session()
73
    """
74 5
    session = generic_session(session)
75 5
    req = session.get(url)
76 5
    soup = BeautifulSoup(req.content, "html.parser")
77 5
    return soup
78
79
80 5
@pem_wrapper
81 5
def get_length(url, session=None):
82
    """
83
    Get content-length header from some URL.
84
85
    :param url: The URL to check.
86
    :type url: str
87
88
    :param session: Requests session object, default is created on the fly.
89
    :type session: requests.Session()
90
    """
91 5
    session = generic_session(session)
92 5
    if url is None:
93 5
        return 0
94 5
    try:
95 5
        heads = session.head(url)
96 5
        fsize = heads.headers['content-length']
97 5
        return int(fsize)
98 5
    except requests.ConnectionError:
99 5
        return 0
100
101
102 5
@pem_wrapper
103 5
def download(url, output_directory=None, session=None):
104
    """
105
    Download file from given URL.
106
107
    :param url: URL to download from.
108
    :type url: str
109
110
    :param output_directory: Download folder. Default is local.
111
    :type output_directory: str
112
113
    :param session: Requests session object, default is created on the fly.
114
    :type session: requests.Session()
115
    """
116 5
    session = generic_session(session)
117 5
    output_directory = utilities.dirhandler(output_directory, os.getcwd())
118 5
    lfname = url.split('/')[-1]
119 5
    sname = utilities.stripper(lfname)
120 5
    fname = os.path.join(output_directory, lfname)
121 5
    download_writer(url, fname, lfname, sname, session)
122 5
    remove_empty_download(fname)
123
124
125 5
def remove_empty_download(fname):
126
    """
127
    Remove file if it's empty.
128
129
    :param fname: File path.
130
    :type fname: str
131
    """
132 5
    if os.stat(fname).st_size == 0:
133 5
        os.remove(fname)
134
135
136 5
def download_writer(url, fname, lfname, sname, session=None):
137
    """
138
    Download file and write to disk.
139
140
    :param url: URL to download from.
141
    :type url: str
142
143
    :param fname: File path.
144
    :type fname: str
145
146
    :param lfname: Long filename.
147
    :type lfname: str
148
149
    :param sname: Short name, for printing to screen.
150
    :type sname: str
151
152
    :param session: Requests session object, default is created on the fly.
153
    :type session: requests.Session()
154
    """
155 5
    with open(fname, "wb") as file:
156 5
        req = session.get(url, stream=True)
157 5
        clength = req.headers['content-length']
158 5
        fsize = utilities.fsizer(clength)
159 5
        if req.status_code == 200:  # 200 OK
160 5
            print("DOWNLOADING {0} [{1}]".format(sname, fsize))
161 5
            for chunk in req.iter_content(chunk_size=1024):
162 5
                file.write(chunk)
163
        else:
164 5
            print("ERROR: HTTP {0} IN {1}".format(req.status_code, lfname))
165
166
167 5
def download_bootstrap(urls, outdir=None, workers=5, session=None):
168
    """
169
    Run downloaders for each file in given URL iterable.
170
171
    :param urls: URLs to download.
172
    :type urls: list
173
174
    :param outdir: Download folder. Default is handled in :func:`download`.
175
    :type outdir: str
176
177
    :param workers: Number of worker processes. Default is 5.
178
    :type workers: int
179
180
    :param session: Requests session object, default is created on the fly.
181
    :type session: requests.Session()
182
    """
183 5
    workers = len(urls) if len(urls) < workers else workers
184 5
    spinman = utilities.SpinManager()
185 5
    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as xec:
186 5
        try:
187 5
            spinman.start()
188 5
            for url in urls:
189 5
                xec.submit(download, url, outdir, session)
190
        except (KeyboardInterrupt, SystemExit):
191
            xec.shutdown()
192
            spinman.stop()
193 5
    spinman.stop()
194 5
    utilities.spinner_clear()
195 5
    utilities.line_begin()
196
197
198 5
def download_android_tools():
199
    """
200
    Download Android SDK platform tools.
201
    """
202 5
    if os.path.exists("plattools"):
203 5
        os.removedirs("plattools")
204 5
    os.mkdir("plattools")
205 5
    platforms = ("windows", "linux", "darwin")
206 5
    dlurls = ["https://dl.google.com/android/repository/platform-tools-latest-{0}.zip".format(plat) for plat in platforms]
207 5
    sess = generic_session()
208 5
    download_bootstrap(dlurls, outdir="plattools", session=sess)
209
210
211 5
@pem_wrapper
212 5
def getcode(url, session=None):
213
    """
214
    Return status code of given URL.
215
216
    :param url: URL to check.
217
    :type url: str
218
219
    :param session: Requests session object, default is created on the fly.
220
    :type session: requests.Session()
221
    """
222 5
    session = generic_session(session)
223 5
    try:
224 5
        shead = session.head(url)
225 5
        status = int(shead.status_code)
226 5
        return status
227 5
    except requests.ConnectionError:
228 5
        return 404
229
230
231 5
@pem_wrapper
232 5
def availability(url, session=None):
233
    """
234
    Check HTTP status code of given URL.
235
    200 or 301-308 is OK, else is not.
236
237
    :param url: URL to check.
238
    :type url: str
239
240
    :param session: Requests session object, default is created on the fly.
241
    :type session: requests.Session()
242
    """
243 5
    status = getcode(url, session)
244 5
    return status == 200 or 300 < status <= 308
245
246
247 5
def clean_availability(results, server):
248
    """
249
    Clean availability for autolookup script.
250
251
    :param results: Result dict.
252
    :type results: dict(str: str)
253
254
    :param server: Server, key for result dict.
255
    :type server: str
256
    """
257 5
    marker = "PD" if server == "p" else server.upper()
258 5
    rel = results[server.lower()]
259 5
    avail = marker if rel != "SR not in system" and rel is not None else "  "
260 5
    return rel, avail
261
262
263 5
@pem_wrapper
264 5
def tcl_check(curef, session=None):
265
    """
266
    Check TCL server for updates.
267
268
    :param curef: PRD of the phone variant to check.
269
    :type curef: str
270
271
    :param session: Requests session object, default is created on the fly.
272
    :type session: requests.Session()
273
    """
274 5
    sess = generic_session(session)
275 5
    geturl = "http://g2master-us-east.tctmobile.com/check.php"
276 5
    params = {"id": "543212345000000", "curef": curef, "fv": "AAM481", "mode": 4, "type": "Firmware", "cltp": 2010, "cktp": 2, "rtd": 1, "chnl": 2}
277 5
    req = sess.get(geturl, params=params)
278 5
    if req.status_code == 200:
279 5
        return req.text
280
    else:
281 5
        return None
282
283
284 5
def parse_tcl_check(data):
285
    """
286
    Extract version and file info from TCL update server response.
287
288
    :param data: The data to parse.
289
    :type data: str
290
    """
291 5
    root = ElementTree.fromstring(data)
292 5
    tvver = root.find("VERSION").find("TV").text
293 5
    fwid = root.find("FIRMWARE").find("FW_ID").text
294 5
    fileinfo = root.find("FIRMWARE").find("FILESET").find("FILE")
295 5
    filename = fileinfo.find("FILENAME").text
296 5
    filesize = fileinfo.find("SIZE").text
297 5
    filehash = fileinfo.find("CHECKSUM").text
298 5
    return tvver, fwid, filename, filesize, filehash
299
300
301 5
def tcl_salt():
302
    """
303
    Generate salt value for TCL server tools.
304
    """
305 5
    millis = round(time.time() * 1000)
306 5
    tail = "{0:06d}".format(random.randint(0, 999999))
307 5
    return "{0}{1}".format(str(millis), tail)
308
309
310 5
def vkhash(curef, tvver, fwid, salt):
311
    """
312
    Generate hash from TCL update server variables.
313
314
    :param curef: PRD of the phone variant to check.
315
    :type curef: str
316
317
    :param tvver: Target software version.
318
    :type tvver: str
319
320
    :param fwid: Firmware ID for desired download file.
321
    :type fwid: str
322
323
    :param salt: Salt hash.
324
    :type salt: str
325
    """
326 5
    vdkey = "1271941121281905392291845155542171963889169361242115412511417616616958244916823523421516924614377131161951402261451161002051042011757216713912611682532031591181861081836612643016596231212872211620511861302106446924625728571011411121471811641125920123641181975581511602312222261817375462445966911723844130106116313122624220514"
327 5
    query = "id={0}&salt={1}&curef={2}&fv={3}&tv={4}&type={5}&fw_id={6}&mode={7}&cltp={8}{9}".format("543212345000000", salt, curef, "AAM481", tvver, "Firmware", fwid, 4, 2010, vdkey)
328 5
    engine = hashlib.sha1()
329 5
    engine.update(bytes(query, "utf-8"))
330 5
    return engine.hexdigest()
331
332
333 5
@pem_wrapper
334 5
def tcl_download_request(curef, tvver, fwid, salt, vkh, session=None):
335
    """
336
    Check TCL server for download URLs.
337
338
    :param curef: PRD of the phone variant to check.
339
    :type curef: str
340
341
    :param tvver: Target software version.
342
    :type tvver: str
343
344
    :param fwid: Firmware ID for desired download file.
345
    :type fwid: str
346
347
    :param salt: Salt hash.
348
    :type salt: str
349
350
    :param vkh: VDKey-based hash.
351
    :type vkh: str
352
353
    :param session: Requests session object, default is created on the fly.
354
    :type session: requests.Session()
355
    """
356 5
    sess = generic_session(session)
357 5
    posturl = "http://g2master-us-east.tctmobile.com/download_request.php"
358 5
    params = {"id": "543212345000000", "curef": curef, "fv": "AAM481", "mode": 4, "type": "Firmware", "tv": tvver, "fw_id": fwid, "salt": salt, "vk": vkh, "cltp": 2010}
359 5
    req = sess.post(posturl, data=params)
360 5
    if req.status_code == 200:
361 5
        return req.text
362
    else:
363 5
        return None
364
365
366 5
def parse_tcl_download_request(body):
367
    """
368
    Extract file URL from TCL update server response.
369
370
    :param data: The data to parse.
371
    :type data: str
372
    """
373 5
    root = ElementTree.fromstring(body)
374 5
    slave = root.find("SLAVE_LIST").find("SLAVE").text
375 5
    dlurl = root.find("FILE_LIST").find("FILE").find("DOWNLOAD_URL").text
376 5
    return "http://{0}{1}".format(slave, dlurl)
377
378
379 5
@pem_wrapper
380 5
def carrier_checker(mcc, mnc, session=None):
381
    """
382
    Query BlackBerry World to map a MCC and a MNC to a country and carrier.
383
384
    :param mcc: Country code.
385
    :type mcc: int
386
387
    :param mnc: Network code.
388
    :type mnc: int
389
390
    :param session: Requests session object, default is created on the fly.
391
    :type session: requests.Session()
392
    """
393 5
    session = generic_session(session)
394 5
    url = "http://appworld.blackberry.com/ClientAPI/checkcarrier?homemcc={0}&homemnc={1}&devicevendorid=-1&pin=0".format(mcc, mnc)
395 5
    user_agent = {'User-agent': 'AppWorld/5.1.0.60'}
396 5
    req = session.get(url, headers=user_agent)
397 5
    root = ElementTree.fromstring(req.text)
398 5
    for child in root:
399 5
        if child.tag == "country":
400 5
            country = child.get("name")
401 5
        if child.tag == "carrier":
402 5
            carrier = child.get("name")
403 5
    return country, carrier
404
405
406 5
def return_npc(mcc, mnc):
407
    """
408
    Format MCC and MNC into a NPC.
409
410
    :param mcc: Country code.
411
    :type mcc: int
412
413
    :param mnc: Network code.
414
    :type mnc: int
415
    """
416 5
    return "{0}{1}30".format(str(mcc).zfill(3), str(mnc).zfill(3))
417
418
419 5
@pem_wrapper
420 5
def carrier_query(npc, device, upgrade=False, blitz=False, forced=None, session=None):
421
    """
422
    Query BlackBerry servers, check which update is out for a carrier.
423
424
    :param npc: MCC + MNC (see `func:return_npc`)
425
    :type npc: int
426
427
    :param device: Hexadecimal hardware ID.
428
    :type device: str
429
430
    :param upgrade: Whether to use upgrade files. False by default.
431
    :type upgrade: bool
432
433
    :param blitz: Whether or not to create a blitz package. False by default.
434
    :type blitz: bool
435
436
    :param forced: Force a software release.
437
    :type forced: str
438
439
    :param session: Requests session object, default is created on the fly.
440
    :type session: requests.Session()
441
    """
442 5
    session = generic_session(session)
443 5
    upg = "upgrade" if upgrade else "repair"
444 5
    forced = "latest" if forced is None else forced
445 5
    url = "https://cs.sl.blackberry.com/cse/updateDetails/2.2/"
446 5
    query = '<?xml version="1.0" encoding="UTF-8"?>'
447 5
    query += '<updateDetailRequest version="2.2.1" authEchoTS="1366644680359">'
448 5
    query += "<clientProperties>"
449 5
    query += "<hardware>"
450 5
    query += "<pin>0x2FFFFFB3</pin><bsn>1128121361</bsn>"
451 5
    query += "<imei>004401139269240</imei>"
452 5
    query += "<id>0x{0}</id>".format(device)
453 5
    query += "</hardware>"
454 5
    query += "<network>"
455 5
    query += "<homeNPC>0x{0}</homeNPC>".format(npc)
456 5
    query += "<iccid>89014104255505565333</iccid>"
457 5
    query += "</network>"
458 5
    query += "<software>"
459 5
    query += "<currentLocale>en_US</currentLocale>"
460 5
    query += "<legalLocale>en_US</legalLocale>"
461 5
    query += "</software>"
462 5
    query += "</clientProperties>"
463 5
    query += "<updateDirectives>"
464 5
    query += '<allowPatching type="REDBEND">true</allowPatching>'
465 5
    query += "<upgradeMode>{0}</upgradeMode>".format(upg)
466 5
    query += "<provideDescriptions>false</provideDescriptions>"
467 5
    query += "<provideFiles>true</provideFiles>"
468 5
    query += "<queryType>NOTIFICATION_CHECK</queryType>"
469 5
    query += "</updateDirectives>"
470 5
    query += "<pollType>manual</pollType>"
471 5
    query += "<resultPackageSetCriteria>"
472 5
    query += '<softwareRelease softwareReleaseVersion="{0}" />'.format(forced)
473 5
    query += "<releaseIndependent>"
474 5
    query += '<packageType operation="include">application</packageType>'
475 5
    query += "</releaseIndependent>"
476 5
    query += "</resultPackageSetCriteria>"
477 5
    query += "</updateDetailRequest>"
478 5
    header = {"Content-Type": "text/xml;charset=UTF-8"}
479 5
    req = session.post(url, headers=header, data=query)
480 5
    return parse_carrier_xml(req.text, blitz)
481
482
483 5
def carrier_swver_get(root):
484
    """
485
    Get software release from carrier XML.
486
487
    :param root: ElementTree we're barking up.
488
    :type root: xml.etree.ElementTree.ElementTree
489
    """
490 5
    for child in root.iter("softwareReleaseMetadata"):
491 5
        swver = child.get("softwareReleaseVersion")
492 5
    return swver
493
494
495 5
def carrier_child_fileappend(child, files, baseurl, blitz=False):
496
    """
497
    Append bar file links to a list from a child element.
498
499
    :param child: Child element in use.
500
    :type child: xml.etree.ElementTree.Element
501
502
    :param files: Filelist.
503
    :type files: list(str)
504
505
    :param baseurl: Base URL, URL minus the filename.
506
    :type baseurl: str
507
508
    :param blitz: Whether or not to create a blitz package. False by default.
509
    :type blitz: bool
510
    """
511 5
    if not blitz:
512 5
        files.append(baseurl + child.get("path"))
513
    else:
514 5
        if child.get("type") not in ["system:radio", "system:desktop", "system:os"]:
515 5
            files.append(baseurl + child.get("path"))
516 5
    return files
517
518
519 5
def carrier_child_finder(root, files, baseurl, blitz=False):
520
    """
521
    Extract filenames, radio and OS from child elements.
522
523
    :param root: ElementTree we're barking up.
524
    :type root: xml.etree.ElementTree.ElementTree
525
526
    :param files: Filelist.
527
    :type files: list(str)
528
529
    :param baseurl: Base URL, URL minus the filename.
530
    :type baseurl: str
531
532
    :param blitz: Whether or not to create a blitz package. False by default.
533
    :type blitz: bool
534
    """
535 5
    osver = radver = ""
536 5
    for child in root.iter("package"):
537 5
        files = carrier_child_fileappend(child, files, baseurl, blitz)
538 5
        if child.get("type") == "system:radio":
539 5
            radver = child.get("version")
540 5
        elif child.get("type") == "system:desktop":
541 5
            osver = child.get("version")
542 5
        elif child.get("type") == "system:os":
543 5
            osver = child.get("version")
544 5
    return osver, radver, files
545
546
547 5
def parse_carrier_xml(data, blitz=False):
548
    """
549
    Parse the response to a carrier update request and return the juicy bits.
550
551
    :param data: The data to parse.
552
    :type data: xml
553
554
    :param blitz: Whether or not to create a blitz package. False by default.
555
    :type blitz: bool
556
    """
557 5
    root = ElementTree.fromstring(data)
558 5
    sw_exists = root.find('./data/content/softwareReleaseMetadata')
559 5
    swver = "N/A" if sw_exists is None else ""
560 5
    if sw_exists is not None:
561 5
        swver = carrier_swver_get(root)
562 5
    files = []
563 5
    package_exists = root.find('./data/content/fileSets/fileSet')
564 5
    osver = radver = ""
565 5
    if package_exists is not None:
566 5
        baseurl = "{0}/".format(package_exists.get("url"))
567 5
        osver, radver, files = carrier_child_finder(root, files, baseurl, blitz)
568 5
    return(swver, osver, radver, files)
569
570
571 5
@pem_wrapper
572 5
def sr_lookup(osver, server, session=None):
573
    """
574
    Software release lookup, with choice of server.
575
    :data:`bbarchivist.bbconstants.SERVERLIST` for server list.
576
577
    :param osver: OS version to lookup, 10.x.y.zzzz.
578
    :type osver: str
579
580
    :param server: Server to use.
581
    :type server: str
582
583
    :param session: Requests session object, default is created on the fly.
584
    :type session: requests.Session()
585
    """
586 5
    query = '<?xml version="1.0" encoding="UTF-8"?>'
587 5
    query += '<srVersionLookupRequest version="2.0.0"'
588 5
    query += ' authEchoTS="1366644680359">'
589 5
    query += '<clientProperties><hardware>'
590 5
    query += '<pin>0x2FFFFFB3</pin><bsn>1140011878</bsn>'
591 5
    query += '<imei>004402242176786</imei><id>0x8D00240A</id>'
592 5
    query += '<isBootROMSecure>true</isBootROMSecure>'
593 5
    query += '</hardware>'
594 5
    query += '<network>'
595 5
    query += '<vendorId>0x0</vendorId><homeNPC>0x60</homeNPC>'
596 5
    query += '<currentNPC>0x60</currentNPC><ecid>0x1</ecid>'
597 5
    query += '</network>'
598 5
    query += '<software><currentLocale>en_US</currentLocale>'
599 5
    query += '<legalLocale>en_US</legalLocale>'
600 5
    query += '<osVersion>{0}</osVersion>'.format(osver)
601 5
    query += '<omadmEnabled>false</omadmEnabled>'
602 5
    query += '</software></clientProperties>'
603 5
    query += '</srVersionLookupRequest>'
604 5
    reqtext = sr_lookup_poster(query, server, session)
605 5
    packtext = sr_lookup_xmlparser(reqtext)
606 5
    return packtext
607
608
609 5
def sr_lookup_poster(query, server, session=None):
610
    """
611
    Post the XML payload for a software release lookup.
612
613
    :param query: XML payload.
614
    :type query: str
615
616
    :param server: Server to use.
617
    :type server: str
618
619
    :param session: Requests session object, default is created on the fly.
620
    :type session: requests.Session()
621
    """
622 5
    session = generic_session(session)
623 5
    header = {"Content-Type": "text/xml;charset=UTF-8"}
624 5
    try:
625 5
        req = session.post(server, headers=header, data=query, timeout=1)
626 5
    except (requests.exceptions.Timeout, requests.exceptions.ConnectionError):
627 5
        reqtext = "SR not in system"
628
    else:
629 5
        reqtext = req.text
630 5
    return reqtext
631
632
633 5
def sr_lookup_xmlparser(reqtext):
634
    """
635
    Take the text of a software lookup request response and parse it as XML.
636
637
    :param reqtext: Response text, hopefully XML formatted.
638
    :type reqtext: str
639
    """
640 5
    try:
641 5
        root = ElementTree.fromstring(reqtext)
642 5
    except ElementTree.ParseError:
643 5
        packtext = "SR not in system"
644
    else:
645 5
        packtext = sr_lookup_extractor(root)
646 5
    return packtext
647
648
649 5
def sr_lookup_extractor(root):
650
    """
651
    Take an ElementTree and extract a software release from it.
652
653
    :param root: ElementTree we're barking up.
654
    :type root: xml.etree.ElementTree.ElementTree
655
    """
656 5
    reg = re.compile(r"(\d{1,4}\.)(\d{1,4}\.)(\d{1,4}\.)(\d{1,4})")
657 5
    packages = root.findall('./data/content/')
658 5
    for package in packages:
659 5
        if package.text is not None:
660 5
            match = reg.match(package.text)
661 5
            packtext = package.text if match else "SR not in system"
662 5
            return packtext
663
664
665 5
def sr_lookup_bootstrap(osv, session=None, no2=False):
666
    """
667
    Run lookups for each server for given OS.
668
669
    :param osv: OS to check.
670
    :type osv: str
671
672
    :param session: Requests session object, default is created on the fly.
673
    :type session: requests.Session()
674
675
    :param no2: Whether to skip Alpha2/Beta2 servers. Default is false.
676
    :type no2: bool
677
    """
678 5
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as xec:
679 5
        try:
680 5
            results = {
681
                "p": None,
682
                "a1": None,
683
                "a2": None,
684
                "b1": None,
685
                "b2": None
686
            }
687 5
            if no2:
688 5
                del results["a2"]
689 5
                del results["b2"]
690 5
            for key in results:
691 5
                results[key] = xec.submit(sr_lookup, osv, SERVERS[key], session).result()
692 5
            return results
693
        except KeyboardInterrupt:
694
            xec.shutdown(wait=False)
695
696
697 5
@pem_wrapper
698 5
def available_bundle_lookup(mcc, mnc, device, session=None):
699
    """
700
    Check which software releases were ever released for a carrier.
701
702
    :param mcc: Country code.
703
    :type mcc: int
704
705
    :param mnc: Network code.
706
    :type mnc: int
707
708
    :param device: Hexadecimal hardware ID.
709
    :type device: str
710
711
    :param session: Requests session object, default is created on the fly.
712
    :type session: requests.Session()
713
    """
714 5
    session = generic_session(session)
715 5
    server = "https://cs.sl.blackberry.com/cse/availableBundles/1.0.0/"
716 5
    npc = return_npc(mcc, mnc)
717 5
    query = '<?xml version="1.0" encoding="UTF-8"?>'
718 5
    query += '<availableBundlesRequest version="1.0.0" '
719 5
    query += 'authEchoTS="1366644680359">'
720 5
    query += '<deviceId><pin>0x2FFFFFB3</pin></deviceId>'
721 5
    query += '<clientProperties><hardware><id>0x{0}</id>'.format(device)
722 5
    query += '<isBootROMSecure>true</isBootROMSecure></hardware>'
723 5
    query += '<network><vendorId>0x0</vendorId><homeNPC>0x{0}</homeNPC>'.format(npc)
724 5
    query += '<currentNPC>0x{0}</currentNPC></network><software>'.format(npc)
725 5
    query += '<currentLocale>en_US</currentLocale>'
726 5
    query += '<legalLocale>en_US</legalLocale>'
727 5
    query += '<osVersion>10.0.0.0</osVersion>'
728 5
    query += '<radioVersion>10.0.0.0</radioVersion></software>'
729 5
    query += '</clientProperties><updateDirectives><bundleVersionFilter>'
730 5
    query += '</bundleVersionFilter></updateDirectives>'
731 5
    query += '</availableBundlesRequest>'
732 5
    header = {"Content-Type": "text/xml;charset=UTF-8"}
733 5
    req = session.post(server, headers=header, data=query)
734 5
    root = ElementTree.fromstring(req.text)
735 5
    package = root.find('./data/content')
736 5
    bundlelist = [child.attrib["version"] for child in package]
737 5
    return bundlelist
738
739
740 5
@pem_wrapper
741 5
def ptcrb_scraper(ptcrbid, session=None):
742
    """
743
    Get the PTCRB results for a given device.
744
745
    :param ptcrbid: Numerical ID from PTCRB (end of URL).
746
    :type ptcrbid: str
747
748
    :param session: Requests session object, default is created on the fly.
749
    :type session: requests.Session()
750
    """
751 5
    baseurl = "https://ptcrb.com/vendor/complete/view_complete_request_guest.cfm?modelid={0}".format(
752
        ptcrbid)
753 5
    sess = generic_session(session)
754 5
    sess.headers.update({"Referer": "https://ptcrb.com/vendor/complete/complete_request.cfm"})
755 5
    soup = generic_soup_parser(baseurl, sess)
756 5
    text = soup.get_text()
757 5
    text = text.replace("\r\n", " ")
758 5
    prelimlist = re.findall("OS .+[^\\n]", text, re.IGNORECASE)
759 5
    if not prelimlist:  # Priv
760 5
        prelimlist = re.findall(r"[A-Z]{3}[0-9]{3}[\s]", text)
761 5
    cleanlist = []
762 5
    for item in prelimlist:
763 5
        if not item.endswith("\r\n"):  # they should hire QC people...
764 5
            cleanlist.append(ptcrb_item_cleaner(item))
765 5
    return cleanlist
766
767
768 5
def space_pad(instring, minlength):
769
    """
770
    Pad a string with spaces until it's the minimum length.
771
772
    :param instring: String to pad.
773
    :type instring: str
774
775
    :param minlength: Pad while len(instring) < minlength.
776
    :type minlength: int
777
    """
778 5
    while len(instring) < minlength:
779 5
        instring += " "
780 5
    return instring
781
782
783 5
def ptcrb_item_cleaner(item):
784
    """
785
    Cleanup poorly formatted PTCRB entries written by an intern.
786
787
    :param item: The item to clean.
788
    :type item: str
789
    """
790 5
    item = item.replace("<td>", "")
791 5
    item = item.replace("</td>", "")
792 5
    item = item.replace("\n", "")
793 5
    item = item.replace(" (SR", ", SR")
794 5
    item = re.sub(r"\s?\((.*)$", "", item)
795 5
    item = re.sub(r"\sSV.*$", "", item)
796 5
    item = item.replace(")", "")
797 5
    item = item.replace(". ", ".")
798 5
    item = item.replace(";", "")
799 5
    item = item.replace("version", "Version")
800 5
    item = item.replace("Verison", "Version")
801 5
    if item.count("OS") > 1:
802 5
        templist = item.split("OS")
803 5
        templist[0] = "OS"
804 5
        item = "".join([templist[0], templist[1]])
805 5
    item = item.replace("SR", "SW Release")
806 5
    item = item.replace(" Version:", ":")
807 5
    item = item.replace("Version ", " ")
808 5
    item = item.replace(":1", ": 1")
809 5
    item = item.replace(", ", " ")
810 5
    item = item.replace("Software", "SW")
811 5
    item = item.replace("  ", " ")
812 5
    item = item.replace("OS ", "OS: ")
813 5
    item = item.replace("Radio ", "Radio: ")
814 5
    item = item.replace("Release ", "Release: ")
815 5
    spaclist = item.split(" ")
816 5
    if len(spaclist) > 1:
817 5
        spaclist[1] = space_pad(spaclist[1], 11)
818 5
        spaclist[3] = space_pad(spaclist[3], 11)
819
    else:
820 5
        spaclist.insert(0, "OS:")
821 5
    item = " ".join(spaclist)
822 5
    item = item.strip()
823 5
    return item
824
825
826 5
@pem_wrapper
827 5
def kernel_scraper(utils=False, session=None):
828
    """
829
    Scrape BlackBerry's GitHub kernel repo for available branches.
830
831
    :param utils: Check android-utils repo instead of android-linux-kernel. Default is False.
832
    :type utils: bool
833
834
    :param session: Requests session object, default is created on the fly.
835
    :type session: requests.Session()
836
    """
837 5
    repo = "android-utils" if utils else "android-linux-kernel"
838 5
    kernlist = []
839 5
    sess = generic_session(session)
840 5
    for page in range(1, 10):
841 5
        url = "https://github.com/blackberry/{0}/branches/all?page={1}".format(repo, page)
842 5
        soup = generic_soup_parser(url, sess)
843 5
        if soup.find("div", {"class": "no-results-message"}):
844 5
            break
845
        else:
846 5
            text = soup.get_text()
847 5
            kernlist.extend(re.findall(r"msm[0-9]{4}\/[A-Z0-9]{6}", text, re.IGNORECASE))
848 5
    return kernlist
849
850
851 5
def root_generator(folder, build, variant="common"):
852
    """
853
    Generate roots for the SHAxxx hash lookup URLs.
854
855
    :param folder: Dictionary of variant: loader name pairs.
856
    :type folder: dict(str: str)
857
858
    :param build: Build to check, 3 letters + 3 numbers.
859
    :type build: str
860
861
    :param variant: Autoloader variant. Default is "common".
862
    :type variant: str
863
    """
864
    #Priv specific
865 5
    privx = "bbfoundation/hashfiles_priv/{0}".format(folder[variant])
866
    #DTEK50 specific
867 5
    dtek50x = "bbSupport/DTEK50" if build[:3] == "AAF" else "bbfoundation/hashfiles_priv/dtek50"
868
    #DTEK60 specific
869 5
    dtek60x = dtek50x  # still uses dtek50 folder, for some reason
870
    #Pack it up
871 5
    roots = {"Priv": privx, "DTEK50": dtek50x, "DTEK60": dtek60x}
872 5
    return roots
873
874
875 5
def make_droid_skeleton_bbm(method, build, device, variant="common"):
876
    """
877
    Make an Android autoloader/hash URL, on the BB Mobile site.
878
879
    :param method: None for regular OS links, "sha256/512" for SHA256 or 512 hash.
880
    :type method: str
881
882
    :param build: Build to check, 3 letters + 3 numbers.
883
    :type build: str
884
885
    :param device: Device to check.
886
    :type device: str
887
888
    :param variant: Autoloader variant. Default is "common".
889
    :type variant: str
890
    """
891 5
    devices = {"KEYone": "qc8953"}
892 5
    base = "bbry_{2}_autoloader_user-{0}-{1}".format(variant, build.upper(), devices[device])
893 5
    if method is None:
894 5
        skel = "http://54.247.87.13/softwareupgrade/BBM/{0}.zip".format(base)
895
    else:
896 5
        skel = "http://54.247.87.13/softwareupgrade/BBM/{0}.{1}sum".format(base, method.lower())
897 5
    return skel
898
899
900 5
def make_droid_skeleton_og(method, build, device, variant="common"):
901
    """
902
    Make an Android autoloader/hash URL, on the original site.
903
904
    :param method: None for regular OS links, "sha256/512" for SHA256 or 512 hash.
905
    :type method: str
906
907
    :param build: Build to check, 3 letters + 3 numbers.
908
    :type build: str
909
910
    :param device: Device to check.
911
    :type device: str
912
913
    :param variant: Autoloader variant. Default is "common".
914
    :type variant: str
915
    """
916 5
    folder = {"vzw-vzw": "verizon", "na-att": "att", "na-tmo": "tmo", "common": "default"}
917 5
    devices = {"Priv": "qc8992", "DTEK50": "qc8952_64_sfi", "DTEK60": "qc8996"}
918 5
    roots = root_generator(folder, build, variant)
919 5
    base = "bbry_{2}_autoloader_user-{0}-{1}".format(variant, build.upper(), devices[device])
920 5
    if method is None:
921 5
        skel = "https://bbapps.download.blackberry.com/Priv/{0}.zip".format(base)
922
    else:
923 5
        skel = "https://ca.blackberry.com/content/dam/{1}/{0}.{2}sum".format(base, roots[device], method.lower())
924 5
    return skel
925
926
927 5
def make_droid_skeleton(method, build, device, variant="common"):
928
    """
929
    Make an Android autoloader/hash URL.
930
931
    :param method: None for regular OS links, "sha256/512" for SHA256 or 512 hash.
932
    :type method: str
933
934
    :param build: Build to check, 3 letters + 3 numbers.
935
    :type build: str
936
937
    :param device: Device to check.
938
    :type device: str
939
940
    :param variant: Autoloader variant. Default is "common".
941
    :type variant: str
942
    """
943
    # No Aurora
944 5
    oglist = ("Priv", "DTEK50", "DTEK60")  # BlackBerry
945 5
    bbmlist = ("KEYone")   # BB Mobile
946 5
    if device in oglist:
947 5
        skel = make_droid_skeleton_og(method, build, device, variant)
948 5
    elif device in bbmlist:
949 5
        skel = make_droid_skeleton_bbm(method, build, device, variant)
950 5
    return skel
951
952
953 5
def bulk_droid_skeletons(devs, build, method=None):
954
    """
955
    Prepare list of Android autoloader/hash URLs.
956
957
    :param devs: List of devices.
958
    :type devs: list(str)
959
960
    :param build: Build to check, 3 letters + 3 numbers.
961
    :type build: str
962
963
    :param method: None for regular OS links, "sha256/512" for SHA256 or 512 hash.
964
    :type method: str
965
    """
966 5
    carrier_variants = {
967
        "Priv": ("common", "vzw-vzw", "na-tmo", "na-att"),
968
        "KEYone": ("common", "usa-vzw", "usa-sprint", "global-att")  # verify this
969
    }
970 5
    common_variants = ("common", )  # for single-variant devices
971 5
    carrier_devices = ("Priv", )  # add KEYone when verified
972 5
    skels = []
973 5
    for dev in devs:
974 5
        varlist = carrier_variants[dev] if dev in carrier_devices else common_variants
975 5
        for var in varlist:
976 5
            skel = make_droid_skeleton(method, build, dev, var)
977 5
            skels.append(skel)
978 5
    return skels
979
980
981 5
def prepare_droid_list(device):
982
    """
983
    Convert single devices to a list, if necessary.
984
985
    :param device: Device to check.
986
    :type device: str
987
    """
988 5
    if isinstance(device, list):
989 5
        devs = device
990
    else:
991 5
        devs = [device]
992 5
    return devs
993
994
995 5
def droid_scanner(build, device, method=None, session=None):
996
    """
997
    Check for Android autoloaders on BlackBerry's site.
998
999
    :param build: Build to check, 3 letters + 3 numbers.
1000
    :type build: str
1001
1002
    :param device: Device to check.
1003
    :type device: str
1004
1005
    :param method: None for regular OS links, "sha256/512" for SHA256 or 512 hash.
1006
    :type method: str
1007
1008
    :param session: Requests session object, default is created on the fly.
1009
    :type session: requests.Session()
1010
    """
1011 5
    devs = prepare_droid_list(device)
1012 5
    skels = bulk_droid_skeletons(devs, build, method)
1013 5
    with concurrent.futures.ThreadPoolExecutor(max_workers=len(skels)) as xec:
1014 5
        results = []
1015 5
        for skel in skels:
1016 5
            avail = xec.submit(availability, skel, session)
1017 5
            if avail.result():
1018 5
                results.append(skel)
1019 5
    return results if results else None
1020
1021
1022 5
def chunker(iterable, inc):
1023
    """
1024
    Convert an iterable into a list of inc sized lists.
1025
1026
    :param iterable: Iterable to chunk.
1027
    :type iterable: list/tuple/string
1028
1029
    :param inc: Increment; how big each chunk is.
1030
    :type inc: int
1031
    """
1032 5
    chunks = [iterable[x:x+inc] for x in range(0, len(iterable), inc)]
1033 5
    return chunks
1034
1035
1036 5
def unicode_filter(intext):
1037
    """
1038
    Remove Unicode crap.
1039
1040
    :param intext: Text to filter.
1041
    :type intext: str
1042
    """
1043 5
    return intext.replace("\u2013", "").strip()
1044
1045
1046 5
def table_header_filter(ptag):
1047
    """
1048
    Validate p tag, to see if it's relevant.
1049
1050
    :param ptag: P tag.
1051
    :type ptag: bs4.element.Tag
1052
    """
1053 5
    valid = ptag.find("b") and "BlackBerry" in ptag.text and not "experts" in ptag.text
1054 5
    return valid
1055
1056
1057 5
def table_headers(pees):
1058
    """
1059
    Generate table headers from list of p tags.
1060
1061
    :param pees: List of p tags.
1062
    :type pees: list(bs4.element.Tag)
1063
    """
1064 5
    bolds = [x.text for x in pees if table_header_filter(x)]
1065 5
    return bolds
1066
1067
1068 5
@pem_wrapper
1069 5
def loader_page_scraper(session=None):
1070
    """
1071
    Return scraped autoloader pages.
1072
1073
    :param session: Requests session object, default is created on the fly.
1074
    :type session: requests.Session()
1075
    """
1076 5
    session = generic_session(session)
1077 5
    loader_page_scraper_og(session)
1078 5
    loader_page_scraper_bbm(session)
1079
1080
1081 5
def loader_page_scraper_og(session=None):
1082
    """
1083
    Return scraped autoloader page, original site.
1084
1085
    :param session: Requests session object, default is created on the fly.
1086
    :type session: requests.Session()
1087
    """
1088 5
    url = "https://ca.blackberry.com/support/smartphones/Android-OS-Reload.html"
1089 5
    soup = generic_soup_parser(url, session)
1090 5
    tables = soup.find_all("table")
1091 5
    headers = table_headers(soup.find_all("p"))
1092 5
    for idx, table in enumerate(tables):
1093 5
        loader_page_chunker_og(idx, table, headers)
1094
1095
1096 5
def loader_page_scraper_bbm(session=None):
1097
    """
1098
    Return scraped autoloader page, new site.
1099
1100
    :param session: Requests session object, default is created on the fly.
1101
    :type session: requests.Session()
1102
    """
1103 5
    url = "https://www.blackberrymobile.com/support/reload-software/"
1104 5
    soup = generic_soup_parser(url, session)
1105 5
    ulls = soup.find_all("ul", {"class": re.compile("list-two special-.")})[1:]
1106 5
    print("~~~BlackBerry KEYone~~~")
1107 5
    for ull in ulls:
1108 5
        loader_page_chunker_bbm(ull)
1109
1110
1111 5
def loader_page_chunker_og(idx, table, headers):
1112
    """
1113
    Given a loader page table, chunk it into lists of table cells.
1114
1115
    :param idx: Index of enumerating tables.
1116
    :type idx: int
1117
1118
    :param table: HTML table tag.
1119
    :type table: bs4.element.Tag
1120
1121
    :param headers: List of table headers.
1122
    :type headers: list(str)
1123
    """
1124 5
    print("~~~{0}~~~".format(headers[idx]))
1125 5
    chunks = chunker(table.find_all("td"), 4)
1126 5
    for chunk in chunks:
1127 5
        loader_page_printer(chunk)
1128 5
    print(" ")
1129
1130
1131 5
def loader_page_chunker_bbm(ull):
1132
    """
1133
    Given a loader page list, chunk it into lists of list items.
1134
1135
    :param ull: HTML unordered list tag.
1136
    :type ull: bs4.element.Tag
1137
    """
1138 5
    chunks = chunker(ull.find_all("li"), 3)
1139 5
    for chunk in chunks:
1140 5
        loader_page_printer(chunk)
1141
1142
1143 5
def loader_page_printer(chunk):
1144
    """
1145
    Print individual cell texts given a list of table cells.
1146
1147
    :param chunk: List of td tags.
1148
    :type chunk: list(bs4.element.Tag)
1149
    """
1150 5
    key = unicode_filter(chunk[0].text)
1151 5
    ver = unicode_filter(chunk[1].text)
1152 5
    link = unicode_filter(chunk[2].find("a")["href"])
1153 5
    print("{0}\n    {1}: {2}".format(key, ver, link))
1154
1155
1156 5
@pem_wrapper
1157 5
def base_metadata(url, session=None):
1158
    """
1159
    Get BBNDK metadata, base function.
1160
1161
    :param url: URL to check.
1162
    :type url: str
1163
1164
    :param session: Requests session object, default is created on the fly.
1165
    :type session: requests.Session()
1166
    """
1167 5
    session = generic_session(session)
1168 5
    req = session.get(url)
1169 5
    data = req.content
1170 5
    entries = data.split(b"\n")
1171 5
    metadata = [entry.split(b",")[1].decode("utf-8") for entry in entries if entry]
1172 5
    return metadata
1173
1174
1175 5
def ndk_metadata(session=None):
1176
    """
1177
    Get BBNDK target metadata.
1178
1179
    :param session: Requests session object, default is created on the fly.
1180
    :type session: requests.Session()
1181
    """
1182 5
    data = base_metadata("http://downloads.blackberry.com/upr/developers/update/bbndk/metadata", session)
1183 5
    metadata = [entry for entry in data if entry.startswith(("10.0", "10.1", "10.2"))]
1184 5
    return metadata
1185
1186
1187 5
def sim_metadata(session=None):
1188
    """
1189
    Get BBNDK simulator metadata.
1190
1191
    :param session: Requests session object, default is created on the fly.
1192
    :type session: requests.Session()
1193
    """
1194 5
    metadata = base_metadata("http://downloads.blackberry.com/upr/developers/update/bbndk/simulator/simulator_metadata", session)
1195 5
    return metadata
1196
1197
1198 5
def runtime_metadata(session=None):
1199
    """
1200
    Get BBNDK runtime metadata.
1201
1202
    :param session: Requests session object, default is created on the fly.
1203
    :type session: requests.Session()
1204
    """
1205 5
    metadata = base_metadata("http://downloads.blackberry.com/upr/developers/update/bbndk/runtime/runtime_metadata", session)
1206 5
    return metadata
1207
1208
1209 5
def series_generator(osversion):
1210
    """
1211
    Generate series/branch name from OS version.
1212
1213
    :param osversion: OS version.
1214
    :type osversion: str
1215
    """
1216 5
    splits = osversion.split(".")
1217 5
    return "BB{0}_{1}_{2}".format(*splits[0:3])
1218
1219
1220 5
@pem_wrapper
1221 5
def devalpha_urls(osversion, skel, session=None):
1222
    """
1223
    Check individual Dev Alpha autoloader URLs.
1224
1225
    :param osversion: OS version.
1226
    :type osversion: str
1227
1228
    :param skel: Individual skeleton format to try.
1229
    :type skel: str
1230
1231
    :param session: Requests session object, default is created on the fly.
1232
    :type session: requests.Session()
1233
    """
1234 5
    session = generic_session(session)
1235 5
    url = "http://downloads.blackberry.com/upr/developers/downloads/{0}{1}.exe".format(skel, osversion)
1236 5
    req = session.head(url)
1237 5
    if req.status_code == 200:
1238 5
        finals = (url, req.headers["content-length"])
1239
    else:
1240 5
        finals = ()
1241 5
    return finals
1242
1243
1244 5
def devalpha_urls_serieshandler(osversion, skeletons):
1245
    """
1246
    Process list of candidate Dev Alpha autoloader URLs.
1247
1248
    :param osversion: OS version.
1249
    :type osversion: str
1250
1251
    :param skeletons: List of skeleton formats to try.
1252
    :type skeletons: list
1253
    """
1254 5
    skels = skeletons
1255 5
    for idx, skel in enumerate(skeletons):
1256 5
        if "<SERIES>" in skel:
1257 5
            skels[idx] = skel.replace("<SERIES>", series_generator(osversion))
1258 5
    return skels
1259
1260
1261 5
def devalpha_urls_bulk(osversion, skeletons, xec, session=None):
1262
    """
1263
    Construct list of valid Dev Alpha autoloader URLs.
1264
1265
    :param osversion: OS version.
1266
    :type osversion: str
1267
1268
    :param skeletons: List of skeleton formats to try.
1269
    :type skeletons: list
1270
1271
    :param xec: ThreadPoolExecutor instance.
1272
    :type xec: concurrent.futures.ThreadPoolExecutor
1273
1274
    :param session: Requests session object, default is created on the fly.
1275
    :type session: requests.Session()
1276
    """
1277 5
    finals = {}
1278 5
    skels = devalpha_urls_serieshandler(osversion, skeletons)
1279 5
    for skel in skels:
1280 5
        final = xec.submit(devalpha_urls, osversion, skel, session).result()
1281 5
        if final:
1282 5
            finals[final[0]] = final[1]
1283 5
    return finals
1284
1285
1286 5
def devalpha_urls_bootstrap(osversion, skeletons, session=None):
1287
    """
1288
    Get list of valid Dev Alpha autoloader URLs.
1289
1290
    :param osversion: OS version.
1291
    :type osversion: str
1292
1293
    :param skeletons: List of skeleton formats to try.
1294
    :type skeletons: list
1295
1296
    :param session: Requests session object, default is created on the fly.
1297
    :type session: requests.Session()
1298
    """
1299 5
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as xec:
1300 5
        try:
1301 5
            return devalpha_urls_bulk(osversion, skeletons, xec, session)
1302
        except KeyboardInterrupt:
1303
            xec.shutdown(wait=False)
1304
1305
1306 5
def dev_dupe_dicter(finals):
1307
    """
1308
    Prepare dictionary to clean duplicate autoloaders.
1309
1310
    :param finals: Dict of URL:content-length pairs.
1311
    :type finals: dict(str: str)
1312
    """
1313 5
    revo = {}
1314 5
    for key, val in finals.items():
1315 5
        revo.setdefault(val, set()).add(key)
1316 5
    return revo
1317
1318
1319 5
def dev_dupe_remover(finals, dupelist):
1320
    """
1321
    Filter dictionary of autoloader entries.
1322
1323
    :param finals: Dict of URL:content-length pairs.
1324
    :type finals: dict(str: str)
1325
1326
    :param dupelist: List of duplicate URLs.
1327
    :type duplist: list(str)
1328
    """
1329 5
    for dupe in dupelist:
1330 5
        for entry in dupe:
1331 5
            if "DevAlpha" in entry:
1332 5
                del finals[entry]
1333 5
    return finals
1334
1335
1336 5
def dev_dupe_cleaner(finals):
1337
    """
1338
    Clean duplicate autoloader entries.
1339
1340
    :param finals: Dict of URL:content-length pairs.
1341
    :type finals: dict(str: str)
1342
    """
1343 5
    revo = dev_dupe_dicter(finals)
1344 5
    dupelist = [val for key, val in revo.items() if len(val) > 1]
1345 5
    finals = dev_dupe_remover(finals, dupelist)
1346
    return finals
1347