├── .github └── workflows │ └── python-app.yml ├── .gitignore ├── Dockerfile ├── README.md ├── docker-compose.yml ├── duckduckgo_search_api ├── __init__.py └── main.py ├── pyproject.toml ├── requirements.txt ├── start.py └── tests └── test_main.py /.github/workflows/python-app.yml: -------------------------------------------------------------------------------- 1 | # This workflow will install Python dependencies, run tests and lint with a single version of Python 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions 3 | 4 | name: Python application 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | 18 | runs-on: ${{ matrix.os }} 19 | 20 | strategy: 21 | matrix: 22 | python-version: ["3.10", "3.12"] 23 | os: [ubuntu-latest, macos-latest, windows-latest] 24 | 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v3 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Install dependencies 32 | run: | 33 | python -m pip install .[dev] 34 | - name: Ruff 35 | run: | 36 | ruff check . 37 | ruff format . --check 38 | - name: Mypy 39 | run: | 40 | mypy --install-types --non-interactive . 41 | - name: Pytest 42 | run: | 43 | pytest 44 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | 162 | # vqd_cache 163 | /vqd_cache 164 | 165 | # vscode 166 | .vscode 167 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.13.3-slim-bookworm 2 | 3 | # Create appuser and switch to it 4 | RUN useradd --create-home appuser 5 | USER appuser 6 | 7 | # Include the directory where python packages are installed in the PATH 8 | ENV PATH="/home/appuser/.local/bin:${PATH}" 9 | 10 | # Set the working directory to a folder inside appuser's home directory 11 | WORKDIR /home/appuser/app 12 | 13 | # Install dependencies 14 | COPY requirements.txt . 15 | RUN pip install --no-cache-dir --user -r requirements.txt 16 | 17 | # Copy the application code 18 | COPY ./duckduckgo_search_api /home/appuser/app/duckduckgo_search_api 19 | 20 | # Run the application 21 | CMD ["uvicorn", "duckduckgo_search_api.main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "warning"] 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Duckduckgo_search_api 2 | 3 | Deploy an API that pulls data from duckduckgo search engine. 4 | 5 | ## Usage 6 | clone 7 | ```python3 8 | git clone https://github.com/deedy5/duckduckgo_search_api.git 9 | cd duckduckgo_search_api 10 | ``` 11 | [Optional] set PROXY and TIMEOUT in main.py (*example with [iproyal residential proxies](https://iproyal.com?r=residential_proxies)*) 12 | ```python3 13 | TIMEOUT = 20 14 | PROXY = "socks5://user:password@geo.iproyal.com:32325" 15 | ``` 16 | create venv and install requirements 17 | ```python3 18 | python3 -m venv .venv && source .venv/bin/activate && pip install -r requirements.txt 19 | ``` 20 | build and run using `docker-compose` 21 | ```python3 22 | docker-compose up --build 23 | ``` 24 | build and run using `docker` 25 | ```python3 26 | docker build -t ddgs . 27 | docker run -d --name ddgs -p 8000:8000 --dns 1.1.1.1 --dns 8.8.8.8 ddgs 28 | ``` 29 | **check**
30 | [http://127.0.0.1:8000/](http://127.0.0.1:8000/)
31 | [http://127.0.0.1:8000/text?q=test&max_results=5](http://127.0.0.1:8000/text?q=test&max_results=5) 32 | 33 | ## Disclaimer 34 | 35 | This library is not affiliated with DuckDuckGo and is for educational purposes only. It is not intended for commercial use or any purpose that violates DuckDuckGo's Terms of Service. By using this library, you acknowledge that you will not use it in a way that infringes on DuckDuckGo's terms. The official DuckDuckGo website can be found at https://duckduckgo.com. 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | ddgs: 3 | build: 4 | context: . 5 | command: uvicorn duckduckgo_search_api.main:app --host 0.0.0.0 --port 8000 6 | ports: 7 | - "8000:8000" 8 | dns: 9 | - 1.1.1.1 10 | - 8.8.8.8 -------------------------------------------------------------------------------- /duckduckgo_search_api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deedy5/duckduckgo_search_api/0fe816f67df0b78c12c15ee15eb2bc97b1c866ab/duckduckgo_search_api/__init__.py -------------------------------------------------------------------------------- /duckduckgo_search_api/main.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from dataclasses import dataclass 3 | from typing import Annotated, cast 4 | 5 | import uvicorn 6 | from duckduckgo_search import DDGS 7 | from litestar import Litestar, Response, get 8 | from litestar.config.compression import CompressionConfig 9 | from litestar.openapi import OpenAPIConfig, OpenAPIController 10 | from litestar.params import Parameter 11 | from litestar.status_codes import HTTP_500_INTERNAL_SERVER_ERROR 12 | 13 | __version__ = "0.10.0" 14 | 15 | 16 | TIMEOUT = 10 17 | PROXY: dict[str, str] | str | None = None 18 | 19 | 20 | @dataclass 21 | class DdgTextOut: 22 | title: str 23 | href: str 24 | body: str 25 | 26 | 27 | @dataclass 28 | class DdgImagesOut: 29 | title: str 30 | image: str 31 | thumbnail: str 32 | url: str 33 | height: int 34 | width: int 35 | source: str 36 | 37 | 38 | @dataclass 39 | class DdgVideosOut: 40 | content: str 41 | description: str 42 | duration: str 43 | embed_html: str 44 | embed_url: str 45 | images: dict[str, str] 46 | provider: str 47 | published: str 48 | publisher: str 49 | statistics: dict[str, int] 50 | title: str 51 | uploader: str 52 | 53 | 54 | @dataclass 55 | class DdgNewsOut: 56 | date: str 57 | title: str 58 | body: str 59 | url: str 60 | source: str 61 | image: str 62 | 63 | 64 | class MyOpenAPIController(OpenAPIController): 65 | path = "/" 66 | 67 | 68 | @get("/text", sync_to_thread=True) 69 | def ddg_text_search( 70 | q: Annotated[str, Parameter(description="Search query", required=True)], 71 | region: Annotated[str, Parameter(description="Region", default="wt-wt")] = "wt-wt", 72 | safesearch: Annotated[ 73 | str, 74 | Parameter( 75 | description="Safe search", 76 | default="moderate", 77 | pattern="^(off|moderate|strict)$", 78 | ), 79 | ] = "moderate", 80 | timelimit: Annotated[ 81 | str | None, 82 | Parameter(description="Time limit", default=None, pattern="^(d|w|m|y)$"), 83 | ] = None, 84 | backend: Annotated[ 85 | str, 86 | Parameter(description="Backend", default="auto", pattern="^(auto|html|lite)$"), 87 | ] = "auto", 88 | max_results: Annotated[ 89 | int | None, 90 | Parameter(title="max_results", description="Max results. Max 100", default=None), 91 | ] = None, 92 | ) -> list[DdgTextOut]: 93 | """DuckDuckGo text search. Query params: https://duckduckgo.com/params""" 94 | try: 95 | ddgs = DDGS(proxies=PROXY, timeout=TIMEOUT) 96 | results = ddgs.text( 97 | q, 98 | region=region, 99 | safesearch=safesearch, 100 | timelimit=timelimit, 101 | backend=backend, 102 | max_results=max_results, 103 | ) 104 | return cast(list[DdgTextOut], results) 105 | except Exception as ex: 106 | logging.warning(ex) 107 | return Response(status_code=HTTP_500_INTERNAL_SERVER_ERROR) # type: ignore 108 | 109 | 110 | @get("/images", sync_to_thread=True) 111 | def ddg_images_search( 112 | q: Annotated[str, Parameter(description="Search query", required=True)], 113 | region: Annotated[str, Parameter(description="Region", default="wt-wt")] = "wt-wt", 114 | safesearch: Annotated[ 115 | str, 116 | Parameter( 117 | description="Safe search", 118 | default="moderate", 119 | pattern="^(off|moderate|strict)$", 120 | ), 121 | ] = "moderate", 122 | timelimit: Annotated[ 123 | str | None, 124 | Parameter(description="Time limit", default=None, pattern="^(Day|Week|Month|Year)$"), 125 | ] = None, 126 | size: Annotated[ 127 | str | None, 128 | Parameter(description="size", default=None, pattern="^(Small|Medium|Large|Wallpaper)$"), 129 | ] = None, 130 | color: Annotated[ 131 | str | None, 132 | Parameter( 133 | description="color", 134 | default=None, 135 | pattern="^(color|Monochrome|Red|Orange|Yellow|Green|Blue|Purple|Pink|Brown|Black|Gray|Teal|White)$", 136 | ), 137 | ] = None, 138 | type_image: Annotated[ 139 | str | None, 140 | Parameter( 141 | description="type of image", 142 | default=None, 143 | pattern="^(photo|clipart|gif|transparent|line)$", 144 | ), 145 | ] = None, 146 | layout: Annotated[ 147 | str | None, 148 | Parameter(description="layout", default=None, pattern="^(Square|Tall|Wide)$"), 149 | ] = None, 150 | license_image: Annotated[ 151 | str | None, 152 | Parameter( 153 | description="""license of image: any (All Creative Commons), Public (PublicDomain), 154 | Share (Free to Share and Use), ShareCommercially (Free to Share and Use Commercially), 155 | Modify (Free to Modify, Share, and Use), ModifyCommercially (Free to Modify, Share, and 156 | Use Commercially)""", 157 | default=None, 158 | pattern="^(any|Public|Share|ShareCommercially|Modify|ModifyCommercially)$", 159 | ), 160 | ] = None, 161 | max_results: Annotated[ 162 | int | None, 163 | Parameter(title="max_results", description="Max results. Max 500", default=None), 164 | ] = None, 165 | ) -> list[DdgImagesOut]: 166 | """DuckDuckGo images search.""" 167 | try: 168 | ddgs = DDGS(proxies=PROXY, timeout=TIMEOUT) 169 | results = ddgs.images( 170 | q, 171 | region=region, 172 | safesearch=safesearch, 173 | timelimit=timelimit, 174 | size=size, 175 | color=color, 176 | type_image=type_image, 177 | layout=layout, 178 | license_image=license_image, 179 | max_results=max_results, 180 | ) 181 | return cast(list[DdgImagesOut], results) 182 | except Exception as ex: 183 | logging.warning(ex) 184 | return Response(status_code=HTTP_500_INTERNAL_SERVER_ERROR) # type: ignore 185 | 186 | 187 | @get("/videos", sync_to_thread=True) 188 | def ddg_videos_search( 189 | q: Annotated[str, Parameter(description="Search query", required=True)], 190 | region: Annotated[str, Parameter(description="Region", default="wt-wt")] = "wt-wt", 191 | safesearch: Annotated[ 192 | str, 193 | Parameter( 194 | description="Safe search", 195 | default="moderate", 196 | pattern="^(off|moderate|strict)$", 197 | ), 198 | ] = "moderate", 199 | timelimit: Annotated[ 200 | str | None, 201 | Parameter(description="Time limit", default=None, pattern="^(d|w|m)$"), 202 | ] = None, 203 | resolution: Annotated[ 204 | str | None, 205 | Parameter(description="Resolution", default=None, pattern="^(high|standard)$"), 206 | ] = None, 207 | duration: Annotated[ 208 | str | None, 209 | Parameter(description="Duration", default=None, pattern="^(short|medium|long)$"), 210 | ] = None, 211 | license_videos: Annotated[ 212 | str | None, 213 | Parameter(description="License of videos", default=None, pattern="^(creativeCommon|youtube)$"), 214 | ] = None, 215 | max_results: Annotated[ 216 | int | None, 217 | Parameter(title="max_results", description="Max results. Max 400", default=None), 218 | ] = None, 219 | ) -> list[DdgVideosOut]: 220 | """DuckDuckGo videos search.""" 221 | try: 222 | ddgs = DDGS(proxies=PROXY, timeout=TIMEOUT) 223 | results = ddgs.videos( 224 | q, 225 | region, 226 | safesearch, 227 | timelimit, 228 | resolution, 229 | duration, 230 | license_videos, 231 | max_results, 232 | ) 233 | return cast(list[DdgVideosOut], results) 234 | except Exception as ex: 235 | logging.warning(ex) 236 | return Response(status_code=HTTP_500_INTERNAL_SERVER_ERROR) # type: ignore 237 | 238 | 239 | @get("/news", sync_to_thread=True) 240 | def ddg_news_search( 241 | q: Annotated[str, Parameter(description="Search query", required=True)], 242 | region: Annotated[str, Parameter(description="Region", default="wt-wt")] = "wt-wt", 243 | safesearch: Annotated[ 244 | str, 245 | Parameter( 246 | description="Safe search", 247 | default="moderate", 248 | pattern="^(off|moderate|strict)$", 249 | ), 250 | ] = "moderate", 251 | timelimit: Annotated[ 252 | str | None, 253 | Parameter(description="Time limit", default=None, pattern="^(d|w|m)$"), 254 | ] = None, 255 | max_results: Annotated[ 256 | int | None, 257 | Parameter(title="max_results", description="Max results. Max 200", default=None), 258 | ] = None, 259 | ) -> list[DdgNewsOut]: 260 | """DuckDuckGo news search""" 261 | try: 262 | ddgs = DDGS(proxies=PROXY, timeout=TIMEOUT) 263 | results = ddgs.news( 264 | q, 265 | region, 266 | safesearch, 267 | timelimit, 268 | max_results, 269 | ) 270 | return cast(list[DdgNewsOut], results) 271 | except Exception as ex: 272 | logging.warning(ex) 273 | return Response(status_code=HTTP_500_INTERNAL_SERVER_ERROR) # type: ignore 274 | 275 | 276 | app = Litestar( 277 | route_handlers=[ 278 | ddg_text_search, 279 | ddg_images_search, 280 | ddg_videos_search, 281 | ddg_news_search, 282 | ], 283 | compression_config=CompressionConfig( 284 | backend="gzip", 285 | gzip_compress_level=1, 286 | ), 287 | openapi_config=OpenAPIConfig( 288 | title="duckduckgo_search_api", version=__version__, openapi_controller=MyOpenAPIController 289 | ), 290 | ) 291 | 292 | 293 | if __name__ == "__main__": 294 | uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info") 295 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "duckduckgo_search_api" 3 | description = "Deploy an API that pulls data from duckduckgo search engine. " 4 | readme = "README.md" 5 | requires-python = ">=3.10" 6 | license = {text = "MIT License"} 7 | keywords = ["python", "duckduckgo"] 8 | authors = [ 9 | {name = "deedy5"} 10 | ] 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "License :: OSI Approved :: MIT License", 14 | "Operating System :: OS Independent", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3 :: Only", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: Implementation :: CPython", 23 | "Topic :: Internet :: WWW/HTTP :: Indexing/Search", 24 | "Topic :: Software Development :: Libraries :: Python Modules", 25 | ] 26 | dependencies = [ 27 | "duckduckgo_search~=8.0.0", 28 | "litestar[standard]~=2.15.2" 29 | ] 30 | dynamic = ["version"] 31 | 32 | [project.urls] # Optional 33 | "Homepage" = "https://github.com/deedy5/duckduckgo_search_api" 34 | 35 | [tool.setuptools.dynamic] 36 | version = {attr = "duckduckgo_search_api.main.__version__"} 37 | 38 | [project.optional-dependencies] 39 | dev = [ 40 | "mypy>=1.15.0", 41 | "pytest>=8.3.5", 42 | "ruff>=0.11.5", 43 | ] 44 | 45 | [tool.ruff] 46 | line-length = 120 47 | exclude = ["tests"] 48 | 49 | [tool.ruff.lint] 50 | select = [ 51 | "E", # pycodestyle 52 | "F", # Pyflakes 53 | "UP", # pyupgrade 54 | "B", # flake8-bugbear 55 | "SIM", # flake8-simplify 56 | "I", # isort 57 | ] 58 | ignore = ["D100"] 59 | 60 | [tool.mypy] 61 | python_version = "3.10" 62 | ignore_missing_imports = true 63 | strict = true 64 | exclude = ["tests/", "build/"] -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | duckduckgo_search~=8.0.0 2 | litestar[standard]~=2.15.2 -------------------------------------------------------------------------------- /start.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | 3 | if __name__ == "__main__": 4 | uvicorn.run("duckduckgo_search_api.main:app", host="0.0.0.0", port=8000) 5 | -------------------------------------------------------------------------------- /tests/test_main.py: -------------------------------------------------------------------------------- 1 | from litestar.status_codes import HTTP_200_OK 2 | from litestar.testing import TestClient 3 | 4 | from duckduckgo_search_api.main import app 5 | 6 | 7 | def test_text() -> None: 8 | with TestClient(app=app) as client: 9 | response = client.get("/text?q=usa") 10 | assert response.status_code == HTTP_200_OK 11 | assert len(response.json()) > 5 12 | 13 | 14 | def test_images() -> None: 15 | with TestClient(app=app) as client: 16 | response = client.get("/images?q=usa") 17 | assert response.status_code == HTTP_200_OK 18 | assert len(response.json()) > 10 19 | 20 | 21 | def test_videos() -> None: 22 | with TestClient(app=app) as client: 23 | response = client.get("/videos?q=usa") 24 | assert response.status_code == HTTP_200_OK 25 | assert len(response.json()) > 10 26 | 27 | 28 | def test_news() -> None: 29 | with TestClient(app=app) as client: 30 | response = client.get("/news?q=usa") 31 | assert response.status_code == HTTP_200_OK 32 | assert len(response.json()) > 10 33 | --------------------------------------------------------------------------------