Giter Club home page Giter Club logo

iliasdownloaderunima's Introduction

Ilias Downloader UniMA

CodeFactor Grade example branch parameter PyPI pyversions PyPI version PyPI downloads total

A simple python package for downloading files from https://ilias.uni-mannheim.de.

  • Automatically synchronizes all files for each download. Only new or updated files and videos will be downloaded.
  • Uses the BeautifulSoup package for scraping and the multiprocessing package to accelerate the download.

Install

Easy way via pip:

pip3 install iliasDownloaderUniMA

Otherwise you can clone or download this repo and then run

python3 setup.py install 

inside the repo directory.

Usage

Starting from version 0.5.0, only your uni_id and your password is required. In general, a simple download script to download all files of the current semester looks like this:

from IliasDownloaderUniMA import IliasDownloaderUniMA

m = IliasDownloaderUniMA()
m.setParam('download_path', '/path/where/you/want/your/files/')
m.login('your_uni_id', 'your_password')
m.addAllSemesterCourses()
m.downloadAllFiles()

The method addAllSemesterCourses() adds all courses of the current semester by default. However, it's possible to modify the search behaviour by passing a regex pattern for semester_pattern. Here are some examples:

# Add all courses from your ilias main page from year 2020:
m.addAllSemesterCourses(semester_pattern=r"\([A-Z]{2,3} 2020\)")
# Add all FSS/ST courses from your ilias main page:
m.addAllSemesterCourses(semester_pattern=r"\((FSS|ST) \d{4}\)")
# Add all HWS/WT courses from your ilias main page:
m.addAllSemesterCourses(semester_pattern=r"\((HWS|WT) \d{4}\)")
# Add all courses from your ilias main page. Even non-regular semester
# courses like 'License Information (Student University of Mannheim)',
# i.e. courses without a semester inside the course name:
m.addAllSemesterCourses(semester_pattern=r"\(.*\)")

You can also exclude courses by passing a list of the corresponding ilias ref ids you want to exclude:

# Add all courses from your ilias main page. Even non-regular semester
# courses. Except the courses with the ref id 954265 or 965389.
m.addAllSemesterCourses(semester_pattern=r"\(.*\)", exclude_ids=[954265, 965389])

A more specific example:

from IliasDownloaderUniMA import IliasDownloaderUniMA

m = IliasDownloaderUniMA()
m.setParam('download_path', '/Users/jonathan/Desktop/')
m.login('jhelgert', 'my_password')
m.addAllSemesterCourses(exclude_ids=[1020946])
m.downloadAllFiles()

Note that the backslash \ is a special character inside a python string. So on a windows machine it's necessary to use a raw string for the download_path:

m.setParam('download_path', r'C:\Users\jonathan\Desktop\')

Where do I get the course ref id?

Parameters

The Parameters can be set by the .setParam(param, value) method, where param is one of the following parameters:

  • 'num_scan_threads' number of threads used for scanning for files inside the folders (default: 5).
  • 'num_download_threads' number of threads used for downloading all files (default: 5).
  • 'download_path' the path all the files will be downloaded to (default: the current working directory).
  • 'tutor_mode' downloads all submissions for each task unit once the deadline has expired (default: False)
  • 'verbose' printing information while scanning the courses (default: False)
from IliasDownloaderUniMA import IliasDownloaderUniMA

m = IliasDownloaderUniMA()
m.setParam('download_path', '/Users/jonathan/Desktop/')
m.setParam('num_scan_threads', 20)
m.setParam('num_download_threads', 20)
m.setParam('tutor_mode', True)
m.login('jhelgert', 'my_password')
m.addAllSemesterCourses()
m.downloadAllFiles()

Advanced Usage

Since some lecturers don't use ILIAS, it's possible to use an external scraper function via the addExternalScraper(fun, *args) method. Here fun is the external scraper function and *args are the corresponding variable number of arguments. Note that's mandatory to use course_name as first function argument for your scraper. Your external scraper is expected to return a list of dicts with keys

# 'course': <the course name>
# 'type': 'file'
# 'name': <name of the parsed file>
# 'size': <file size (in mb) as float>
# 'mod-date': <the modification date as datetime object>
# 'url': <file url>
# 'path': <path where you want to download the file>

Here's an example:

from IliasDownloaderUniMA import IliasDownloaderUniMA
from urllib.parse import urljoin
from bs4 import BeautifulSoup
from dateparser import parse
import requests

def myExtScraper(course_name, url):
	"""
	Extracts all file links from the given url.
	"""
	files = []
	file_extensions = (".pdf", ".zip", ".tar.gz", ".cc", ".hh", ".cpp", ".h")
	soup = BeautifulSoup(requests.get(url).content, "lxml")
	for link in [i for i in soup.find_all(href=True) if i['href'].endswith(file_extensions)]: 
		file_url = urljoin(url, link['href'])
		resp = requests.head(file_url)
		files.append({
			'course': course_name,
			'type': 'file',
			'name': file_url.split("/")[-1],
			'size': 1e-6 * float(resp.headers['Content-Length']),
			'mod-date': parse(resp.headers['Last-Modified']),
			'url': file_url,
			'path': course_name + '/'
		})
	return files

m = IliasDownloaderUniMA()
m.login("jhelgert", "my_password")
m.addAllSemesterCourses()
m.addExternalScraper(myExtScraper, "OOP for SC", "https://conan.iwr.uni-heidelberg.de/teaching/oopfsc_ws2020/")
m.downloadAllFiles()

Contribute

Feel free to contribute in any form! Feature requests, Bug reports or PRs are more than welcome.

iliasdownloaderunima's People

Contributors

jhelgert avatar jonas7654 avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

Forkers

jacobtscher

iliasdownloaderunima's Issues

Parser error

Hi,

I am getting this error while trying to download files from Ilias:

File "5_Semester.py", line 12, in
m.downloadAllFiles()
File "/home/jonas/.local/lib/python3.8/site-packages/IliasDownloaderUniMA-0.4-py3.8.egg/IliasDownloaderUniMA/init.py", line 322, in downloadAllFiles
File "/home/jonas/.local/lib/python3.8/site-packages/IliasDownloaderUniMA-0.4-py3.8.egg/IliasDownloaderUniMA/init.py", line 284, in scanCourses
File "/home/jonas/.local/lib/python3.8/site-packages/IliasDownloaderUniMA-0.4-py3.8.egg/IliasDownloaderUniMA/init.py", line 271, in searchForFiles
File "/usr/lib/python3.8/multiprocessing/pool.py", line 868, in next
raise value
File "/usr/lib/python3.8/multiprocessing/pool.py", line 125, in worker
result = (True, func(*args, **kwds))
File "/home/jonas/.local/lib/python3.8/site-packages/IliasDownloaderUniMA-0.4-py3.8.egg/IliasDownloaderUniMA/init.py", line 270, in
File "/home/jonas/.local/lib/python3.8/site-packages/IliasDownloaderUniMA-0.4-py3.8.egg/IliasDownloaderUniMA/init.py", line 255, in scanHelper
File "/home/jonas/.local/lib/python3.8/site-packages/IliasDownloaderUniMA-0.4-py3.8.egg/IliasDownloaderUniMA/init.py", line 209, in scanFolder
File "/home/jonas/.local/lib/python3.8/site-packages/IliasDownloaderUniMA-0.4-py3.8.egg/IliasDownloaderUniMA/init.py", line 168, in _parseFileProperties
File "/usr/lib/python3.8/site-packages/dateutil/parser/_parser.py", line 1374, in parse
return DEFAULTPARSER.parse(timestr, **kwargs)
File "/usr/lib/python3.8/site-packages/dateutil/parser/_parser.py", line 649, in parse
raise ParserError("Unknown string format: %s", timestr)
dateutil.parser._parser.ParserError: Unknown string format:

Some courses crash the addCourse function

Describe the bug
The breadcrumbs of courses seem to be inconsistent.
Some courses have only two instead of three li tags in their breadcrumb.
addCourse expects the breadcrumb to have three, so it crashes when parsing the course name.

The only course I found so far that suffers from this has the ref_id 342184.

I tried to write a fix here

Traceback (most recent call last):
  File "download.py", line 7, in <module>
    m.addCourse(342184)
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 113, in addCourse
    course_name = soup.find_all("ol", "breadcrumb")[0].find_all('li')[2].get_text()
IndexError: list index out of range

Expected behavior
addCourse should work with every Course.

Download files inside task units

At the moment, only the files inside the 'Dateien' folder will be downloaded. The files inside task units are ignored.

Edit: Unfortunately, Ilias doesn't provide any information about the file modification date inside a task unit. Hence, there's no way to see if the files have been updated on Ilias inside a task unit.

Implement a addExternalScraper method

Some lecturers don't use ILIAS. In this case, one could provide an external function that scrapes the external page for links so that these links will be downloaded, too.

Something like this:

def myExternalScraper(course_name, url):
    # Scrapes a webpage for files with specific file_extensions
    # returns a list of dicts
    # 	{ 'course': course_name, 
    # 'type': 'file',
    # 'name': v_name,
    # 'size': v_size,
    # 'mod-date': v_mod_date,
    # 'url': v_url,
    # 'path': file_path
    # }


m = IliasDownloaderUniMA()
m.login(...)
m.addExternalScraper(myExternalScraper, course_name, url)
m.addAllSemesterCourses()
m.downloadAllFiles()

File properties parsing error

Describe the bug
The parsing of the file properties fails if there's a fourth token 'Verfügbarkeit':

Bildschirmfoto 2020-10-13 um 08 29 26

  File "/usr/local/lib/python3.8/site-packages/IliasDownloaderUniMA/__init__.py", line 169, in _parseFileProperties
    file_mod_date = parsedate(self.translate_date(p[-1].get_text()), dayfirst=True)
  File "/usr/local/lib/python3.8/site-packages/dateutil/parser/_parser.py", line 1374, in parse
    return DEFAULTPARSER.parse(timestr, **kwargs)
  File "/usr/local/lib/python3.8/site-packages/dateutil/parser/_parser.py", line 649, in parse
    raise ParserError("Unknown string format: %s", timestr)
dateutil.parser._parser.ParserError: Unknown string format:

		Verfügbarkeit: 12. Oct 2020, 17:00 - 28. Feb 2021, 15:00

Wrong Python version in setup.py

Wrong Python version in setup.py

AttributeError: type object 'datetime.datetime' has no attribute 'fromisoformat'

(from https://docs.python.org/3/library/datetime.html)
classmethod date.fromisoformat(date_string)
Return a date corresponding to a date_string given in the format YYYY-MM-DD:

from datetime import date
date.fromisoformat('2019-12-04')
datetime.date(2019, 12, 4)
This is the inverse of date.isoformat(). It only supports the format YYYY-MM-DD.

New in version 3.7.
Solution
Increase required Python Version to 3.7+

IndexError _parseFileProperties

Describe the bug

Exception has occurred: IndexError
list index out of range
line 172, in _parseFileProperties file_size = float(file_size_tmp[0])

image

source:<bs4.element.SoupStrainer object at 0x7fd58c4ff550>
0:<span class="il_ItemProperty">
		
		Dateiendung fehlt  </span>
1:<span class="il_ItemProperty">
		
		  </span>
2:<span class="il_ItemProperty">
		
		739 Bytes  </span>

Course ID: 1019887
Path to file: Datenbanksysteme I [V] [1. PG] (HWS 2020) Übung Quellcode Blatt 08 7_Implementierung_Normalform-Tests_Zerlegungsalgorithmen Java

broken since ilias update

Any plans to update this for the new version of ilias? Since then I cannot run the script anymore.

Traceback (most recent call last):
  File "iliasscript.py", line 5, in <module>
    m.login('ilias_name', 'ilias_password')
  File "/home/aleyc0re/.local/lib/python3.8/site-packages/IliasDownloaderUniMA/IliasDL.py", line 121, in login
    raise ConnectionError("Couldn't log into ILIAS. Make sure your provided uni-id and the password are correct.")
requests.exceptions.ConnectionError: Couldn't log into ILIAS. Make sure your provided uni-id and the password are correct

ValueError: day is out of range for month

Hello,

I am getting the following error when trying to download files from Ilias

Traceback (most recent call last): File "Ilias.py", line 11, in <module> m.downloadAllFiles() File "C:\Users\veitj\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\LocalCache\local-packages\Python38\site-packages\IliasDownloaderUniMA\__init__.py", line 307, in downloadAllFiles self.scanCourses() File "C:\Users\veitj\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\LocalCache\local-packages\Python38\site-packages\IliasDownloaderUniMA\__init__.py", line 269, in scanCourses self.searchForFiles(course['name']) File "C:\Users\veitj\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\LocalCache\local-packages\Python38\site-packages\IliasDownloaderUniMA\__init__.py", line 256, in searchForFiles for r in results: File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.8_3.8.1776.0_x64__qbz5n2kfra8p0\lib\multiprocessing\pool.py", line 868, in next raise value File "C:\Program Files\WindowsApps\PythonSoftwareFoundation.Python.3.8_3.8.1776.0_x64__qbz5n2kfra8p0\lib\multiprocessing\pool.py", line 125, in worker result = (True, func(*args, **kwds)) File "C:\Users\veitj\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\LocalCache\local-packages\Python38\site-packages\IliasDownloaderUniMA\__init__.py", line 255, in <lambda> results = ThreadPool(self.params['num_scan_threads']).imap_unordered(lambda x: self.scanHelper(course_name, x), self.to_scan) File "C:\Users\veitj\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\LocalCache\local-packages\Python38\site-packages\IliasDownloaderUniMA\__init__.py", line 240, in scanHelper self.scanFolder(course_name, el['url']) File "C:\Users\veitj\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\LocalCache\local-packages\Python38\site-packages\IliasDownloaderUniMA\__init__.py", line 194, in scanFolder file_ending, file_size, file_mod_date = self._parseFileProperties(i) File "C:\Users\veitj\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\LocalCache\local-packages\Python38\site-packages\IliasDownloaderUniMA\__init__.py", line 168, in _parseFileProperties file_mod_date = parsedate(self.translate_date(p[-1].get_text()), dayfirst=True) File "C:\Users\veitj\AppData\Local\Packages\PythonSoftwareFoundation.Python.3.8_qbz5n2kfra8p0\LocalCache\local-packages\Python38\site-packages\IliasDownloaderUniMA\__init__.py", line 140, in translate_date gestern = today.replace(day = today.day-1).strftime("%d. %b %Y") ValueError: day is out of range for month

I don't know why this error occurs. The tool worked fine over the last weeks

Missing il_ItemProperty cause crashes

Describe the bug
Another weird Ilias quirk. Some files have only one il_ItemProperty, the file extension.
_parseFileProperties expects that property list includes at least extension and file size.
It can only access one, so it crashes.

A course with this problem is VL Einführung in das politische System der BRD (HWS 2020) with the ref_id 1035247.

I tried to write a fix here

Scanning VL Einführung in das politische System der BRD (HWS 2020) with 5 Threads....
Scanning Folder...
VL Einführung in das politische System der BRD (HWS 2020)//
https://ilias.uni-mannheim.de/ilias.php?ref_id=1035247&cmd=frameset&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Scanning Folder...
VL Einführung in das politische System der BRD (HWS 2020)/30. Okt 2020, 10 - 15 - 11 - 45 -  Soziale und politische Partizipation/
https://ilias.uni-mannheim.de/ilias.php?ref_id=1041436&cmd=infoScreen&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Scanning Folder...
VL Einführung in das politische System der BRD (HWS 2020)/09. Okt 2020, 10 - 15 - 11 - 45 -  Historische Entwicklungslinien des politischen System Deutschlands/
https://ilias.uni-mannheim.de/ilias.php?ref_id=1041433&cmd=infoScreen&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Traceback (most recent call last):
  File "download.py", line 8, in <module>
    m.downloadAllFiles()
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 341, in downloadAllFiles
    self.scanCourses()
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 303, in scanCourses
    self.searchForFiles(course['name'])
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 290, in searchForFiles
    for r in results:
  File "/usr/lib/python3.8/multiprocessing/pool.py", line 868, in next
    raise value
  File "/usr/lib/python3.8/multiprocessing/pool.py", line 125, in worker
    result = (True, func(*args, **kwds))
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 289, in <lambda>
    results = ThreadPool(self.params['num_scan_threads']).imap_unordered(lambda x: self.scanHelper(course_name, x), self.to_scan)
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 274, in scanHelper
    self.scanFolder(course_name, el['url'])
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 225, in scanFolder
    file_ending, file_size, file_mod_date = self._parseFileProperties(i)
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 170, in _parseFileProperties
    file_size_tmp = p[1].get_text().replace(".","").replace(",", ".").split()
IndexError: list index out of range

Expected behavior
_parseFileProperties shouldn't crash with misconfigured files.

Some Videos don't have captions, resulting in crashs

Describe the bug
The scanFolder method assumes that every Video has a corresponding ilc_media_caption_MediaCaption div to take the video filename from.
Ilias courses are once again inconsistent, with videos that are missing captions, which leads to a crash.

An example would be the VL Einführung in die Politikwissenschaft (HWS 2020) with the ref_id 1017589.

I tried to write a fix here

Scanning VL Einführung in die Politikwissenschaft (HWS 2020) with 5 Threads....
Scanning Folder...
VL Einführung in die Politikwissenschaft (HWS 2020)//
https://ilias.uni-mannheim.de/ilias.php?ref_id=997744&cmd=frameset&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Scanning Folder...
VL Einführung in die Politikwissenschaft (HWS 2020)/Dateien/
https://ilias.uni-mannheim.de/ilias.php?ref_id=997745&cmd=view&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Scanning Folder...
VL Einführung in die Politikwissenschaft (HWS 2020)/Dateien/Sitzung 2 (6.10.2020, Schoen)/
https://ilias.uni-mannheim.de/ilias.php?ref_id=1017587&cmd=view&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Scanning Folder...
VL Einführung in die Politikwissenschaft (HWS 2020)/Dateien/Sitzung 3  (13.10. 2020, Traunmüller)/
https://ilias.uni-mannheim.de/ilias.php?ref_id=1017588&cmd=view&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Scanning Folder...
VL Einführung in die Politikwissenschaft (HWS 2020)/Dateien/Sitzung 1 (29.9.2020, Schoen)/
https://ilias.uni-mannheim.de/ilias.php?ref_id=998824&cmd=view&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Scanning Folder...
VL Einführung in die Politikwissenschaft (HWS 2020)/Dateien/Sitzung 4 (20.10.2020, Blom)/
https://ilias.uni-mannheim.de/ilias.php?ref_id=1017589&cmd=view&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Scanning Folder...
VL Einführung in die Politikwissenschaft (HWS 2020)/Dateien/Sitzung 5 (Schmitt-Beck, Aufzeichnung)/
https://ilias.uni-mannheim.de/ilias.php?ref_id=1017590&cmd=view&cmdClass=ilrepositorygui&cmdNode=vi&baseClass=ilrepositorygui
-------------------------------------------------
Traceback (most recent call last):
  File "download.py", line 8, in <module>
    m.downloadAllFiles()
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 341, in downloadAllFiles
    self.scanCourses()
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 303, in scanCourses
    self.searchForFiles(course['name'])
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 290, in searchForFiles
    for r in results:
  File "/usr/lib/python3.8/multiprocessing/pool.py", line 868, in next
    raise value
  File "/usr/lib/python3.8/multiprocessing/pool.py", line 125, in worker
    result = (True, func(*args, **kwds))
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 289, in <lambda>
    results = ThreadPool(self.params['num_scan_threads']).imap_unordered(lambda x: self.scanHelper(course_name, x), self.to_scan)
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 274, in scanHelper
    self.scanFolder(course_name, el['url'])
  File "/home/gity/temp/IliasDownloaderUniMA/IliasDownloaderUniMA/__init__.py", line 202, in scanFolder
    el_name = v.find('div', {'class': 'ilc_media_caption_MediaCaption'}).get_text()
AttributeError: 'NoneType' object has no attribute 'get_text'

Expected behavior
scanFolder should be able to download videos, even if they are missing a corresponding caption div.

[WinError 267] The directory name is invalid

The downloadAllFiles() method yields a WinError257 on windows for files with a ":" inside the parsed file path. The ":" is a reserved character on windows and not valid for directory names.

Download all submissions inside a task unit

As a tutor, it would be nice to download all submissions for each task unit after the schedule has ended.

Note: Pushing 'Download all submissions' triggers an Ilias background task to prepare the download. May be necessary to tinker with some javascript to handle the download properly.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.