Compare commits

...

10 commits

13 changed files with 261 additions and 46 deletions

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright (c) 2024 xenofem
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -4,10 +4,12 @@ import argparse
import asyncio import asyncio
import importlib_resources as resources import importlib_resources as resources
from pathlib import Path from pathlib import Path
from os import getenv
from os.path import relpath, splitext from os.path import relpath, splitext
import re import re
import shutil import shutil
import sqlite3 import sqlite3
import textwrap
from urllib.parse import urlparse from urllib.parse import urlparse
import zipfile import zipfile
@ -296,12 +298,15 @@ def metadata(args):
con.close() con.close()
def copy_contents(src, dest): def copy_recursive(src, dest):
dest.mkdir(parents=True, exist_ok=True) dest.mkdir(parents=True, exist_ok=True)
for item in src.iterdir(): for item in src.iterdir():
if item.is_dir() and not item.is_symlink():
copy_recursive(item, dest / item.name)
else:
shutil.copyfile(item, dest / item.name) shutil.copyfile(item, dest / item.name)
def publish(args): def generate(args):
jenv = Environment( jenv = Environment(
loader=PackageLoader("dlibrary"), loader=PackageLoader("dlibrary"),
autoescape=select_autoescape() autoescape=select_autoescape()
@ -347,7 +352,7 @@ def publish(args):
with open(viewer_dir / 'index.html', 'w') as f: with open(viewer_dir / 'index.html', 'w') as f:
f.write(viewer_template.render(depth=3, work=work, title=title, images=images)) f.write(viewer_template.render(depth=3, work=work, title=title, images=images))
def make_categorization(categorization, query, work_filter): def make_categorization(categorization, query, work_filter, work_style_cards=False):
categorization_dir = args.destdir / 'site' / categorization categorization_dir = args.destdir / 'site' / categorization
cats = [cat for (cat,) in cur.execute(query)] cats = [cat for (cat,) in cur.execute(query)]
@ -374,6 +379,7 @@ def publish(args):
categorization=categorization, categorization=categorization,
categories=cats, categories=cats,
samples=cat_samples, samples=cat_samples,
work_style_cards=work_style_cards,
)) ))
make_categorization( make_categorization(
@ -395,10 +401,11 @@ def publish(args):
'series', 'series',
'SELECT DISTINCT series FROM works WHERE series NOT NULL ORDER BY series', 'SELECT DISTINCT series FROM works WHERE series NOT NULL ORDER BY series',
lambda series: lambda work: work['series'] == series, lambda series: lambda work: work['series'] == series,
work_style_cards=True,
) )
with resources.as_file(resources.files("dlibrary")) as r: with resources.as_file(resources.files("dlibrary")) as r:
copy_contents(r / 'static', args.destdir / 'site' / 'static') copy_recursive(r / 'static', args.destdir / 'site' / 'static')
with open(args.destdir / 'site' / 'index.html', 'w') as f: with open(args.destdir / 'site' / 'index.html', 'w') as f:
f.write(list_template.render(depth=0, works=works)) f.write(list_template.render(depth=0, works=works))
@ -406,12 +413,33 @@ def publish(args):
con.close() con.close()
argparser = argparse.ArgumentParser(prog='dlibrary') argparser = argparse.ArgumentParser(
prog='dlibrary',
formatter_class=argparse.RawDescriptionHelpFormatter,
description=textwrap.dedent("""\
Organize DRM-free works purchased from DLSite into a library
that can be viewed in a web browser.
Intended workflow:
- `extract` a collection of zipfiles downloaded from DLSite
into DLibrary's data directory, giving each work its own
subfolder.
- `fetch` metadata and thumbnail images for extracted works
from DLSite.
- `collate` and/or `manual-collate` extracted works,
producing a single sequence of image files (or symlinks
into the extracted data, when possible) for each work.
- Manually adjust works' `metadata` when necessary.
- `generate` a static website providing a catalog and viewer
for all collated works.
"""),
)
argparser.add_argument( argparser.add_argument(
'-d', '--destdir', '-d', '--destdir',
type=Path, type=Path,
default=Path('./dlibrary'), default=Path(getenv('DLIBRARY_DIR', './dlibrary')),
help='directory to store dlibrary content and metadata to (default: ./dlibrary)', help='directory to store dlibrary content and metadata to (default: $DLIBRARY_DIR or ./dlibrary)',
) )
subparsers = argparser.add_subparsers(title="subcommands", required=True) subparsers = argparser.add_subparsers(title="subcommands", required=True)
@ -433,23 +461,79 @@ parser_extract.set_defaults(func=extract)
parser_fetch = subparsers.add_parser('fetch', help='fetch metadata and thumbnails') parser_fetch = subparsers.add_parser('fetch', help='fetch metadata and thumbnails')
parser_fetch.set_defaults(func=fetch) parser_fetch.set_defaults(func=fetch)
parser_collate = subparsers.add_parser('collate', help='collate a single sequence of image files for each work') parser_collate = subparsers.add_parser(
'collate',
help='collate each work into a sequence of image files',
formatter_class=argparse.RawDescriptionHelpFormatter,
description=textwrap.dedent("""\
For each extracted work that has not already been collated,
DLibrary will attempt to intuit its structure as follows:
- Enter the work's directory. If the directory contains
nothing except a single subdirectory (ignoring a few types
of files that are definitely not relevant), traverse
downwards repeatedly.
- If the current directory contains nothing except a single
PDF (again, ignoring irrelevant files), attempt to extract
a series of images from the PDF. This process expects that
each page of the PDF consists of a single embedded image,
which will be extracted at full resolution. Support for
more complex PDFs is not yet implemented.
- If the current directory contains nothing except image
files, and the image files are named in a way that clearly
indicates a complete numerical order (each filename
consists of a shared prefix followed by a distinct
number), symlink files in the inferred order.
- Otherwise, skip processing this work for now.
DLibrary can be given "collation hints" which provide
alternative starting points for this search process. A hint
is a path under $DLIBRARY_DIR/extract/[work id]/
indicating a different directory or PDF file to begin the
search process for that work, rather than starting at the
top level of the extracted data. There can be at most one
hint per work; for more complicated scenarios where a work
includes multiple folders that need to be collated together,
or where filenames do not clearly indicate an ordering, use
`manual-collate` instead.
"""),
)
parser_collate.add_argument( parser_collate.add_argument(
'hints', 'hints',
metavar='PATH', metavar='PATH',
type=Path, type=Path,
nargs='*', nargs='*',
help='manually-specified paths of subdirectories or PDFs within extraction folders, at most one per work', help='paths within extraction folders as collation hints'
) )
parser_collate.set_defaults(func=collate) parser_collate.set_defaults(func=collate)
parser_manual_collate = subparsers.add_parser('manual-collate', help='collate a specific work manually, specifying all paths to include') parser_manual_collate = subparsers.add_parser(
'manual-collate',
help='collate a single work manually',
formatter_class=argparse.RawDescriptionHelpFormatter,
description=textwrap.dedent("""\
All provided paths must be under $DLIBRARY_DIR/extract/[work id]/
for the work being manually collated. `manual-collate` can
only handle one work at a time. Paths are used as follows:
- If a path is a directory, all *image files* immediately
inside that directory will be appended to the sequence. If
files are named in a way which indicates a clear ordering,
that ordering will be used. Otherwise, filenames will be
sorted lexicographically. Non-image files and
subdirectories will be ignored.
- If a path is an image file, that image file will be
appended to the sequence.
- If a path is a PDF file, page images will be extracted
from that PDF and appended to the sequence.
"""),
)
parser_manual_collate.add_argument( parser_manual_collate.add_argument(
'paths', 'paths',
metavar='PATH', metavar='PATH',
type=Path, type=Path,
nargs='+', nargs='+',
help='paths of files (images to symlink, pdfs to extract) or directories (symlink all images in the directory, no recursion, best-effort sorting)' help='paths within a single work to be collated in sequence',
) )
parser_manual_collate.set_defaults(func=manual_collate) parser_manual_collate.set_defaults(func=manual_collate)
@ -462,8 +546,20 @@ parser_metadata.add_argument(
) )
parser_metadata.set_defaults(func=metadata) parser_metadata.set_defaults(func=metadata)
parser_publish = subparsers.add_parser('publish', help='generate HTML/CSS/JS for library site') parser_generate = subparsers.add_parser(
parser_publish.set_defaults(func=publish) 'generate',
help='generate HTML/CSS/JS for library site',
formatter_class=argparse.RawDescriptionHelpFormatter,
description=textwrap.dedent("""\
The static site will be generated under $DLIBRARY_DIR/site/
and can be served by pointing an HTTP server at that
directory. Note that some files inside the static site
hierarchy will be symlinks into $DLIBRARY_DIR/extract/
outside the site hierarchy, so make sure your HTTP server
will allow those symlinks to be read.
"""),
)
parser_generate.set_defaults(func=generate)
def main(): def main():
args = argparser.parse_args() args = argparser.parse_args()

View file

@ -51,12 +51,19 @@ body {
.work-container { .work-container {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 30px; gap: 30px;
} }
.work-preview img {
max-width: 100%;
}
.work-info { .work-info {
max-width: min(50%, 500px); flex-basis: 40%;
flex-grow: 1;
max-width: 500px;
} }
.work-info td, .work-info th { .work-info td, .work-info th {

View file

@ -4,3 +4,39 @@ html, body {
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.tap {
background: #000000;
opacity: 0.8;
position: fixed;
animation: 2s linear forwards fade;
display: flex;
justify-content: center;
align-items: center;
}
@keyframes fade {
to { opacity: 0; }
}
#tap-left, #tap-right {
position: fixed;
bottom: 0px;
width: 30vw;
height: 100vh;
}
#tap-left {
left: 0px;
}
#tap-right {
right: 0px;
}
#tap-back {
top: 0px;
left: 30vw;
width: 40vw;
height: 20vh;
}

View file

@ -5,6 +5,7 @@ document.addEventListener('DOMContentLoaded', () => {
let paused = true; let paused = true;
let interval; let interval;
let elapsed = 0; let elapsed = 0;
let rtl = (localStorage.getItem(`${WORK_ID}-rtl`) !== "false");
function startTimer() { function startTimer() {
if (interval) { if (interval) {
@ -78,16 +79,46 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
function left() {
if (currentPage === 0) {
rtl = true;
localStorage.setItem(`${WORK_ID}-rtl`, rtl);
}
changePage(currentPage + (rtl ? 1 : -1));
}
function right() {
if (currentPage === 0) {
rtl = false;
localStorage.setItem(`${WORK_ID}-rtl`, rtl);
}
changePage(currentPage + (rtl ? -1 : 1));
}
function exitToWork() {
changeDuration(duration, true);
localStorage.setItem(`${WORK_ID}-currentPage`, 0);
window.location.href = `../${INDEX}`;
}
function hideTapZones() {
for (const el of document.getElementsByClassName('tap')) {
el.style.opacity = 0;
}
}
changePage(currentPage); changePage(currentPage);
changeDuration(duration, paused); changeDuration(duration, paused);
document.onkeydown = event => { document.onkeydown = event => {
hideTapZones();
switch (event.keyCode) { switch (event.keyCode) {
case 32: //space case 32: //space
changeDuration(duration, !paused); changeDuration(duration, !paused);
break; break;
case 37: //left case 37: //left
changePage(currentPage - 1); left();
break; break;
case 38: //up case 38: //up
if (2 <= duration && duration <= 10) { if (2 <= duration && duration <= 10) {
@ -99,7 +130,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
break; break;
case 39: //right case 39: //right
changePage(currentPage + 1); right();
break; break;
case 40: //down case 40: //down
if (duration < 10) { if (duration < 10) {
@ -111,10 +142,13 @@ document.addEventListener('DOMContentLoaded', () => {
} }
break; break;
case 13: //enter case 13: //enter
changeDuration(duration, true); exitToWork();
localStorage.setItem(`${WORK_ID}-currentPage`, 0);
window.location.href = "../";
break; break;
} }
}; };
document.onclick = hideTapZones;
document.getElementById("tap-left").onclick = left;
document.getElementById("tap-right").onclick = right;
document.getElementById("tap-back").onclick = exitToWork;
}); });

View file

@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark"> <meta name="color-scheme" content="dark">
<title>{% block title %}{% if title %}{{ title }} - {% else %}{% endif %}DLibrary{% endblock %}</title> <title>{% block title %}{% if title %}{{ title }} - {% else %}{% endif %}DLibrary{% endblock %}</title>
<link rel="stylesheet" type="text/css" href="{{ root() }}static/dlibrary.css"> <link rel="stylesheet" type="text/css" href="{{ root() }}/static/dlibrary.css">
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>

View file

@ -1,18 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{{ categorization.capitalize() }} - DLibrary{% endblock %} {% block title %}{{ categorization.capitalize() }} - DLibrary{% endblock %}
{% block body %} {% block body %}
{% from 'utils.html' import urlcat, root with context %} {% from 'utils.html' import urlcat, index, root with context %}
<h1 id="title"><a href="{{ root() }}">DLibrary</a> &gt; <a href="{{ root() }}{{ categorization }}">{{ categorization.capitalize() }}</a></h1> <h1 id="title"><a href="{{ root() }}/{{ index() }}">DLibrary</a> &gt; {{ categorization.capitalize() }}</h1>
{% include 'nav.html' %} {% include 'nav.html' %}
<div class="card-listing"> <div class="card-listing">
{% for cat in categories %} {% for cat in categories %}
<div class="card category"> <div class="card {% if not work_style_cards %}category{% endif %}">
<a href="{{ root() }}{{ categorization }}/{{ urlcat(cat) }}/"> <a href="{{ root() }}/{{ categorization }}/{{ urlcat(cat) }}/{{ index() }}">
<div class="card-title"> <div class="card-title">
{{ cat }} {{ cat }}
</div> </div>
{% if samples[cat] %} {% if samples[cat] %}
<img src="{{ root() }}thumbnails/{{ samples[cat]['id'] }}.jpg"> <img src="{{ root() }}/thumbnails/{{ samples[cat]['id'] }}.jpg">
{% endif %} {% endif %}
</a> </a>
</div> </div>

View file

@ -1,13 +1,13 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block body %} {% block body %}
{% from 'utils.html' import root with context %} {% from 'utils.html' import index, root with context %}
<h1 id="title"><a href="{{ root() }}">DLibrary</a>{% if categorization %} &gt; <a href="{{ root() }}{{ categorization }}">{{ categorization.capitalize() }}</a>{% endif %}{% if title %} &gt; {{ title }}{% endif %}</h1> <h1 id="title"><a href="{{ root() }}/{{ index() }}">DLibrary</a>{% if categorization %} &gt; <a href="{{ root() }}/{{ categorization }}/{{ index() }}">{{ categorization.capitalize() }}</a>{% endif %}{% if title %} &gt; {{ title }}{% endif %}</h1>
{% include 'nav.html' %} {% include 'nav.html' %}
<div class="card-listing"> <div class="card-listing">
{% for work in works %} {% for work in works %}
<div class="card"> <div class="card">
<a href="{{ root() }}works/{{ work['id'] }}/"> <a href="{{ root() }}/works/{{ work['id'] }}/{{ index() }}">
<img src="{{ root() }}thumbnails/{{ work['id'] }}.jpg"> <img src="{{ root() }}/thumbnails/{{ work['id'] }}.jpg">
<div class="card-authors"> <div class="card-authors">
[{% if work['circle'] %}{{ work['circle'] }}{% endif %}{% if work['circle'] and work['authors'] %} ({% endif %}{{ ', '.join(work['authors']) }}{% if work['circle'] and work['authors'] %}){% endif %}] [{% if work['circle'] %}{{ work['circle'] }}{% endif %}{% if work['circle'] and work['authors'] %} ({% endif %}{{ ', '.join(work['authors']) }}{% if work['circle'] and work['authors'] %}){% endif %}]
</div> </div>

View file

@ -1,2 +1,2 @@
{% from 'utils.html' import root with context %} {% from 'utils.html' import root, index with context %}
<div class="nav">{% for c in ['circles', 'authors', 'tags', 'series'] %}<div class="nav-item"><a href="{{ root() }}{{ c }}">{{ c.capitalize() }}</a></div>{% endfor %}</div> <div class="nav">{% for c in ['circles', 'authors', 'tags', 'series'] %}<div class="nav-item"><a href="{{ root() }}/{{ c }}/{{ index() }}">{{ c.capitalize() }}</a></div>{% endfor %}</div>

View file

@ -1,2 +1,3 @@
{% macro root() %}{% for i in range(depth) %}../{% endfor %}{% endmacro %} {% macro root() %}{% if depth == 0 %}.{% else %}..{% endif %}{{ '/..' * (depth-1) }}{% endmacro %}
{% macro index() %}{% if not noindex %}index.html{% endif %}{% endmacro %}
{% macro urlcat(s) %}{{ s | replace('/', ' ') | urlencode }}{% endmacro %} {% macro urlcat(s) %}{{ s | replace('/', ' ') | urlencode }}{% endmacro %}

View file

@ -1,20 +1,38 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% from 'utils.html' import root with context %} {% from 'utils.html' import index, root with context %}
{% block head %} {% block head %}
<link rel="stylesheet" type="text/css" href="{{ root() }}static/viewer.css"> <link rel="stylesheet" type="text/css" href="{{ root() }}/static/viewer.css">
<script> <script>
const WORK_ID = "{{ work['id'] }}"; const WORK_ID = "{{ work['id'] }}";
const INDEX = "{{ index() }}";
</script> </script>
<script src="{{ root() }}static/viewer.js"></script> <script src="{{ root() }}/static/viewer.js"></script>
{% endblock %} {% endblock %}
{% block body %} {% block body %}
<div id="viewer-images"> <div id="viewer-images">
{% for filename in images %} {% for filename in images %}
<img src="{{ root() }}images/{{ work['id'] }}/{{ filename }}" class="viewer-image"> <img src="{{ root() }}/images/{{ work['id'] }}/{{ filename }}" class="viewer-image">
{% endfor %} {% endfor %}
</div> </div>
<div id="progress"></div> <div id="progress"></div>
<div id="page-num"></div> <div id="page-num"></div>
<div id="duration"></div> <div id="duration"></div>
<div id="controls">
<div id="tap-left" class="tap">
<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='80' height='140' viewBox='-10 -70 80 140'>
<path d='M 60 -60 L 0 0 L 60 60' fill='none' stroke='#ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='10'/>
</svg>
</div>
<div id="tap-right" class="tap">
<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='80' height='140' viewBox='-10 -70 80 140'>
<path d='M 0 -60 L 60 0 L 0 60' fill='none' stroke='#ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='10'/>
</svg>
</div>
<div id="tap-back" class="tap">
<svg version='1.1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' width='140' height='80' viewBox='-70 -10 140 80'>
<path d='M -60 60 L 0 0 L 60 60' fill='none' stroke='#ffffff' stroke-linecap='round' stroke-linejoin='round' stroke-miterlimit='10' stroke-width='10'/>
</svg>
</div>
</div>
<div id="image-container"></div> <div id="image-container"></div>
{% endblock %} {% endblock %}

View file

@ -1,11 +1,11 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block body %} {% block body %}
{% from 'utils.html' import urlcat, root with context %} {% from 'utils.html' import urlcat, root, index with context %}
<h1 id="title"><a href="{{ root() }}">DL</a> &gt; {{ title }}</h1> <h1 id="title"><a href="{{ root() }}/{{ index() }}">DL</a> &gt; {{ title }}</h1>
<div class="work-container"> <div class="work-container">
<div class="work-preview"> <div class="work-preview">
<a href="view/"> <a href="view/{{ index() }}">
<img src="{{ root() }}thumbnails/{{ work['id'] }}.jpg"> <img src="{{ root() }}/thumbnails/{{ work['id'] }}.jpg">
</a> </a>
</div> </div>
<div class="work-info"> <div class="work-info">
@ -13,28 +13,29 @@
{% if work['circle'] %} {% if work['circle'] %}
<tr> <tr>
<th>Circle</th> <th>Circle</th>
<td><a class="work-info-link" href="{{ root() }}circles/{{ urlcat(work['circle']) }}">{{ work['circle'] }}</a></td> <td><a class="work-info-link" href="{{ root() }}/circles/{{ urlcat(work['circle']) }}/{{ index() }}">{{ work['circle'] }}</a></td>
</tr> </tr>
{% endif %} {% endif %}
{% if work['authors'] %} {% if work['authors'] %}
<tr> <tr>
<th>Authors</th> <th>Authors</th>
<td>{% for author in work['authors'] %}<a class="work-info-link" href="{{ root() }}authors/{{ urlcat(author) }}">{{ author }}</a> {% endfor %}</td> <td>{% for author in work['authors'] %}<a class="work-info-link" href="{{ root() }}/authors/{{ urlcat(author) }}/{{ index() }}">{{ author }}</a> {% endfor %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if work['tags'] %} {% if work['tags'] %}
<tr> <tr>
<th>Tags</th> <th>Tags</th>
<td>{% for tag in work['tags'] %}<a class="work-info-link" href="{{ root() }}tags/{{ urlcat(tag) }}">{{ tag }}</a> {% endfor %}</td> <td>{% for tag in work['tags'] %}<a class="work-info-link" href="{{ root() }}/tags/{{ urlcat(tag) }}/{{ index() }}">{{ tag }}</a> {% endfor %}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if work['series'] %} {% if work['series'] %}
<tr> <tr>
<th>Series</th> <th>Series</th>
<td><a class="work-info-link" href="{{ root() }}series/{{ urlcat(work['series']) }}">{{ work['series'] }}</a></td> <td><a class="work-info-link" href="{{ root() }}/series/{{ urlcat(work['series']) }}/{{ index() }}">{{ work['series'] }}</a></td>
</tr> </tr>
{% endif %} {% endif %}
</table> </table>
{{ work['description'] }}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -1,6 +1,9 @@
[project] [project]
name = "dlibrary" name = "dlibrary"
version = "0.1" version = "0.1"
description = "Cataloging tool and viewer for downloaded DLSite purchases"
license = {file = "LICENSE"}
authors = [{name = "xenofem"}]
dependencies = [ dependencies = [
"requests", "requests",
"PyMuPDF", "PyMuPDF",