From 5038520e6db619ca4dd45ae77a0b65efce4cd18d Mon Sep 17 00:00:00 2001
From: retoor <retoor@molodetz.nl>
Date: Wed, 12 Feb 2025 14:50:44 +0100
Subject: [PATCH] Source code and builds.

---
 .gitignore                         |   2 +-
 dist/downie-1.0.0-py3-none-any.whl | Bin 0 -> 5368 bytes
 dist/downie-1.0.0.tar.gz           | Bin 0 -> 5138 bytes
 downie                             |   1 +
 src/downie/__init__.py             | 187 +++++++++++++++++++++++++++++
 src/downie/__main__.py             |  24 ++++
 6 files changed, 213 insertions(+), 1 deletion(-)
 create mode 100644 dist/downie-1.0.0-py3-none-any.whl
 create mode 100644 dist/downie-1.0.0.tar.gz
 create mode 120000 downie
 create mode 100644 src/downie/__init__.py
 create mode 100644 src/downie/__main__.py

diff --git a/.gitignore b/.gitignore
index 08a196e..c8b0182 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,4 @@
-downie*
+downie.*
 __pycache__/
 www*
 .venv
diff --git a/dist/downie-1.0.0-py3-none-any.whl b/dist/downie-1.0.0-py3-none-any.whl
new file mode 100644
index 0000000000000000000000000000000000000000..e98dbb2c2c52874796f9508bb56fccacffcb8486
GIT binary patch
literal 5368
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

literal 0
HcmV?d00001

diff --git a/dist/downie-1.0.0.tar.gz b/dist/downie-1.0.0.tar.gz
new file mode 100644
index 0000000000000000000000000000000000000000..ab55ea5800d39e8dcd7da1c1e6254726d02d5653
GIT binary patch
literal 5138
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

literal 0
HcmV?d00001

diff --git a/downie b/downie
new file mode 120000
index 0000000..070c65d
--- /dev/null
+++ b/downie
@@ -0,0 +1 @@
+.venv/bin/downie
\ No newline at end of file
diff --git a/src/downie/__init__.py b/src/downie/__init__.py
new file mode 100644
index 0000000..ee0cfa8
--- /dev/null
+++ b/src/downie/__init__.py
@@ -0,0 +1,187 @@
+import aiohttp
+from app.app import Application as BaseApplication
+import asyncio
+from bs4 import BeautifulSoup
+import argparse
+import pathlib
+import logging 
+import aiofiles
+logger = logging.getLogger("downie")
+
+
+class Downie(BaseApplication):
+
+
+    def __init__(self, request_concurrency_limit=500,write_concurrency_limit=10,*args, **kwargs):
+        self.base_url = None
+        self.request_concurrency_limit = 500
+        self.write_concurrency_limit = 10
+        self.semaphore_write = asyncio.Semaphore(self.write_concurrency_limit)
+        self.semaphore_request = asyncio.Semaphore(self.request_concurrency_limit)
+        self.output_dir = pathlib.Path(".")
+        self.request_count = 0
+        self.redirect_limit = 5
+        self.links = set()
+        self._http_session = None
+        super().__init__(db_path="sqlite:///downie.db",*args, **kwargs)
+        self.db.query("PRAGMA synchronous = 0")
+        self.db.query("PRAGMA use_journal_mode = 0")
+        self.db.query("PRAGMA use_wal = 1")
+
+    @property
+    def http_session(self):
+        if not self._http_session:
+            self._http_session = aiohttp.ClientSession()
+        return self._http_session
+
+    async def is_registered(self, url):
+        if url == self.base_url:
+            return False
+        return len(list(await self.find('crawl',dict(url=url)))) > 0
+
+    async def set_in_progress(self, url):
+        return await self.upsert('crawl',dict(url=url,status=1),['url'])
+    
+    async def set_done(self,url,content):
+        if not content:
+            return
+        await self.upsert('crawl',dict(url=url,status=2),['url'])
+        local_path = await self.get_local_path(url)
+        if not local_path:
+            return None
+        path = pathlib.Path(local_path)
+        try:
+            if path.parent.name.endswith(".html"):
+                path = path.parent.parent.joinpath(path.parent.name.rstrip(".html")).join_path(path.name)
+            path.parent.mkdir(exist_ok=True,parents=True)
+        except Exception as ex:
+            logger.exception(ex)
+        if not path.is_dir():
+            if not path.exists():
+                logger.debug(f"Writing new file: {local_path}.")
+                async with self.semaphore_write:
+                    try:
+                        content = content.replace(b'"' + self.base_url.encode(),b'"')
+                        content = content.replace(b"'" + self.base_url.encode(),b"'")
+                        async with aiofiles.open(path, 'wb+') as file:
+                            await file.write(content)
+                    except Exception as ex:
+                        logger.exception(ex)
+
+            else:
+                logger.debug(f"Write cancelled, file already exists: {local_path}.")
+
+    async def get_local_path(self,url):
+        if url == self.base_url:
+            return None
+        local_dir = str(self.output_dir)
+        url = url.replace(self.base_url,"")
+        if url.startswith("/"):
+            url = url.lstrip("/")
+        #url_parts = url.split("/")
+        #for x in range(0,len(url_parts)- 1):
+            #url_parts[x] = 'd_' + url_parts[x]  
+        #url = "/".join(url_parts)
+        if not "." in url.split("/")[-1]:
+            url = url + ".html"
+        return local_dir + "/" + url
+
+    async def joinurl(self, url):
+        if url.startswith("javascript:"):
+            return None
+        if url.startswith("mailto"):
+            return None
+        if url.startswith("http"):
+            if url.startswith(self.base_url):
+                return url
+            return None
+        if url.startswith("/"):
+            return self.base_url + url 
+        return self.base_url + "/" + url
+
+    async def crawl(self, url):
+        urls = set()
+        if 'search' in url:
+            return False
+        if '#' in url:
+            return False
+        logger.debug("Current url: " + url)
+        await self.register_url(url)
+        content = await self.http_get(url) 
+        await self.set_done(url, content)
+        async for link in self.get_hrefs(content):
+            if not link in self.links:
+                self.links.add(link)
+                urls.add(link)
+        tasks = []
+        for url in urls:
+            tasks.append(self.crawl(url=url))
+        await asyncio.gather(*tasks)
+
+    async def close(self):
+        if self._http_session:
+            await self._http_session.close()
+            self._http_session = None 
+
+    async def register_url(self, url):
+        if not (await self.is_registered(url)):
+            await self.insert("crawl",dict(url=url,status=0))
+
+    async def get_hrefs(self, content):
+        if not content:
+            return 
+        try:
+            soup = BeautifulSoup(content, 'html.parser')
+            links = [a['href'] for a in soup.find_all('a', href=True)]
+            for link in links:
+                if link:
+                    link = await self.joinurl(link)
+                    if link:
+                        yield link
+        except Exception as ex:
+            logger.exception(ex)
+
+    async def http_get(self, url):
+        self.request_count += 1
+        cached = await self.find('content',dict(url=url))
+        if cached:
+            logger.debug(f"Request #{self.request_count} hitted cache: {url}.")
+            return cached[0].get('data')
+        else:
+            logger.debug(f"Request #{self.request_count} is to new url: {url}.")
+
+        async with self.semaphore_request: 
+            try:        
+                for x in range(self.redirect_limit):
+                    response = await self.http_session.get(url,allow_redirects=False)
+                    if response.status in (301, 302, 303, 307, 308):
+                        url = await self.joinurl(response.headers.get("Location"))
+                        if not url:
+                            return None 
+                        continue 
+
+                    response.raise_for_status()
+                    content = await response.read()
+                    await self.upsert('content',dict(url=url,data=content),['url'])
+                    logger.debug(f"Request #{self.request_count} is written to cache: {url}.")
+                    return content 
+            except Exception as ex:
+                logger.exception(ex)
+            return None
+
+    async def run_async(self, url):
+        self.base_url = url.strip("/")
+        if not self.base_url.startswith("http"):
+            raise ValueError("Base url should start with https.")
+        self.output_dir = self.base_url[self.base_url.find("//") + 2:] 
+        try:
+            await self.crawl(self.base_url)
+        finally:
+            await self.close()
+
+    def run(self,url):
+        asyncio.run(self.run_async(url=url))
+
+
+
+
diff --git a/src/downie/__main__.py b/src/downie/__main__.py
new file mode 100644
index 0000000..dd7cf13
--- /dev/null
+++ b/src/downie/__main__.py
@@ -0,0 +1,24 @@
+import argparse
+
+from downie import Downie
+
+def main():
+    argparser = argparse.ArgumentParser()
+
+    argparser.add_argument(
+        "url",
+        type=str
+    )
+    argparser.add_argument(
+        "-c",
+        help="Concurrency",
+        default=10,
+        type=int
+    )
+
+    args = argparser.parse_args()
+    downie = Downie()
+    downie.run(url=args.url)
+
+if __name__ == '__main__':
+    main()