├── .python-version ├── demo ├── __init__.py ├── images │ ├── test01.jpg │ ├── test02.jpg │ ├── test03.jpg │ ├── test04.jpg │ ├── test05.jpg │ ├── test06.jpg │ ├── test07.jpg │ └── test08.jpg └── code │ ├── config.py │ ├── yandex.py │ ├── baidu.py │ ├── anime_trace.py │ ├── iqdb_3d.py │ ├── saucenao.py │ ├── ascii2d.py │ ├── iqdb.py │ ├── ehentai.py │ ├── tracemoe.py │ ├── google.py │ ├── lenso.py │ ├── copyseeker.py │ ├── bing.py │ ├── tineye.py │ └── google_lens.py ├── docs ├── model │ ├── bing.md │ ├── iqdb.md │ ├── baidu.md │ ├── lenso.md │ ├── ascii2d.md │ ├── ehentai.md │ ├── google.md │ ├── tineye.md │ ├── yandex.md │ ├── saucenao.md │ ├── tracemoe.md │ ├── anime-trace.md │ ├── copyseeker.md │ └── google-lens.md ├── engines │ ├── bing.md │ ├── iqdb.md │ ├── baidu.md │ ├── google.md │ ├── lenso.md │ ├── tineye.md │ ├── yandex.md │ ├── ascii2d.md │ ├── ehentai.md │ ├── saucenao.md │ ├── tracemoe.md │ ├── anime-trace.md │ ├── copyseeker.md │ └── google-lens.md ├── images │ ├── bing.png │ ├── iqdb.png │ ├── ascii2d.png │ ├── baidu.png │ ├── google.png │ ├── lenso.png │ ├── tineye.png │ ├── yandex.png │ ├── e-hentai.png │ ├── saucenao.png │ ├── tracemoe.png │ ├── copyseeker.png │ └── google-lens.png ├── stylesheets │ └── extra.css ├── zh │ ├── README.zh-CN.md │ └── index.md ├── ja │ ├── README.ja-JP.md │ └── index.md ├── ru │ ├── README.ru-RU.md │ └── index.md └── index.md ├── requirements.txt ├── tests ├── config │ └── test_config.json ├── test_iqdb.py ├── test_baidu.py ├── test_tineye.py ├── test_yandex.py ├── test_bing.py ├── test_ehentai.py ├── test_tracemoe.py ├── test_copyseeker.py ├── test_google.py ├── test_ascii2d.py ├── test_google_lens.py ├── test_saucenao.py ├── test_anime_trace.py └── conftest.py ├── typings └── pyquery │ ├── __init__.pyi │ ├── openers.pyi │ └── text.pyi ├── .pre-commit-config.yaml ├── PicImageSearch ├── constants.py ├── exceptions.py ├── __init__.py ├── engines │ ├── __init__.py │ ├── yandex.py │ ├── iqdb.py │ ├── ascii2d.py │ ├── base.py │ ├── ehentai.py │ ├── anime_trace.py │ ├── baidu.py │ ├── copyseeker.py │ └── saucenao.py ├── types.py ├── model │ ├── __init__.py │ ├── base.py │ ├── tineye.py │ ├── copyseeker.py │ ├── baidu.py │ ├── anime_trace.py │ ├── ehentai.py │ ├── yandex.py │ ├── lenso.py │ └── google.py ├── sync.py └── utils.py ├── .github ├── workflows │ ├── linter.yaml │ ├── translator.yml │ ├── release.yml │ ├── close-stale-issues.yml │ ├── python-eol-check.yml │ └── update-copyseeker-tokens.yml ├── renovate.json └── scripts │ └── python_eol_check.py ├── LICENSE ├── pyproject.toml ├── .gitignore ├── README.md └── mkdocs.yml /.python-version: -------------------------------------------------------------------------------- 1 | 3.9 -------------------------------------------------------------------------------- /demo/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/model/bing.md: -------------------------------------------------------------------------------- 1 | # Bing 2 | 3 | ::: PicImageSearch.model.bing 4 | -------------------------------------------------------------------------------- /docs/model/iqdb.md: -------------------------------------------------------------------------------- 1 | # IQDB 2 | 3 | ::: PicImageSearch.model.iqdb 4 | -------------------------------------------------------------------------------- /docs/engines/bing.md: -------------------------------------------------------------------------------- 1 | # Bing 2 | 3 | ::: PicImageSearch.engines.bing 4 | -------------------------------------------------------------------------------- /docs/engines/iqdb.md: -------------------------------------------------------------------------------- 1 | # IQDB 2 | 3 | ::: PicImageSearch.engines.iqdb 4 | -------------------------------------------------------------------------------- /docs/model/baidu.md: -------------------------------------------------------------------------------- 1 | # Baidu 2 | 3 | ::: PicImageSearch.model.baidu 4 | -------------------------------------------------------------------------------- /docs/model/lenso.md: -------------------------------------------------------------------------------- 1 | # Lenso 2 | 3 | ::: PicImageSearch.model.lenso 4 | -------------------------------------------------------------------------------- /docs/engines/baidu.md: -------------------------------------------------------------------------------- 1 | # Baidu 2 | 3 | ::: PicImageSearch.engines.baidu 4 | -------------------------------------------------------------------------------- /docs/engines/google.md: -------------------------------------------------------------------------------- 1 | # Google 2 | 3 | ::: PicImageSearch.engines.google 4 | -------------------------------------------------------------------------------- /docs/engines/lenso.md: -------------------------------------------------------------------------------- 1 | # Lenso 2 | 3 | ::: PicImageSearch.engines.lenso 4 | -------------------------------------------------------------------------------- /docs/engines/tineye.md: -------------------------------------------------------------------------------- 1 | # Tineye 2 | 3 | ::: PicImageSearch.engines.tineye 4 | -------------------------------------------------------------------------------- /docs/engines/yandex.md: -------------------------------------------------------------------------------- 1 | # Yandex 2 | 3 | ::: PicImageSearch.engines.yandex 4 | -------------------------------------------------------------------------------- /docs/model/ascii2d.md: -------------------------------------------------------------------------------- 1 | # Ascii2D 2 | 3 | ::: PicImageSearch.model.ascii2d 4 | -------------------------------------------------------------------------------- /docs/model/ehentai.md: -------------------------------------------------------------------------------- 1 | # EHentai 2 | 3 | ::: PicImageSearch.model.ehentai 4 | -------------------------------------------------------------------------------- /docs/model/google.md: -------------------------------------------------------------------------------- 1 | # Google 2 | 3 | ::: PicImageSearch.model.google 4 | -------------------------------------------------------------------------------- /docs/model/tineye.md: -------------------------------------------------------------------------------- 1 | # Tineye 2 | 3 | ::: PicImageSearch.model.tineye 4 | -------------------------------------------------------------------------------- /docs/model/yandex.md: -------------------------------------------------------------------------------- 1 | # Yandex 2 | 3 | ::: PicImageSearch.model.yandex 4 | -------------------------------------------------------------------------------- /docs/engines/ascii2d.md: -------------------------------------------------------------------------------- 1 | # Ascii2D 2 | 3 | ::: PicImageSearch.engines.ascii2d 4 | -------------------------------------------------------------------------------- /docs/engines/ehentai.md: -------------------------------------------------------------------------------- 1 | # EHentai 2 | 3 | ::: PicImageSearch.engines.ehentai 4 | -------------------------------------------------------------------------------- /docs/model/saucenao.md: -------------------------------------------------------------------------------- 1 | # SauceNAO 2 | 3 | ::: PicImageSearch.model.saucenao 4 | -------------------------------------------------------------------------------- /docs/model/tracemoe.md: -------------------------------------------------------------------------------- 1 | # TraceMoe 2 | 3 | ::: PicImageSearch.model.tracemoe 4 | -------------------------------------------------------------------------------- /docs/engines/saucenao.md: -------------------------------------------------------------------------------- 1 | # SauceNAO 2 | 3 | ::: PicImageSearch.engines.saucenao 4 | -------------------------------------------------------------------------------- /docs/engines/tracemoe.md: -------------------------------------------------------------------------------- 1 | # TraceMoe 2 | 3 | ::: PicImageSearch.engines.tracemoe 4 | -------------------------------------------------------------------------------- /docs/model/anime-trace.md: -------------------------------------------------------------------------------- 1 | # AnimeTrace 2 | 3 | ::: PicImageSearch.model.anime_trace 4 | -------------------------------------------------------------------------------- /docs/model/copyseeker.md: -------------------------------------------------------------------------------- 1 | # Copyseeker 2 | 3 | ::: PicImageSearch.model.copyseeker 4 | -------------------------------------------------------------------------------- /docs/engines/anime-trace.md: -------------------------------------------------------------------------------- 1 | # AnimeTrace 2 | 3 | ::: PicImageSearch.engines.anime_trace 4 | -------------------------------------------------------------------------------- /docs/engines/copyseeker.md: -------------------------------------------------------------------------------- 1 | # Copyseeker 2 | 3 | ::: PicImageSearch.engines.copyseeker 4 | -------------------------------------------------------------------------------- /docs/engines/google-lens.md: -------------------------------------------------------------------------------- 1 | # Google Lens 2 | 3 | ::: PicImageSearch.engines.google_lens 4 | -------------------------------------------------------------------------------- /docs/model/google-lens.md: -------------------------------------------------------------------------------- 1 | # Google Lens 2 | 3 | ::: PicImageSearch.model.google_lens 4 | -------------------------------------------------------------------------------- /docs/images/bing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/bing.png -------------------------------------------------------------------------------- /docs/images/iqdb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/iqdb.png -------------------------------------------------------------------------------- /demo/images/test01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/demo/images/test01.jpg -------------------------------------------------------------------------------- /demo/images/test02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/demo/images/test02.jpg -------------------------------------------------------------------------------- /demo/images/test03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/demo/images/test03.jpg -------------------------------------------------------------------------------- /demo/images/test04.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/demo/images/test04.jpg -------------------------------------------------------------------------------- /demo/images/test05.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/demo/images/test05.jpg -------------------------------------------------------------------------------- /demo/images/test06.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/demo/images/test06.jpg -------------------------------------------------------------------------------- /demo/images/test07.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/demo/images/test07.jpg -------------------------------------------------------------------------------- /demo/images/test08.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/demo/images/test08.jpg -------------------------------------------------------------------------------- /docs/images/ascii2d.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/ascii2d.png -------------------------------------------------------------------------------- /docs/images/baidu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/baidu.png -------------------------------------------------------------------------------- /docs/images/google.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/google.png -------------------------------------------------------------------------------- /docs/images/lenso.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/lenso.png -------------------------------------------------------------------------------- /docs/images/tineye.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/tineye.png -------------------------------------------------------------------------------- /docs/images/yandex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/yandex.png -------------------------------------------------------------------------------- /docs/images/e-hentai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/e-hentai.png -------------------------------------------------------------------------------- /docs/images/saucenao.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/saucenao.png -------------------------------------------------------------------------------- /docs/images/tracemoe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/tracemoe.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mkdocs 2 | mkdocs-material 3 | mkdocs-static-i18n[material] 4 | mkdocstrings[python] 5 | -------------------------------------------------------------------------------- /docs/images/copyseeker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/copyseeker.png -------------------------------------------------------------------------------- /docs/images/google-lens.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kitUIN/PicImageSearch/HEAD/docs/images/google-lens.png -------------------------------------------------------------------------------- /docs/stylesheets/extra.css: -------------------------------------------------------------------------------- 1 | .grid.cards { 2 | grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr)) !important; 3 | } 4 | 5 | .grid.cards img { 6 | max-width: 2rem; 7 | max-height: 2rem; 8 | } 9 | -------------------------------------------------------------------------------- /tests/config/test_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "ascii2d": { 3 | "base_url": "" 4 | }, 5 | "google": { 6 | "cookies": "" 7 | }, 8 | "saucenao": { 9 | "api_key": "a4ab3f81009b003528f7e31aed187fa32a063f58" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /typings/pyquery/__init__.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | from .pyquery import PyQuery 6 | 7 | """ 8 | This type stub file was generated by pyright. 9 | """ 10 | 11 | __all__ = ["PyQuery"] 12 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | minimum_pre_commit_version: "3.5.0" 2 | files: ^.*\.py$ 3 | repos: 4 | - repo: https://github.com/charliermarsh/ruff-pre-commit 5 | rev: 'v0.12.11' 6 | hooks: 7 | - id: ruff 8 | args: [ --fix ] 9 | - id: ruff-format 10 | -------------------------------------------------------------------------------- /typings/pyquery/openers.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | """ 6 | This type stub file was generated by pyright. 7 | """ 8 | HAS_REQUEST = ... 9 | DEFAULT_TIMEOUT = ... 10 | basestring = ... 11 | allowed_args = ... 12 | def url_opener(url, kwargs): 13 | ... 14 | 15 | -------------------------------------------------------------------------------- /PicImageSearch/constants.py: -------------------------------------------------------------------------------- 1 | COPYSEEKER_CONSTANTS = { 2 | "URL_SEARCH_TOKEN": "4043145be31d0cf2763e1df5f4053b81590b03841b", 3 | "FILE_UPLOAD_TOKEN": "4022bf0bcc5c9691f99ceeabc26c840b88d65cfe30", 4 | "SET_COOKIE_TOKEN": "00f89b2346ad5d5cf5adc804efe82908de4f1cd2c3", 5 | "GET_RESULTS_TOKEN": "409b83c0a4ce8f68cde752ec3cfd67d886cbc1a024", 6 | } 7 | -------------------------------------------------------------------------------- /PicImageSearch/exceptions.py: -------------------------------------------------------------------------------- 1 | class ParsingError(Exception): 2 | """Exception raised for errors in parsing.""" 3 | 4 | def __init__(self, message: str, engine: str, details: str = ""): 5 | self.engine: str = engine 6 | full_msg = f"[{engine}] {message}" 7 | 8 | if details: 9 | full_msg += f"\n Details: {details}" 10 | 11 | super().__init__(full_msg) 12 | -------------------------------------------------------------------------------- /PicImageSearch/__init__.py: -------------------------------------------------------------------------------- 1 | from .engines import * # noqa: F403 2 | from .network import Network 3 | 4 | __version__ = "3.12.11" 5 | 6 | __all__ = [ 7 | "AnimeTrace", 8 | "Ascii2D", 9 | "BaiDu", 10 | "Bing", 11 | "Copyseeker", 12 | "EHentai", 13 | "Google", 14 | "GoogleLens", 15 | "Iqdb", 16 | "Lenso", 17 | "Network", 18 | "SauceNAO", 19 | "Tineye", 20 | "TraceMoe", 21 | "Yandex", 22 | ] 23 | -------------------------------------------------------------------------------- /.github/workflows/linter.yaml: -------------------------------------------------------------------------------- 1 | name: Linter 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout code 12 | uses: actions/checkout@v5 13 | 14 | - name: Set up Ruff 15 | uses: astral-sh/ruff-action@v3 16 | 17 | - name: Run Ruff format 18 | run: ruff format --diff 19 | 20 | - name: Run Ruff check 21 | run: ruff check --diff 22 | -------------------------------------------------------------------------------- /typings/pyquery/text.pyi: -------------------------------------------------------------------------------- 1 | """ 2 | This type stub file was generated by pyright. 3 | """ 4 | 5 | """ 6 | This type stub file was generated by pyright. 7 | """ 8 | INLINE_TAGS = ... 9 | SEPARATORS = ... 10 | WHITESPACE_RE = ... 11 | def squash_html_whitespace(text): 12 | ... 13 | 14 | def extract_text_array(dom, squash_artifical_nl=..., strip_artifical_nl=...): 15 | ... 16 | 17 | def extract_text(dom, block_symbol=..., sep_symbol=..., squash_space=...): 18 | ... 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/translator.yml: -------------------------------------------------------------------------------- 1 | name: "translator" 2 | 3 | on: 4 | issues: 5 | types: [opened, edited] 6 | issue_comment: 7 | types: [created, edited] 8 | pull_request_target: 9 | types: [opened, edited] 10 | pull_request_review_comment: 11 | types: [created, edited] 12 | 13 | jobs: 14 | translate: 15 | permissions: 16 | issues: write 17 | discussions: write 18 | pull-requests: write 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: lizheming/github-translate-action@1.1.2 22 | env: 23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 24 | with: 25 | IS_MODIFY_TITLE: true 26 | APPEND_TRANSLATION: true 27 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Publish to PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | pypi-publish: 10 | name: upload release to PyPI 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v5 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v6 18 | with: 19 | enable-cache: true 20 | 21 | - name: Install dependencies 22 | run: uv sync --frozen --no-dev 23 | 24 | - name: Build package 25 | run: uv build 26 | 27 | - name: Publish package 28 | env: 29 | UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN }} 30 | run: uv publish 31 | -------------------------------------------------------------------------------- /PicImageSearch/engines/__init__.py: -------------------------------------------------------------------------------- 1 | from .anime_trace import AnimeTrace 2 | from .ascii2d import Ascii2D 3 | from .baidu import BaiDu 4 | from .bing import Bing 5 | from .copyseeker import Copyseeker 6 | from .ehentai import EHentai 7 | from .google import Google 8 | from .google_lens import GoogleLens 9 | from .iqdb import Iqdb 10 | from .lenso import Lenso 11 | from .saucenao import SauceNAO 12 | from .tineye import Tineye 13 | from .tracemoe import TraceMoe 14 | from .yandex import Yandex 15 | 16 | __all__ = [ 17 | "AnimeTrace", 18 | "Ascii2D", 19 | "BaiDu", 20 | "Bing", 21 | "Copyseeker", 22 | "EHentai", 23 | "Google", 24 | "GoogleLens", 25 | "Iqdb", 26 | "Lenso", 27 | "SauceNAO", 28 | "Tineye", 29 | "TraceMoe", 30 | "Yandex", 31 | ] 32 | -------------------------------------------------------------------------------- /PicImageSearch/types.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from typing import Any, Optional 4 | 5 | 6 | class DomainTag(str, Enum): 7 | """Domain tag types""" 8 | 9 | STOCK = "stock" 10 | COLLECTION = "collection" 11 | 12 | 13 | @dataclass 14 | class DomainInfo: 15 | """Domain information structure""" 16 | 17 | domain: str 18 | count: int 19 | tag: Optional[DomainTag] = None 20 | 21 | @classmethod 22 | def from_raw_data(cls, data: list[Any]) -> "DomainInfo": 23 | """Create DomainInfo from raw API response data""" 24 | domain_name = str(data[0]) 25 | count = int(data[1]) 26 | tag = DomainTag(data[2][0]) if data[2] else None 27 | return cls(domain=domain_name, count=count, tag=tag) 28 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base", "schedule:weekends"], 3 | "baseBranches": ["main"], 4 | "dependencyDashboard": false, 5 | "rangeStrategy": "bump", 6 | "enabledManagers": ["github-actions", "pep621", "pre-commit"], 7 | "pre-commit": { 8 | "enabled": true 9 | }, 10 | "packageRules": [ 11 | { 12 | "matchPackagePatterns": ["*"], 13 | "matchUpdateTypes": ["minor", "patch"], 14 | "groupName": "all non-major dependencies", 15 | "groupSlug": "all-minor-patch", 16 | "labels": ["dependencies"], 17 | "automerge": true 18 | }, 19 | { 20 | "matchPackagePatterns": ["*"], 21 | "matchUpdateTypes": ["major"], 22 | "labels": ["dependencies", "breaking"] 23 | } 24 | ], 25 | "ignoreDeps": ["python"] 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/close-stale-issues.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | 11 | jobs: 12 | close_stale_issues: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/stale@v9 16 | with: 17 | repo-token: ${{ secrets.GITHUB_TOKEN }} 18 | stale-issue-message: "This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days." 19 | close-issue-message: "This issue was closed because it has been stalled for 5 days with no activity." 20 | days-before-issue-stale: 30 21 | days-before-issue-close: 5 22 | exempt-issue-labels: "bug,pinned" 23 | -------------------------------------------------------------------------------- /demo/code/config.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | from loguru import logger 5 | 6 | USE_SIMPLE_LOGGER = False 7 | PROXIES = "http://127.0.0.1:1080" 8 | # PROXIES = None 9 | IMAGE_BASE_URL = "https://raw.githubusercontent.com/kitUIN/PicImageSearch/main/demo/images" 10 | # Note: Google search requires the `NID` cookie (when NOT logged into any Google account), expected format: `NID=...` 11 | GOOGLE_COOKIES = "" 12 | 13 | if USE_SIMPLE_LOGGER: 14 | logger.remove() 15 | logger.add(sys.stderr, format="{level: <8} {message}") # pyright: ignore[reportUnusedCallResult] 16 | 17 | 18 | def get_image_path(image_name: str) -> Path: 19 | return Path(__file__).parent.parent / "images" / image_name 20 | 21 | 22 | __all__ = ["GOOGLE_COOKIES", "IMAGE_BASE_URL", "PROXIES", "get_image_path", "logger"] 23 | -------------------------------------------------------------------------------- /tests/test_iqdb.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import Iqdb 4 | 5 | 6 | class TestIqdb: 7 | @pytest.fixture 8 | def engine(self): 9 | return Iqdb() 10 | 11 | @pytest.fixture 12 | def test_image_path(self, engine_image_path_mapping): 13 | return engine_image_path_mapping.get("iqdb") 14 | 15 | @pytest.fixture 16 | def test_image_url(self, engine_image_url_mapping): 17 | return engine_image_url_mapping.get("iqdb") 18 | 19 | @pytest.mark.asyncio 20 | @pytest.mark.vcr("iqdb_file_search.yaml") 21 | async def test_search_with_file(self, engine, test_image_path): 22 | result = await engine.search(file=test_image_path) 23 | assert len(result.raw) > 0 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.vcr("iqdb_url_search.yaml") 27 | async def test_search_with_url(self, engine, test_image_url): 28 | result = await engine.search(url=test_image_url) 29 | assert len(result.raw) > 0 30 | -------------------------------------------------------------------------------- /tests/test_baidu.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import BaiDu 4 | 5 | 6 | class TestBaiDu: 7 | @pytest.fixture 8 | def engine(self): 9 | return BaiDu() 10 | 11 | @pytest.fixture 12 | def test_image_path(self, engine_image_path_mapping): 13 | return engine_image_path_mapping.get("baidu") 14 | 15 | @pytest.fixture 16 | def test_image_url(self, engine_image_url_mapping): 17 | return engine_image_url_mapping.get("baidu") 18 | 19 | @pytest.mark.asyncio 20 | @pytest.mark.vcr("baidu_file_search.yaml") 21 | async def test_search_with_file(self, engine, test_image_path): 22 | result = await engine.search(file=test_image_path) 23 | assert len(result.raw) > 0 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.vcr("baidu_url_search.yaml") 27 | async def test_search_with_url(self, engine, test_image_url): 28 | result = await engine.search(url=test_image_url) 29 | assert len(result.raw) > 0 30 | -------------------------------------------------------------------------------- /.github/workflows/python-eol-check.yml: -------------------------------------------------------------------------------- 1 | name: Python EOL Check 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 6" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | check-python-eol: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v5 14 | 15 | - name: Install uv 16 | uses: astral-sh/setup-uv@v6 17 | with: 18 | enable-cache: true 19 | 20 | - name: Run Python EOL check script 21 | env: 22 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 23 | run: | 24 | # Extract repository name from github.repository (format: owner/repo) 25 | REPO_NAME=$(echo "${{ github.repository }}" | cut -d '/' -f 2) 26 | 27 | uv run --no-project --with "httpx,PyGithub" .github/scripts/python_eol_check.py \ 28 | --repo-owner ${{ github.repository_owner }} \ 29 | --repo-name $REPO_NAME \ 30 | --github-token ${{ secrets.GITHUB_TOKEN }} 31 | -------------------------------------------------------------------------------- /tests/test_tineye.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import Tineye 4 | 5 | 6 | class TestTineye: 7 | @pytest.fixture 8 | def engine(self): 9 | return Tineye() 10 | 11 | @pytest.fixture 12 | def test_image_path(self, engine_image_path_mapping): 13 | return engine_image_path_mapping.get("tineye") 14 | 15 | @pytest.fixture 16 | def test_image_url(self, engine_image_url_mapping): 17 | return engine_image_url_mapping.get("tineye") 18 | 19 | @pytest.mark.asyncio 20 | @pytest.mark.vcr("tineye_file_search.yaml") 21 | async def test_search_with_file(self, engine, test_image_path): 22 | result = await engine.search(file=test_image_path) 23 | assert len(result.raw) > 0 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.vcr("tineye_url_search.yaml") 27 | async def test_search_with_url(self, engine, test_image_url): 28 | result = await engine.search(url=test_image_url) 29 | assert len(result.raw) > 0 30 | -------------------------------------------------------------------------------- /tests/test_yandex.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import Yandex 4 | 5 | 6 | class TestYandex: 7 | @pytest.fixture 8 | def engine(self): 9 | return Yandex() 10 | 11 | @pytest.fixture 12 | def test_image_path(self, engine_image_path_mapping): 13 | return engine_image_path_mapping.get("yandex") 14 | 15 | @pytest.fixture 16 | def test_image_url(self, engine_image_url_mapping): 17 | return engine_image_url_mapping.get("yandex") 18 | 19 | @pytest.mark.asyncio 20 | @pytest.mark.vcr("yandex_file_search.yaml") 21 | async def test_search_with_file(self, engine, test_image_path): 22 | result = await engine.search(file=test_image_path) 23 | assert len(result.raw) > 0 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.vcr("yandex_url_search.yaml") 27 | async def test_search_with_url(self, engine, test_image_url): 28 | result = await engine.search(url=test_image_url) 29 | assert len(result.raw) > 0 30 | -------------------------------------------------------------------------------- /tests/test_bing.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import Bing 4 | 5 | 6 | class TestBing: 7 | @pytest.fixture 8 | def engine(self): 9 | return Bing() 10 | 11 | @pytest.fixture 12 | def test_image_path(self, engine_image_path_mapping): 13 | return engine_image_path_mapping.get("bing") 14 | 15 | @pytest.fixture 16 | def test_image_url(self, engine_image_url_mapping): 17 | return engine_image_url_mapping.get("bing") 18 | 19 | @pytest.mark.asyncio 20 | @pytest.mark.vcr("bing_file_search.yaml") 21 | async def test_search_with_file(self, engine, test_image_path): 22 | result = await engine.search(file=test_image_path) 23 | assert len(result.visual_search) > 0 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.vcr("bing_url_search.yaml") 27 | async def test_search_with_url(self, engine, test_image_url): 28 | result = await engine.search(url=test_image_url) 29 | assert len(result.visual_search) > 0 30 | -------------------------------------------------------------------------------- /tests/test_ehentai.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import EHentai 4 | 5 | 6 | class TestEHentai: 7 | @pytest.fixture 8 | def engine(self): 9 | return EHentai() 10 | 11 | @pytest.fixture 12 | def test_image_path(self, engine_image_path_mapping): 13 | return engine_image_path_mapping.get("ehentai") 14 | 15 | @pytest.fixture 16 | def test_image_url(self, engine_image_url_mapping): 17 | return engine_image_url_mapping.get("ehentai") 18 | 19 | @pytest.mark.asyncio 20 | @pytest.mark.vcr("ehentai_file_search.yaml") 21 | async def test_search_with_file(self, engine, test_image_path): 22 | result = await engine.search(file=test_image_path) 23 | assert len(result.raw) > 0 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.vcr("ehentai_url_search.yaml") 27 | async def test_search_with_url(self, engine, test_image_url): 28 | result = await engine.search(url=test_image_url) 29 | assert len(result.raw) > 0 30 | -------------------------------------------------------------------------------- /tests/test_tracemoe.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import TraceMoe 4 | 5 | 6 | class TestTraceMoe: 7 | @pytest.fixture 8 | def engine(self): 9 | return TraceMoe() 10 | 11 | @pytest.fixture 12 | def test_image_path(self, engine_image_path_mapping): 13 | return engine_image_path_mapping.get("tracemoe") 14 | 15 | @pytest.fixture 16 | def test_image_url(self, engine_image_url_mapping): 17 | return engine_image_url_mapping.get("tracemoe") 18 | 19 | @pytest.mark.asyncio 20 | @pytest.mark.vcr("tracemoe_file_search.yaml") 21 | async def test_search_with_file(self, engine, test_image_path): 22 | result = await engine.search(file=test_image_path) 23 | assert len(result.raw) > 0 24 | 25 | @pytest.mark.asyncio 26 | @pytest.mark.vcr("tracemoe_url_search.yaml") 27 | async def test_search_with_url(self, engine, test_image_url): 28 | result = await engine.search(url=test_image_url) 29 | assert len(result.raw) > 0 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 kitUIN 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /tests/test_copyseeker.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import Copyseeker, Network 4 | 5 | 6 | class TestCopyseeker: 7 | @pytest.fixture 8 | def test_image_path(self, engine_image_path_mapping): 9 | return engine_image_path_mapping.get("copyseeker") 10 | 11 | @pytest.fixture 12 | def test_image_url(self, engine_image_url_mapping): 13 | return engine_image_url_mapping.get("copyseeker") 14 | 15 | @pytest.mark.asyncio 16 | @pytest.mark.vcr("copyseeker_file_search.yaml") 17 | async def test_search_with_file(self, test_image_path): 18 | async with Network() as client: 19 | engine = Copyseeker(client=client) 20 | result = await engine.search(file=test_image_path) 21 | assert len(result.raw) > 0 22 | 23 | @pytest.mark.asyncio 24 | @pytest.mark.vcr("copyseeker_url_search.yaml") 25 | async def test_search_with_url(self, test_image_url): 26 | async with Network() as client: 27 | engine = Copyseeker(client=client) 28 | result = await engine.search(url=test_image_url) 29 | assert len(result.raw) > 0 30 | -------------------------------------------------------------------------------- /tests/test_google.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import Google 4 | from tests.conftest import has_google_config 5 | 6 | 7 | class TestGoogle: 8 | @pytest.fixture 9 | def engine(self, test_config): 10 | if not has_google_config(test_config): 11 | pytest.skip("Missing Google configuration") 12 | return Google(cookies=test_config.get("google", {}).get("cookies")) 13 | 14 | @pytest.fixture 15 | def test_image_path(self, engine_image_path_mapping): 16 | return engine_image_path_mapping.get("google") 17 | 18 | @pytest.fixture 19 | def test_image_url(self, engine_image_url_mapping): 20 | return engine_image_url_mapping.get("google") 21 | 22 | @pytest.mark.asyncio 23 | @pytest.mark.vcr("google_file_search.yaml") 24 | async def test_search_with_file(self, engine, test_image_path): 25 | result = await engine.search(file=test_image_path) 26 | assert len(result.raw) > 0 27 | 28 | @pytest.mark.asyncio 29 | @pytest.mark.vcr("google_url_search.yaml") 30 | async def test_search_with_url(self, engine, test_image_url): 31 | result = await engine.search(url=test_image_url) 32 | assert len(result.raw) > 0 33 | -------------------------------------------------------------------------------- /tests/test_ascii2d.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import Ascii2D 4 | from tests.conftest import has_ascii2d_config 5 | 6 | 7 | class TestAscii2D: 8 | @pytest.fixture 9 | def engine(self, test_config): 10 | if not has_ascii2d_config(test_config): 11 | pytest.skip("Missing Ascii2D configuration") 12 | return Ascii2D(base_url=test_config.get("ascii2d", {}).get("base_url")) 13 | 14 | @pytest.fixture 15 | def test_image_path(self, engine_image_path_mapping): 16 | return engine_image_path_mapping.get("ascii2d") 17 | 18 | @pytest.fixture 19 | def test_image_url(self, engine_image_url_mapping): 20 | return engine_image_url_mapping.get("ascii2d") 21 | 22 | @pytest.mark.asyncio 23 | @pytest.mark.vcr("ascii2d_file_search.yaml") 24 | async def test_search_with_file(self, engine, test_image_path): 25 | result = await engine.search(file=test_image_path) 26 | assert len(result.raw) > 0 27 | 28 | @pytest.mark.asyncio 29 | @pytest.mark.vcr("ascii2d_url_search.yaml") 30 | async def test_search_with_url(self, engine, test_image_url): 31 | result = await engine.search(url=test_image_url) 32 | assert len(result.raw) > 0 33 | -------------------------------------------------------------------------------- /tests/test_google_lens.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import GoogleLens 4 | from tests.conftest import has_google_config 5 | 6 | 7 | class TestGoogleLens: 8 | @pytest.fixture 9 | def engine(self, test_config): 10 | if not has_google_config(test_config): 11 | pytest.skip("Missing Google configuration") 12 | return GoogleLens(cookies=test_config.get("google", {}).get("cookies")) 13 | 14 | @pytest.fixture 15 | def test_image_path(self, engine_image_path_mapping): 16 | return engine_image_path_mapping.get("google") 17 | 18 | @pytest.fixture 19 | def test_image_url(self, engine_image_url_mapping): 20 | return engine_image_url_mapping.get("google") 21 | 22 | @pytest.mark.asyncio 23 | @pytest.mark.vcr("google_lens_file_search.yaml") 24 | async def test_search_with_file(self, engine, test_image_path): 25 | result = await engine.search(file=test_image_path) 26 | assert len(result.raw) > 0 27 | 28 | @pytest.mark.asyncio 29 | @pytest.mark.vcr("google_lens_url_search.yaml") 30 | async def test_search_with_url(self, engine, test_image_url): 31 | result = await engine.search(url=test_image_url) 32 | assert len(result.raw) > 0 33 | -------------------------------------------------------------------------------- /tests/test_saucenao.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from PicImageSearch import SauceNAO 4 | from tests.conftest import has_saucenao_config 5 | 6 | 7 | class TestSauceNAO: 8 | @pytest.fixture 9 | def engine(self, test_config): 10 | if not has_saucenao_config(test_config): 11 | pytest.skip("Missing SauceNAO configuration") 12 | return SauceNAO(api_key=test_config.get("saucenao", {}).get("api_key")) 13 | 14 | @pytest.fixture 15 | def test_image_path(self, engine_image_path_mapping): 16 | return engine_image_path_mapping.get("saucenao") 17 | 18 | @pytest.fixture 19 | def test_image_url(self, engine_image_url_mapping): 20 | return engine_image_url_mapping.get("saucenao") 21 | 22 | @pytest.mark.asyncio 23 | @pytest.mark.vcr("saucenao_file_search.yaml") 24 | async def test_search_with_file(self, engine, test_image_path): 25 | result = await engine.search(file=test_image_path) 26 | assert len(result.raw) > 0 27 | 28 | @pytest.mark.asyncio 29 | @pytest.mark.vcr("saucenao_url_search.yaml") 30 | async def test_search_with_url(self, engine, test_image_url): 31 | result = await engine.search(url=test_image_url) 32 | assert len(result.raw) > 0 33 | -------------------------------------------------------------------------------- /demo/code/yandex.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import Network, Yandex 5 | from PicImageSearch.model import YandexResponse 6 | from PicImageSearch.sync import Yandex as YandexSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test06.jpg" 9 | file = get_image_path("test06.jpg") 10 | 11 | 12 | @logger.catch() 13 | async def demo_async() -> None: 14 | async with Network(proxies=PROXIES) as client: 15 | yandex = Yandex(client=client) 16 | # resp = await yandex.search(url=url) 17 | resp = await yandex.search(file=file) 18 | show_result(resp) 19 | 20 | 21 | @logger.catch() 22 | def demo_sync() -> None: 23 | yandex = YandexSync(proxies=PROXIES) 24 | resp = yandex.search(url=url) 25 | # resp = yandex.search(file=file) 26 | show_result(resp) # pyright: ignore[reportArgumentType] 27 | 28 | 29 | def show_result(resp: YandexResponse) -> None: 30 | # logger.info(resp.origin) # Original data 31 | logger.info(resp.url) # Link to search results 32 | # logger.info(resp.raw[0].origin) 33 | logger.info(resp.raw[0].title) 34 | logger.info(resp.raw[0].url) 35 | logger.info(resp.raw[0].thumbnail) 36 | logger.info(resp.raw[0].source) 37 | logger.info(resp.raw[0].content) 38 | logger.info(resp.raw[0].size) 39 | logger.info("-" * 50) 40 | 41 | 42 | if __name__ == "__main__": 43 | asyncio.run(demo_async()) 44 | # demo_sync() 45 | -------------------------------------------------------------------------------- /demo/code/baidu.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, get_image_path, logger 4 | from PicImageSearch import BaiDu, Network 5 | from PicImageSearch.model import BaiDuResponse 6 | from PicImageSearch.sync import BaiDu as BaiDuSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test02.jpg" 9 | file = get_image_path("test02.jpg") 10 | 11 | 12 | @logger.catch() 13 | async def demo_async() -> None: 14 | async with Network() as client: 15 | baidu = BaiDu(client=client) 16 | # resp = await baidu.search(url=url) 17 | resp = await baidu.search(file=file) 18 | show_result(resp) 19 | 20 | 21 | @logger.catch() 22 | def demo_sync() -> None: 23 | baidu = BaiDuSync() 24 | resp = baidu.search(url=url) 25 | # resp = baidu.search(file=file) 26 | show_result(resp) # pyright: ignore[reportArgumentType] 27 | 28 | 29 | def show_result(resp: BaiDuResponse) -> None: 30 | # logger.info(resp.origin) # Original data 31 | logger.info(resp.url) # Link to search results 32 | # logger.info(resp.raw[0].origin) 33 | # logger.info(resp.raw[0].similarity) # deprecated 34 | logger.info(resp.raw[0].url) 35 | logger.info(resp.raw[0].thumbnail) 36 | 37 | if resp.exact_matches: 38 | logger.info("-" * 20) 39 | logger.info(resp.exact_matches[0].title) 40 | logger.info(resp.exact_matches[0].url) 41 | logger.info(resp.exact_matches[0].thumbnail) 42 | 43 | logger.info("-" * 50) 44 | 45 | 46 | if __name__ == "__main__": 47 | asyncio.run(demo_async()) 48 | # demo_sync() 49 | -------------------------------------------------------------------------------- /demo/code/anime_trace.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import AnimeTrace, Network 5 | from PicImageSearch.model import AnimeTraceResponse 6 | from PicImageSearch.sync import AnimeTrace as AnimeTraceSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test05.jpg" 9 | file = get_image_path("test05.jpg") 10 | 11 | 12 | @logger.catch() 13 | async def demo_async() -> None: 14 | async with Network(proxies=PROXIES) as client: 15 | anime_trace = AnimeTrace(client=client) 16 | resp = await anime_trace.search(url=url) 17 | # resp = await anime_trace.search(file=file) 18 | show_result(resp) 19 | 20 | 21 | @logger.catch() 22 | def demo_sync() -> None: 23 | anime_trace = AnimeTraceSync(proxies=PROXIES) 24 | # resp = anime_trace.search(url=url) 25 | resp = anime_trace.search(file=file) 26 | show_result(resp) # pyright: ignore[reportArgumentType] 27 | 28 | 29 | def show_result(resp: AnimeTraceResponse) -> None: 30 | # logger.info(resp.origin) 31 | logger.info(resp.code) 32 | logger.info(resp.ai) 33 | logger.info(resp.trace_id) 34 | 35 | if resp.raw: 36 | # logger.info(resp.raw[0].origin) 37 | logger.info(resp.raw[0].box) 38 | logger.info(resp.raw[0].box_id) 39 | if characters := resp.raw[0].characters: 40 | for character in characters: 41 | logger.info(f"Character Name: {character.name}") 42 | logger.info(f"From Work: {character.work}") 43 | 44 | 45 | if __name__ == "__main__": 46 | asyncio.run(demo_async()) 47 | # demo_sync() 48 | -------------------------------------------------------------------------------- /tests/test_anime_trace.py: -------------------------------------------------------------------------------- 1 | from base64 import b64encode 2 | from pathlib import Path 3 | 4 | import pytest 5 | 6 | from PicImageSearch import AnimeTrace 7 | 8 | 9 | class TestAnimeTrace: 10 | @pytest.fixture 11 | def engine(self): 12 | return AnimeTrace() 13 | 14 | @pytest.fixture 15 | def test_image_path(self, engine_image_path_mapping): 16 | return engine_image_path_mapping.get("animetrace") 17 | 18 | @pytest.fixture 19 | def test_image_url(self, engine_image_url_mapping): 20 | return engine_image_url_mapping.get("animetrace") 21 | 22 | @pytest.mark.asyncio 23 | @pytest.mark.vcr("animetrace_file_search.yaml") 24 | async def test_search_with_file(self, engine, test_image_path): 25 | result = await engine.search(file=test_image_path) 26 | assert len(result.raw) > 0 27 | 28 | item = result.raw[0] 29 | assert len(item.characters) > 0 30 | 31 | @pytest.mark.asyncio 32 | @pytest.mark.vcr("animetrace_url_search.yaml") 33 | async def test_search_with_url(self, engine, test_image_url): 34 | result = await engine.search(url=test_image_url) 35 | assert len(result.raw) > 0 36 | 37 | item = result.raw[0] 38 | assert len(item.characters) > 0 39 | 40 | @pytest.mark.asyncio 41 | @pytest.mark.vcr("animetrace_base64_search.yaml") 42 | async def test_search_with_base64(self, engine, test_image_path): 43 | content = Path(test_image_path).read_bytes() 44 | base64 = b64encode(content).decode() 45 | result = await engine.search(base64=base64) 46 | assert len(result.raw) > 0 47 | 48 | item = result.raw[0] 49 | assert len(item.characters) > 0 50 | -------------------------------------------------------------------------------- /demo/code/iqdb_3d.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import Iqdb, Network 5 | from PicImageSearch.model import IqdbResponse 6 | from PicImageSearch.sync import Iqdb as IqdbSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test04.jpg" 9 | file = get_image_path("test04.jpg") 10 | 11 | 12 | @logger.catch() 13 | async def demo_async() -> None: 14 | async with Network(proxies=PROXIES) as client: 15 | iqdb = Iqdb(is_3d=True, client=client) 16 | # resp = await iqdb.search(url=url) 17 | resp = await iqdb.search(file=file) 18 | show_result(resp) 19 | 20 | 21 | @logger.catch() 22 | def demo_sync() -> None: 23 | iqdb = IqdbSync(is_3d=True, proxies=PROXIES) 24 | resp = iqdb.search(url=url) 25 | # resp = iqdb.search(file=file) 26 | show_result(resp) # pyright: ignore[reportArgumentType] 27 | 28 | 29 | def show_result(resp: IqdbResponse) -> None: 30 | # logger.info(resp.origin) # Original Data 31 | logger.info(resp.url) # Link to search results 32 | # logger.info(resp.raw[0].origin) 33 | logger.info(f"Description: {resp.raw[0].content}") 34 | logger.info(f"Source URL: {resp.raw[0].url}") 35 | logger.info(f"Thumbnail: {resp.raw[0].thumbnail}") 36 | logger.info(f"Similarity: {resp.raw[0].similarity}") 37 | logger.info(f"Image Size: {resp.raw[0].size}") 38 | logger.info(f"Image Source: {resp.raw[0].source}") 39 | logger.info(f"Other Image Sources: {resp.raw[0].other_source}") 40 | logger.info(f"Number of Results with Lower Similarity: {len(resp.more)}") 41 | logger.info("-" * 50) 42 | 43 | 44 | if __name__ == "__main__": 45 | asyncio.run(demo_async()) 46 | # demo_sync() 47 | -------------------------------------------------------------------------------- /PicImageSearch/model/__init__.py: -------------------------------------------------------------------------------- 1 | from .anime_trace import AnimeTraceItem, AnimeTraceResponse 2 | from .ascii2d import Ascii2DItem, Ascii2DResponse 3 | from .baidu import BaiDuItem, BaiDuResponse 4 | from .bing import BingItem, BingResponse 5 | from .copyseeker import CopyseekerItem, CopyseekerResponse 6 | from .ehentai import EHentaiItem, EHentaiResponse 7 | from .google import GoogleItem, GoogleResponse 8 | from .google_lens import ( 9 | GoogleLensExactMatchesItem, 10 | GoogleLensExactMatchesResponse, 11 | GoogleLensItem, 12 | GoogleLensRelatedSearchItem, 13 | GoogleLensResponse, 14 | ) 15 | from .iqdb import IqdbItem, IqdbResponse 16 | from .lenso import LensoResponse, LensoResultItem, LensoURLItem 17 | from .saucenao import SauceNAOItem, SauceNAOResponse 18 | from .tineye import TineyeItem, TineyeResponse 19 | from .tracemoe import TraceMoeItem, TraceMoeMe, TraceMoeResponse 20 | from .yandex import YandexItem, YandexResponse 21 | 22 | __all__ = [ 23 | "AnimeTraceItem", 24 | "AnimeTraceResponse", 25 | "Ascii2DItem", 26 | "Ascii2DResponse", 27 | "BaiDuItem", 28 | "BaiDuResponse", 29 | "BingItem", 30 | "BingResponse", 31 | "CopyseekerItem", 32 | "CopyseekerResponse", 33 | "EHentaiItem", 34 | "EHentaiResponse", 35 | "GoogleItem", 36 | "GoogleResponse", 37 | "GoogleLensItem", 38 | "GoogleLensResponse", 39 | "GoogleLensExactMatchesResponse", 40 | "GoogleLensExactMatchesItem", 41 | "GoogleLensRelatedSearchItem", 42 | "IqdbItem", 43 | "IqdbResponse", 44 | "LensoResponse", 45 | "LensoResultItem", 46 | "LensoURLItem", 47 | "SauceNAOItem", 48 | "SauceNAOResponse", 49 | "TineyeItem", 50 | "TineyeResponse", 51 | "TraceMoeItem", 52 | "TraceMoeMe", 53 | "TraceMoeResponse", 54 | "YandexItem", 55 | "YandexResponse", 56 | ] 57 | -------------------------------------------------------------------------------- /demo/code/saucenao.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import Network, SauceNAO 5 | from PicImageSearch.model import SauceNAOResponse 6 | from PicImageSearch.sync import SauceNAO as SauceNAOSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test01.jpg" 9 | file = get_image_path("test01.jpg") 10 | api_key = "a4ab3f81009b003528f7e31aed187fa32a063f58" 11 | 12 | 13 | @logger.catch() 14 | async def demo_async() -> None: 15 | async with Network(proxies=PROXIES) as client: 16 | saucenao = SauceNAO(api_key=api_key, hide=3, client=client) 17 | # resp = await saucenao.search(url=url) 18 | resp = await saucenao.search(file=file) 19 | show_result(resp) 20 | 21 | 22 | @logger.catch() 23 | def demo_sync() -> None: 24 | saucenao = SauceNAOSync(api_key=api_key, hide=3, proxies=PROXIES) 25 | resp = saucenao.search(url=url) 26 | # resp = saucenao.search(file=file) 27 | show_result(resp) # pyright: ignore[reportArgumentType] 28 | 29 | 30 | def show_result(resp: SauceNAOResponse) -> None: 31 | logger.info(resp.status_code) # HTTP status 32 | logger.info(resp.origin) # Original Data 33 | logger.info(resp.url) # Link to search results 34 | logger.info(resp.raw[0].origin) 35 | logger.info(resp.long_remaining) 36 | logger.info(resp.short_remaining) 37 | logger.info(resp.raw[0].thumbnail) 38 | logger.info(resp.raw[0].similarity) 39 | logger.info(resp.raw[0].hidden) 40 | logger.info(resp.raw[0].title) 41 | logger.info(resp.raw[0].author) 42 | logger.info(resp.raw[0].author_url) 43 | logger.info(resp.raw[0].source) 44 | logger.info(resp.raw[0].url) 45 | logger.info(resp.raw[0].ext_urls) 46 | logger.info("-" * 50) 47 | 48 | 49 | if __name__ == "__main__": 50 | asyncio.run(demo_async()) 51 | # demo_sync() 52 | -------------------------------------------------------------------------------- /demo/code/ascii2d.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import Ascii2D, Network 5 | from PicImageSearch.model import Ascii2DResponse 6 | from PicImageSearch.sync import Ascii2D as Ascii2DSync 7 | 8 | base_url = "https://ascii2d.net" 9 | url = f"{IMAGE_BASE_URL}/test01.jpg" 10 | file = get_image_path("test01.jpg") 11 | bovw = False # Use feature search or not 12 | verify_ssl = True # Whether to verify SSL certificates or not 13 | 14 | 15 | @logger.catch() 16 | async def demo_async() -> None: 17 | async with Network(proxies=PROXIES, verify_ssl=verify_ssl) as client: 18 | ascii2d = Ascii2D(base_url=base_url, bovw=bovw, client=client) 19 | # resp = await ascii2d.search(url=url) 20 | resp = await ascii2d.search(file=file) 21 | show_result(resp) 22 | 23 | 24 | @logger.catch() 25 | def demo_sync() -> None: 26 | ascii2d = Ascii2DSync( 27 | base_url=base_url, 28 | bovw=bovw, 29 | proxies=PROXIES, 30 | verify_ssl=verify_ssl, 31 | ) 32 | resp = ascii2d.search(url=url) 33 | # resp = ascii2d.search(file=file) 34 | show_result(resp) # pyright: ignore[reportArgumentType] 35 | 36 | 37 | def show_result(resp: Ascii2DResponse) -> None: 38 | # logger.info(resp.origin) # Original data 39 | logger.info(resp.url) # Link to search results 40 | selected = next((i for i in resp.raw if i.title or i.url_list), resp.raw[0]) 41 | logger.info(selected.origin) 42 | logger.info(selected.thumbnail) 43 | logger.info(selected.title) 44 | logger.info(selected.author) 45 | logger.info(selected.author_url) 46 | logger.info(selected.url) 47 | logger.info(selected.url_list) 48 | logger.info(selected.hash) 49 | logger.info(selected.detail) 50 | logger.info("-" * 50) 51 | 52 | 53 | if __name__ == "__main__": 54 | asyncio.run(demo_async()) 55 | # demo_sync() 56 | -------------------------------------------------------------------------------- /demo/code/iqdb.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import Iqdb, Network 5 | from PicImageSearch.model import IqdbResponse 6 | from PicImageSearch.sync import Iqdb as IqdbSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test01.jpg" 9 | file = get_image_path("test01.jpg") 10 | 11 | 12 | @logger.catch() 13 | async def demo_async() -> None: 14 | async with Network(proxies=PROXIES) as client: 15 | iqdb = Iqdb(client=client) 16 | # resp = await iqdb.search(url=url) 17 | resp = await iqdb.search(file=file) 18 | show_result(resp) 19 | 20 | 21 | @logger.catch() 22 | def demo_sync() -> None: 23 | iqdb = IqdbSync(proxies=PROXIES) 24 | resp = iqdb.search(url=url) 25 | # resp = iqdb.search(file=file) 26 | show_result(resp) # pyright: ignore[reportArgumentType] 27 | 28 | 29 | def show_result(resp: IqdbResponse) -> None: 30 | # logger.info(resp.origin) # Original Data 31 | logger.info(resp.url) # Link to search results 32 | # logger.info(resp.raw[0].origin) 33 | logger.info(f"Description: {resp.raw[0].content}") 34 | logger.info(f"Source URL: {resp.raw[0].url}") 35 | logger.info(f"Thumbnail: {resp.raw[0].thumbnail}") 36 | logger.info(f"Similarity: {resp.raw[0].similarity}") 37 | logger.info(f"Image Size: {resp.raw[0].size}") 38 | logger.info(f"Image Source: {resp.raw[0].source}") 39 | logger.info(f"Other Image Sources: {resp.raw[0].other_source}") 40 | logger.info(f"SauceNAO Search Link: {resp.saucenao_url}") 41 | logger.info(f"Ascii2d Search Link: {resp.ascii2d_url}") 42 | logger.info(f"TinEye Search Link: {resp.tineye_url}") 43 | logger.info(f"Google Search Link: {resp.google_url}") 44 | logger.info(f"Number of Results with Lower Similarity: {len(resp.more)}") 45 | logger.info("-" * 50) 46 | 47 | 48 | if __name__ == "__main__": 49 | asyncio.run(demo_async()) 50 | # demo_sync() 51 | -------------------------------------------------------------------------------- /demo/code/ehentai.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 5 | from PicImageSearch import EHentai, Network 6 | from PicImageSearch.model import EHentaiResponse 7 | from PicImageSearch.sync import EHentai as EHentaiSync 8 | 9 | url = f"{IMAGE_BASE_URL}/test06.jpg" 10 | file = get_image_path("test06.jpg") 11 | 12 | # Note: EXHentai search requires cookies if to be used 13 | cookies: Optional[str] = None 14 | 15 | # Use EXHentai search or not, it's recommended to use bool(cookies), i.e. use EXHentai search if cookies is configured 16 | is_ex = False 17 | 18 | # Whenever possible, avoid timeouts that return an empty document 19 | timeout = 60 20 | 21 | 22 | @logger.catch() 23 | async def demo_async() -> None: 24 | async with Network(proxies=PROXIES, cookies=cookies, timeout=timeout) as client: 25 | ehentai = EHentai(is_ex=is_ex, client=client) 26 | # resp = await ehentai.search(url=url) 27 | resp = await ehentai.search(file=file) 28 | show_result(resp) 29 | 30 | 31 | @logger.catch() 32 | def demo_sync() -> None: 33 | ehentai = EHentaiSync( 34 | is_ex=is_ex, 35 | proxies=PROXIES, 36 | cookies=cookies, 37 | timeout=timeout, 38 | ) 39 | resp = ehentai.search(url=url) 40 | # resp = ehentai.search(file=file) 41 | show_result(resp) # pyright: ignore[reportArgumentType] 42 | 43 | 44 | def show_result(resp: EHentaiResponse) -> None: 45 | # logger.info(resp.origin) # Original data 46 | logger.info(resp.url) # Link to search results 47 | # logger.info(resp.raw[0].origin) 48 | logger.info(resp.raw[0].title) 49 | logger.info(resp.raw[0].url) 50 | logger.info(resp.raw[0].thumbnail) 51 | logger.info(resp.raw[0].type) 52 | logger.info(resp.raw[0].date) 53 | 54 | # It is recommended to use the Compact / Extended page layout, otherwise you will not get tags 55 | logger.info(resp.raw[0].tags) 56 | logger.info("-" * 50) 57 | 58 | 59 | if __name__ == "__main__": 60 | asyncio.run(demo_async()) 61 | # demo_sync() 62 | -------------------------------------------------------------------------------- /demo/code/tracemoe.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import Network, TraceMoe 5 | from PicImageSearch.model import TraceMoeResponse 6 | from PicImageSearch.sync import TraceMoe as TraceMoeSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test05.jpg" 9 | file = get_image_path("test05.jpg") 10 | 11 | 12 | @logger.catch() 13 | async def demo_async() -> None: 14 | async with Network(proxies=PROXIES) as client: 15 | tracemoe = TraceMoe(mute=False, size=None, client=client) 16 | # resp = await tracemoe.search(url=url) 17 | resp = await tracemoe.search(file=file) 18 | show_result(resp) 19 | 20 | 21 | @logger.catch() 22 | def demo_sync() -> None: 23 | tracemoe = TraceMoeSync(mute=False, size=None, proxies=PROXIES) 24 | resp = tracemoe.search(url=url) 25 | # resp = tracemoe.search(file=file) 26 | show_result(resp) # pyright: ignore[reportArgumentType] 27 | 28 | 29 | def show_result(resp: TraceMoeResponse) -> None: 30 | # logger.info(resp.origin) # Original Data 31 | logger.info(resp.raw[0].origin) 32 | logger.info(resp.raw[0].anime_info) 33 | logger.info(resp.frameCount) 34 | logger.info(resp.raw[0].anilist) 35 | logger.info(resp.raw[0].idMal) 36 | logger.info(resp.raw[0].title_native) 37 | logger.info(resp.raw[0].title_romaji) 38 | logger.info(resp.raw[0].title_english) 39 | logger.info(resp.raw[0].title_chinese) 40 | logger.info(resp.raw[0].synonyms) 41 | logger.info(resp.raw[0].isAdult) 42 | logger.info(resp.raw[0].type) 43 | logger.info(resp.raw[0].format) 44 | logger.info(resp.raw[0].start_date) 45 | logger.info(resp.raw[0].end_date) 46 | logger.info(resp.raw[0].cover_image) 47 | logger.info(resp.raw[0].filename) 48 | logger.info(resp.raw[0].episode) 49 | logger.info(resp.raw[0].From) 50 | logger.info(resp.raw[0].To) 51 | logger.info(resp.raw[0].similarity) 52 | logger.info(resp.raw[0].video) 53 | logger.info(resp.raw[0].image) 54 | 55 | 56 | if __name__ == "__main__": 57 | asyncio.run(demo_async()) 58 | # demo_sync() 59 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "PicImageSearch" 3 | dynamic = ["version"] 4 | description = "PicImageSearch - Aggregator for Reverse Image Search APIs" 5 | readme = "README.md" 6 | authors = [ 7 | { name = "kitUIN", email = "kulujun@gmail.com" }, 8 | ] 9 | maintainers = [ 10 | { name = "kitUIN", email = "kulujun@gmail.com" }, 11 | { name = "lleans" }, 12 | { name = "chinoll" }, 13 | { name = "NekoAria" }, 14 | ] 15 | license = { text = "MIT" } 16 | requires-python = ">=3.9" 17 | dependencies = [ 18 | "httpx[http2]>=0.28.1", 19 | "lxml>=5.4.0,<6.0.0", 20 | "pyquery>=2.0.1", 21 | ] 22 | keywords = [ 23 | "animetrace", 24 | "ascii2d", 25 | "baidu", 26 | "bing", 27 | "copyseeker", 28 | "e-hentai", 29 | "google", 30 | "google-lens", 31 | "iqdb", 32 | "saucenao", 33 | "tineye", 34 | "tracemoe", 35 | "yandex", 36 | ] 37 | 38 | [project.urls] 39 | homepage = "https://github.com/kitUIN/PicImageSearch" 40 | repository = "https://github.com/kitUIN/PicImageSearch" 41 | 42 | [project.optional-dependencies] 43 | socks = ["socksio>=1.0.0"] 44 | 45 | [dependency-groups] 46 | dev = [ 47 | "basedpyright>=1.31.3", 48 | "loguru>=0.7.3", 49 | "pytest>=8.4.1", 50 | "pytest-asyncio>=1.1.0", 51 | "pytest-vcr>=1.0.2", 52 | "pre-commit>=4.3.0", 53 | "ruff>=0.12.11", 54 | "types-lxml[basedpyright]>=2025.8.25", 55 | ] 56 | 57 | [build-system] 58 | requires = ["hatchling"] 59 | build-backend = "hatchling.build" 60 | 61 | [tool.hatch.version] 62 | path = "PicImageSearch/__init__.py" 63 | 64 | [tool.hatch.build.targets.wheel] 65 | packages = ["PicImageSearch"] 66 | 67 | [tool.basedpyright] 68 | pythonVersion = "3.9" 69 | reportAny = false 70 | reportExplicitAny = false 71 | reportUnknownArgumentType = false 72 | reportUnknownMemberType = false 73 | reportUnknownVariableType = false 74 | 75 | [tool.pytest.ini_options] 76 | asyncio_mode = "strict" 77 | asyncio_default_fixture_loop_scope = "function" 78 | 79 | [tool.ruff] 80 | target-version = "py39" 81 | line-length = 120 82 | extend-exclude = ["typings"] 83 | 84 | [tool.ruff.lint] 85 | select = ["F", "E", "W", "I", "UP"] 86 | 87 | [tool.ruff.lint.per-file-ignores] 88 | "__init__.py" = ["F401"] 89 | "*.py" = ["F405", "N813"] 90 | 91 | [tool.ruff.lint.pydocstyle] 92 | convention = "google" 93 | -------------------------------------------------------------------------------- /demo/code/google.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | from demo.code.config import GOOGLE_COOKIES, IMAGE_BASE_URL, PROXIES, get_image_path, logger 5 | from PicImageSearch import Google, Network 6 | from PicImageSearch.model import GoogleResponse 7 | from PicImageSearch.sync import Google as GoogleSync 8 | 9 | url = f"{IMAGE_BASE_URL}/test03.jpg" 10 | file = get_image_path("test03.jpg") 11 | base_url = "https://www.google.co.jp" 12 | 13 | 14 | @logger.catch() 15 | async def demo_async() -> None: 16 | async with Network(proxies=PROXIES, cookies=GOOGLE_COOKIES) as client: 17 | google = Google(base_url=base_url, client=client) 18 | # resp = await google.search(url=url) 19 | resp = await google.search(file=file) 20 | show_result(resp) 21 | resp2 = await google.next_page(resp) 22 | show_result(resp2) 23 | if resp2: 24 | resp3 = await google.pre_page(resp2) 25 | show_result(resp3) 26 | 27 | 28 | @logger.catch() 29 | def demo_sync() -> None: 30 | google = GoogleSync(base_url=base_url, proxies=PROXIES, cookies=GOOGLE_COOKIES) 31 | resp = google.search(url=url) 32 | # resp = google.search(file=file) 33 | show_result(resp) # pyright: ignore[reportArgumentType] 34 | resp2 = google.next_page(resp) # pyright: ignore[reportArgumentType] 35 | show_result(resp2) # pyright: ignore[reportArgumentType] 36 | if resp2: # pyright: ignore[reportUnnecessaryComparison] 37 | resp3 = google.pre_page(resp2) # pyright: ignore[reportArgumentType] 38 | show_result(resp3) # pyright: ignore[reportArgumentType] 39 | 40 | 41 | def show_result(resp: Optional[GoogleResponse]) -> None: 42 | if not resp or not resp.raw: 43 | return 44 | # logger.info(resp.origin) # Original Data 45 | logger.info(resp.pages) 46 | logger.info(len(resp.pages)) 47 | logger.info(resp.url) # Link to search results 48 | logger.info(resp.page_number) 49 | 50 | # try to get first result with thumbnail 51 | selected = next((i for i in resp.raw if i.thumbnail), resp.raw[0]) 52 | logger.info(selected.origin) 53 | logger.info(selected.thumbnail) 54 | logger.info(selected.title) 55 | logger.info(selected.content) 56 | logger.info(selected.url) 57 | logger.info("-" * 50) 58 | 59 | 60 | if __name__ == "__main__": 61 | asyncio.run(demo_async()) 62 | # demo_sync() 63 | -------------------------------------------------------------------------------- /demo/code/lenso.py: -------------------------------------------------------------------------------- 1 | """ 2 | Warning: The Lenso engine is deprecated as the website now uses Cloudflare turnstile protection 3 | which prevents this client from working properly. 4 | """ 5 | 6 | import asyncio 7 | 8 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 9 | from PicImageSearch import Lenso, Network 10 | from PicImageSearch.model import LensoResponse 11 | from PicImageSearch.sync import Lenso as LensoSync 12 | 13 | url = f"{IMAGE_BASE_URL}/test08.jpg" 14 | file = get_image_path("test08.jpg") 15 | 16 | 17 | @logger.catch() 18 | async def demo_async() -> None: 19 | async with Network(proxies=PROXIES) as client: 20 | lenso = Lenso(client=client) 21 | resp = await lenso.search(file=file) 22 | show_result(resp) 23 | 24 | 25 | @logger.catch() 26 | def demo_sync() -> None: 27 | lenso = LensoSync(proxies=PROXIES) 28 | resp = lenso.search(file=file) 29 | show_result(resp) # pyright: ignore[reportArgumentType] 30 | 31 | 32 | def show_result(resp: LensoResponse, search_type: str = "") -> None: 33 | logger.info(f"Search Type: {search_type}") 34 | logger.info(f"Search URL: {resp.url}") 35 | 36 | result_lists = { 37 | "duplicates": resp.duplicates, 38 | "similar": resp.similar, 39 | "places": resp.places, 40 | "related": resp.related, 41 | "people": resp.people, 42 | } 43 | 44 | for res_type, items in result_lists.items(): 45 | if items: 46 | logger.info(f"--- {res_type} Results ---") 47 | for item in items: 48 | logger.info(f" Hash: {item.hash}") 49 | logger.info(f" Similarity: {item.similarity}") 50 | logger.info(f" Thumbnail URL: {item.thumbnail}") 51 | logger.info(f" Size: {item.width}x{item.height}") 52 | logger.info(f" URL: {item.url}") 53 | logger.info(f" Title: {item.title}") 54 | 55 | if item.url_list: 56 | logger.info(" URLs:") 57 | for url_item in item.url_list: 58 | logger.info(f" > Image URL: {url_item.image_url}") 59 | logger.info(f" > Source URL: {url_item.source_url}") 60 | logger.info(f" > Title: {url_item.title}") 61 | logger.info(f" > Lang: {url_item.lang}") 62 | logger.info("-" * 20) 63 | else: 64 | logger.info(f"--- No {res_type} Results ---") 65 | 66 | logger.info("=" * 50) 67 | 68 | 69 | if __name__ == "__main__": 70 | asyncio.run(demo_async()) 71 | # demo_sync() 72 | -------------------------------------------------------------------------------- /demo/code/copyseeker.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import Copyseeker, Network 5 | from PicImageSearch.model import CopyseekerResponse 6 | from PicImageSearch.sync import Copyseeker as CopyseekerSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test05.jpg" 9 | file = get_image_path("test05.jpg") 10 | 11 | 12 | @logger.catch() 13 | async def demo_async() -> None: 14 | async with Network(proxies=PROXIES) as client: 15 | copyseeker = Copyseeker(client=client) 16 | # resp = await copyseeker.search(url=url) 17 | resp = await copyseeker.search(file=file) 18 | show_result(resp) 19 | 20 | 21 | @logger.catch() 22 | def demo_sync() -> None: 23 | # Note: Unlike other modules, Copyseeker requires special handling in sync mode 24 | # due to its multi-step API that depends on persistent cookies between requests. 25 | # 26 | # The standard syncified wrapper doesn't maintain client state properly for such APIs, 27 | # because each request may create a new client instance, losing cookie state. 28 | # 29 | # This explicit client management approach ensures cookies persist across requests. 30 | # This complexity is one reason why async methods are generally recommended over sync methods. 31 | network = Network(proxies=PROXIES) 32 | client = network.start() 33 | 34 | # Pass the client explicitly to maintain the same session across multiple requests 35 | copyseeker = CopyseekerSync(client=client) 36 | resp = copyseeker.search(url=url) 37 | # resp = copyseeker.search(file=file) 38 | 39 | # Important: We need to properly close the network client to avoid resource leaks 40 | # This step isn't necessary with context managers in async code (async with Network() as client:) 41 | network.close() 42 | 43 | show_result(resp) # pyright: ignore[reportArgumentType] 44 | 45 | 46 | def show_result(resp: CopyseekerResponse) -> None: 47 | logger.info(resp.id) 48 | logger.info(resp.image_url) 49 | logger.info(resp.best_guess_label) 50 | logger.info(resp.entities) 51 | logger.info(resp.total) 52 | logger.info(resp.exif) 53 | logger.info(resp.similar_image_urls) 54 | if resp.raw: 55 | logger.info(resp.raw[0].url) 56 | logger.info(resp.raw[0].title) 57 | logger.info(resp.raw[0].website_rank) 58 | logger.info(resp.raw[0].thumbnail) 59 | logger.info(resp.raw[0].thumbnail_list) 60 | logger.info("-" * 50) 61 | # logger.info(resp.visuallySimilarImages) 62 | 63 | 64 | if __name__ == "__main__": 65 | asyncio.run(demo_async()) 66 | # demo_sync() 67 | -------------------------------------------------------------------------------- /docs/zh/README.zh-CN.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # PicImageSearch 4 | 5 | [English](../../README.md) | 简体中文 | [Русский](../ru/README.ru-RU.md) | [日本語](../ja/README.ja-JP.md) 6 | 7 | ✨ 聚合识图引擎 用于以图搜源 ✨ 8 | 9 | 10 | license 11 | 12 | 13 | pypi 14 | 15 | python 16 | 17 | release 18 | 19 | 20 | issues 21 | 22 | 23 | 📖 文档 24 | · 25 | 🐛 提交问题 26 | 27 |
28 | 29 | ## 支持的搜索引擎 30 | 31 | | 引擎 | 网站 | 32 | |--------------------|--------------------------------------| 33 | | AnimeTrace | | 34 | | ASCII2D | | 35 | | Baidu | | 36 | | Bing | | 37 | | Copyseeker | | 38 | | E-Hentai | | 39 | | Google | | 40 | | Google Lens | | 41 | | IQDB | | 42 | | Lenso (deprecated) | | 43 | | SauceNAO | | 44 | | Tineye | | 45 | | TraceMoe | | 46 | | Yandex | | 47 | 48 | ## 使用方法 49 | 50 | 详细信息请参阅 [文档](https://pic-image-search.kituin.fun/) 或者 [示例代码](demo/code/)。 51 | `同步`请使用 `from PicImageSearch.sync import ...` 导入。 52 | `异步`请使用 `from PicImageSearch import Network,...` 导入。 53 | **推荐使用异步。** 54 | 55 | ### 安装 56 | 57 | - 需要 Python 3.9 及以上版本。 58 | - 安装命令:`pip install PicImageSearch` 59 | - 或使用清华镜像:`pip install PicImageSearch -i https://pypi.tuna.tsinghua.edu.cn/simple` 60 | 61 | ## 星标历史 62 | 63 | [![星标历史](https://starchart.cc/kitUIN/PicImageSearch.svg)](https://starchart.cc/kitUIN/PicImageSearch) 64 | -------------------------------------------------------------------------------- /docs/ja/README.ja-JP.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # PicImageSearch 4 | 5 | [English](../../README.md) | [简体中文](../zh/README.zh-CN.md) | [Русский](../ru/README.ru-RU.md) | 日本語 6 | 7 | ✨ 画像検索アグリゲーター ✨ 8 | 9 | 10 | license 11 | 12 | 13 | pypi 14 | 15 | python 16 | 17 | release 18 | 19 | 20 | issues 21 | 22 | 23 | 📖 ドキュメント 24 | · 25 | 🐛 問題を報告 26 | 27 |
28 | 29 | ## サポート 30 | 31 | | エンジン | ウェブサイト | 32 | |--------------------|--------------------------------------| 33 | | AnimeTrace | | 34 | | ASCII2D | | 35 | | Baidu | | 36 | | Bing | | 37 | | Copyseeker | | 38 | | E-Hentai | | 39 | | Google | | 40 | | Google Lens | | 41 | | IQDB | | 42 | | Lenso (deprecated) | | 43 | | SauceNAO | | 44 | | Tineye | | 45 | | TraceMoe | | 46 | | Yandex | | 47 | 48 | ## 使用方法 49 | 50 | 詳細は [ドキュメント](https://pic-image-search.kituin.fun/) または [デモコード](demo/code/) を参照してください。 51 | `同期`を使用する場合は `from PicImageSearch.sync import ...` をインポートしてください。 52 | `非同期`を使用する場合は `from PicImageSearch import Network,...` をインポートしてください。 53 | **非同期の使用を推奨します。** 54 | 55 | ### インストール 56 | 57 | - Python 3.9 以降が必要です。 58 | - インストール: `pip install PicImageSearch` 59 | - または Tsinghua ミラーを使用: `pip install PicImageSearch -i https://pypi.tuna.tsinghua.edu.cn/simple` 60 | 61 | ## スター履歴 62 | 63 | [![スター履歴](https://starchart.cc/kitUIN/PicImageSearch.svg)](https://starchart.cc/kitUIN/PicImageSearch) 64 | -------------------------------------------------------------------------------- /.github/workflows/update-copyseeker-tokens.yml: -------------------------------------------------------------------------------- 1 | name: Update Copyseeker Tokens 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * 6" 6 | workflow_dispatch: 7 | 8 | jobs: 9 | update-tokens: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v5 15 | 16 | - name: Install uv 17 | uses: astral-sh/setup-uv@v6 18 | with: 19 | enable-cache: true 20 | 21 | - name: Install dependencies 22 | run: uv sync --frozen 23 | 24 | - name: Run update script 25 | run: uv run python .github/scripts/update_copyseeker_tokens.py 26 | 27 | - name: Check for changes 28 | id: check-changes 29 | run: | 30 | uv run ruff format 31 | if [[ -n $(git status --porcelain) ]]; then 32 | echo "changes=true" >> $GITHUB_OUTPUT 33 | else 34 | echo "changes=false" >> $GITHUB_OUTPUT 35 | fi 36 | 37 | - name: Run tests 38 | if: steps.check-changes.outputs.changes == 'true' 39 | id: run-tests 40 | run: | 41 | uv run python -m pytest tests/test_copyseeker.py -v 42 | if [ $? -eq 0 ]; then 43 | echo "tests_passed=true" >> $GITHUB_OUTPUT 44 | else 45 | echo "tests_passed=false" >> $GITHUB_OUTPUT 46 | fi 47 | 48 | - name: Commit changes 49 | if: steps.check-changes.outputs.changes == 'true' && steps.run-tests.outputs.tests_passed == 'true' 50 | run: | 51 | git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" 52 | git config --local user.name "github-actions[bot]" 53 | git add PicImageSearch/constants.py 54 | git commit -m "fix(copyseeker): update the \`next-action\` tokens" 55 | 56 | - name: Create Pull Request 57 | if: steps.check-changes.outputs.changes == 'true' && steps.run-tests.outputs.tests_passed == 'true' 58 | uses: peter-evans/create-pull-request@v7 59 | with: 60 | title: "fix(copyseeker): update the `next-action` tokens" 61 | body: | 62 | This PR was automatically created by GitHub Actions to update the Copyseeker `next-action` tokens. 63 | 64 | The Copyseeker website may have updated its token values, requiring an update to our code to maintain compatibility. 65 | 66 | ✅ All tests for Copyseeker functionality have passed with the updated tokens. 67 | branch: bot/update-copyseeker-tokens 68 | base: main 69 | delete-branch: true 70 | 71 | - name: Report test failure 72 | if: steps.check-changes.outputs.changes == 'true' && steps.run-tests.outputs.tests_passed == 'false' 73 | run: | 74 | echo "::warning::Copyseeker tokens were updated but tests failed. No PR was created." 75 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Python template 3 | # Byte-compiled / optimized / DLL files 4 | __pycache__/ 5 | *.py[cod] 6 | *$py.class 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | cover/ 55 | 56 | # Translations 57 | *.mo 58 | *.pot 59 | 60 | # Django stuff: 61 | *.log 62 | local_settings.py 63 | db.sqlite3 64 | db.sqlite3-journal 65 | 66 | # Flask stuff: 67 | instance/ 68 | .webassets-cache 69 | 70 | # Scrapy stuff: 71 | .scrapy 72 | 73 | # Sphinx documentation 74 | docs/_build/ 75 | 76 | # PyBuilder 77 | .pybuilder/ 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | # For a library or package, you might want to ignore these files since the code is 89 | # intended to run in multiple environments; otherwise, check them in: 90 | # .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 100 | __pypackages__/ 101 | 102 | # Celery stuff 103 | celerybeat-schedule 104 | celerybeat.pid 105 | 106 | # SageMath parsed files 107 | *.sage.py 108 | 109 | # Environments 110 | .env 111 | .venv 112 | env/ 113 | venv/ 114 | ENV/ 115 | env.bak/ 116 | venv.bak/ 117 | 118 | # Spyder project settings 119 | .spyderproject 120 | .spyproject 121 | 122 | # Rope project settings 123 | .ropeproject 124 | 125 | # mkdocs documentation 126 | /site 127 | 128 | # mypy 129 | .mypy_cache/ 130 | .dmypy.json 131 | dmypy.json 132 | 133 | # Pyre type checker 134 | .pyre/ 135 | 136 | # pytype static type analyzer 137 | .pytype/ 138 | 139 | # Cython debug symbols 140 | cython_debug/ 141 | 142 | .idea/ 143 | node_modules 144 | .pdm-python 145 | 146 | tests/cassettes 147 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # PicImageSearch 4 | 5 | English | [简体中文](docs/zh/README.zh-CN.md) | [Русский](docs/ru/README.ru-RU.md) | [日本語](docs/ja/README.ja-JP.md) 6 | 7 | ✨ Aggregated Image Search Engine for Reverse Image Search ✨ 8 | 9 | 10 | license 11 | 12 | 13 | pypi 14 | 15 | python 16 | 17 | release 18 | 19 | 20 | issues 21 | 22 | 23 | 📖 Documentation 24 | · 25 | 🐛 Submit an Issue 26 | 27 |
28 | 29 | ## Supported Search Engines 30 | 31 | | Engine | Website | 32 | |--------------------|--------------------------------------| 33 | | AnimeTrace | | 34 | | ASCII2D | | 35 | | Baidu | | 36 | | Bing | | 37 | | Copyseeker | | 38 | | E-Hentai | | 39 | | Google | | 40 | | Google Lens | | 41 | | IQDB | | 42 | | Lenso (deprecated) | | 43 | | SauceNAO | | 44 | | Tineye | | 45 | | TraceMoe | | 46 | | Yandex | | 47 | 48 | ## Usage 49 | 50 | For detailed information, please refer to the [documentation](https://pic-image-search.kituin.fun/) or [example code](demo/code/). 51 | For `synchronous` usage, import using `from PicImageSearch.sync import ...` . 52 | For `asynchronous` usage, import using `from PicImageSearch import Network,...` . 53 | **Asynchronous usage is recommended.** 54 | 55 | ### Installation 56 | 57 | - Requires Python 3.9 and above. 58 | - Installation command: `pip install PicImageSearch` 59 | - Or use the Tsinghua mirror: `pip install PicImageSearch -i https://pypi.tuna.tsinghua.edu.cn/simple` 60 | 61 | ## Star History 62 | 63 | [![Star History](https://starchart.cc/kitUIN/PicImageSearch.svg)](https://starchart.cc/kitUIN/PicImageSearch) 64 | -------------------------------------------------------------------------------- /docs/ru/README.ru-RU.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # PicImageSearch 4 | 5 | [English](../../README.md) | [简体中文](../zh/README.zh-CN.md) | Русский | [日本語](../ja/README.ja-JP.md) 6 | 7 | ✨ Агрегатор Обратного Поиска Изображений ✨ 8 | 9 | 10 | лицензия 11 | 12 | 13 | pypi 14 | 15 | python 16 | 17 | версия 18 | 19 | 20 | проблемы 21 | 22 | 23 | 📖 Документация 24 | · 25 | 🐛 Сообщить о Проблеме 26 | 27 |
28 | 29 | ## Поддерживаемые сервисы 30 | 31 | | Поисковик | Веб-сайт | 32 | |--------------------|--------------------------------------| 33 | | AnimeTrace | | 34 | | ASCII2D | | 35 | | Baidu | | 36 | | Bing | | 37 | | Copyseeker | | 38 | | E-Hentai | | 39 | | Google | | 40 | | Google Lens | | 41 | | IQDB | | 42 | | Lenso (deprecated) | | 43 | | SauceNAO | | 44 | | Tineye | | 45 | | TraceMoe | | 46 | | Yandex | | 47 | 48 | ## Применение 49 | 50 | Подробности см. в [документации](https://pic-image-search.kituin.fun/) или в [демонстрационных примерах](demo/code/). 51 | Для `синхронного` использования импортируйте `from PicImageSearch.sync import ...` . 52 | Для `асинхронного` использования импортируйте `from PicImageSearch import Network,...` . 53 | **Рекомендуется использовать асинхронный режим.** 54 | 55 | ### Установка 56 | 57 | - Требуется Python 3.9 или более поздняя версия. 58 | - Установка: `pip install PicImageSearch` 59 | - Или используйте зеркало Tsinghua: `pip install PicImageSearch -i https://pypi.tuna.tsinghua.edu.cn/simple` 60 | 61 | ## История Звёзд 62 | 63 | [![История Звёзд](https://starchart.cc/kitUIN/PicImageSearch.svg)](https://starchart.cc/kitUIN/PicImageSearch) 64 | -------------------------------------------------------------------------------- /PicImageSearch/engines/yandex.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Optional, Union 3 | 4 | from typing_extensions import override 5 | 6 | from ..model import YandexResponse 7 | from ..utils import read_file 8 | from .base import BaseSearchEngine 9 | 10 | 11 | class Yandex(BaseSearchEngine[YandexResponse]): 12 | """API client for the Yandex reverse image search engine. 13 | 14 | This class provides an interface to perform reverse image searches using Yandex's service. 15 | It supports searching by both image URL and local image file upload. 16 | 17 | Attributes: 18 | base_url (str): The base URL for Yandex image search service. 19 | 20 | Note: 21 | - The service might be affected by regional restrictions. 22 | - Search results may vary based on the user's location and Yandex's algorithms. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | base_url: str = "https://yandex.com", 28 | **request_kwargs: Any, 29 | ): 30 | """Initializes a Yandex API client with specified configurations. 31 | 32 | Args: 33 | base_url (str): The base URL for Yandex searches. 34 | **request_kwargs (Any): Additional arguments for network requests. 35 | """ 36 | base_url = f"{base_url}/images/search" 37 | super().__init__(base_url, **request_kwargs) 38 | 39 | @override 40 | async def search( 41 | self, 42 | url: Optional[str] = None, 43 | file: Union[str, bytes, Path, None] = None, 44 | **kwargs: Any, 45 | ) -> YandexResponse: 46 | """Performs a reverse image search on Yandex. 47 | 48 | This method supports two ways of searching: 49 | 1. Search by image URL 50 | 2. Search by uploading a local image file 51 | 52 | Args: 53 | url (Optional[str]): URL of the image to search. 54 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 55 | **kwargs (Any): Additional arguments passed to the parent class. 56 | 57 | Returns: 58 | YandexResponse: An object containing: 59 | - Search results and metadata 60 | - The final search URL used by Yandex 61 | 62 | Raises: 63 | ValueError: If neither `url` nor `file` is provided. 64 | 65 | Note: 66 | - Only one of `url` or `file` should be provided. 67 | - When using file upload, the image will be sent to Yandex's servers. 68 | - The search process involves standard Yandex parameters like `rpt` and `cbir_page`. 69 | """ 70 | params = {"rpt": "imageview", "cbir_page": "sites"} 71 | 72 | if url: 73 | params["url"] = url 74 | resp = await self._send_request(method="get", params=params) 75 | elif file: 76 | files = {"upfile": read_file(file)} 77 | resp = await self._send_request( 78 | method="post", 79 | params=params, 80 | data={"prg": 1}, 81 | files=files, 82 | ) 83 | else: 84 | raise ValueError("Either 'url' or 'file' must be provided") 85 | 86 | return YandexResponse(resp.text, resp.url) 87 | -------------------------------------------------------------------------------- /PicImageSearch/model/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from typing import Any, Generic, TypeVar 3 | 4 | T = TypeVar("T") 5 | 6 | 7 | class BaseSearchItem(ABC): 8 | """Base class for search result items. 9 | 10 | This class serves as a template for individual search results from various search engines. 11 | Each search engine should implement its own subclass with specific parsing logic. 12 | 13 | Attributes: 14 | origin (Any): The raw data from the search engine. 15 | url (str): The URL of the found image or page. 16 | thumbnail (str): The URL of the thumbnail image. 17 | title (str): The title or description of the search result. 18 | similarity (float): A float value indicating the similarity score (0.0 to 100.0). 19 | """ 20 | 21 | def __init__(self, data: Any, **kwargs: Any): 22 | """Initialize a search result item. 23 | 24 | Args: 25 | data (Any): Raw data from the search engine response. 26 | **kwargs (Any): Additional keyword arguments for specific search engines. 27 | """ 28 | self.origin: Any = data 29 | self.url: str = "" 30 | self.thumbnail: str = "" 31 | self.title: str = "" 32 | self.similarity: float = 0.0 33 | self._parse_data(data, **kwargs) 34 | 35 | @abstractmethod 36 | def _parse_data(self, data: Any, **kwargs: Any) -> None: 37 | """Parse the raw search result data. 38 | 39 | This method should be implemented by subclasses to extract relevant information 40 | from the raw data and populate the instance attributes. 41 | 42 | Args: 43 | data (Any): Raw data from the search engine response. 44 | **kwargs (Any): Additional keyword arguments for specific search engines. 45 | """ 46 | pass 47 | 48 | 49 | class BaseSearchResponse(ABC, Generic[T]): 50 | """Base class for search response handling. 51 | 52 | This class serves as a template for processing and storing search results 53 | from various search engines. 54 | 55 | Attributes: 56 | origin (Any): The original response data from the search engine. 57 | url (str): The URL of the search request. 58 | raw (list[BaseSearchItem]): A list of BaseSearchItem objects representing individual search results. 59 | """ 60 | 61 | def __init__(self, resp_data: Any, resp_url: str, **kwargs: Any): 62 | """Initialize a search response. 63 | 64 | Args: 65 | resp_data (Any): Raw response data from the search engine. 66 | resp_url (str): The URL of the search request. 67 | **kwargs (Any): Additional keyword arguments for specific search engines. 68 | """ 69 | self.origin: Any = resp_data 70 | self.url: str = resp_url 71 | self.raw: list[T] = [] 72 | self._parse_response(resp_data, resp_url=resp_url, **kwargs) 73 | 74 | @abstractmethod 75 | def _parse_response(self, resp_data: Any, **kwargs: Any) -> None: 76 | """Parse the raw search response data. 77 | 78 | This method should be implemented by subclasses to process the response data 79 | and populate the raw results list. 80 | 81 | Args: 82 | resp_data (Any): Raw response data from the search engine. 83 | **kwargs (Any): Additional keyword arguments for specific search engines. 84 | """ 85 | pass 86 | -------------------------------------------------------------------------------- /PicImageSearch/sync.py: -------------------------------------------------------------------------------- 1 | """From: telethon/sync 2 | Rewrites all public asynchronous methods in the library's public interface for synchronous execution. 3 | Useful for scripts, with low runtime overhead. Ideal for synchronous calls preference over managing an event loop. 4 | 5 | Automatically wraps asynchronous methods of specified classes, enabling synchronous calls. 6 | """ 7 | 8 | import asyncio 9 | import functools 10 | import inspect 11 | from collections.abc import Coroutine 12 | from typing import Any, Callable 13 | 14 | from . import ( 15 | AnimeTrace, 16 | Ascii2D, 17 | BaiDu, 18 | Bing, 19 | Copyseeker, 20 | EHentai, 21 | Google, 22 | GoogleLens, 23 | Iqdb, 24 | Lenso, 25 | Network, 26 | SauceNAO, 27 | Tineye, 28 | TraceMoe, 29 | Yandex, 30 | ) 31 | 32 | 33 | def _syncify_wrap(class_type: type, method_name: str) -> None: 34 | """Wrap an asynchronous method of a class for synchronous calling. 35 | 36 | Creates a synchronous version of the specified asynchronous method. 37 | Checks if the event loop is running; if not, runs it until method completion. 38 | Original asynchronous method remains accessible via `__tl.sync` attribute. 39 | 40 | Args: 41 | class_type: Class with the method to wrap. 42 | method_name: Name of the asynchronous method to wrap. 43 | 44 | Returns: 45 | None: Modifies the class method in-place. 46 | """ 47 | method: Callable[..., Coroutine[None, None, Any]] = getattr(class_type, method_name) 48 | 49 | @functools.wraps(method) 50 | def syncified(*args: Any, **kwargs: Any) -> Any: 51 | coro: Coroutine[None, None, Any] = method(*args, **kwargs) 52 | try: 53 | loop = asyncio.get_event_loop() 54 | except RuntimeError: 55 | loop = asyncio.new_event_loop() 56 | asyncio.set_event_loop(loop) 57 | return coro if loop.is_running() else loop.run_until_complete(coro) 58 | 59 | setattr(syncified, "__tl.sync", method) 60 | setattr(class_type, method_name, syncified) 61 | 62 | 63 | def syncify(*classes: type) -> None: 64 | """Decorate coroutine methods of classes for synchronous execution. 65 | 66 | Iterates over classes, applying `_syncify_wrap` to coroutine methods. 67 | Enables methods to be used synchronously without managing an asyncio loop. 68 | 69 | Args: 70 | *classes: Classes to modify for synchronous coroutine method use. 71 | """ 72 | for c in classes: 73 | for name in dir(c): 74 | attr = getattr(c, name, None) 75 | if (not name.startswith("_") or name == "__call__") and inspect.iscoroutinefunction(attr): 76 | _syncify_wrap(c, name) 77 | 78 | 79 | syncify( 80 | AnimeTrace, 81 | Ascii2D, 82 | BaiDu, 83 | Bing, 84 | Copyseeker, 85 | EHentai, 86 | Google, 87 | GoogleLens, 88 | Iqdb, 89 | Lenso, 90 | Network, 91 | SauceNAO, 92 | Tineye, 93 | TraceMoe, 94 | Yandex, 95 | ) 96 | 97 | __all__ = [ 98 | "AnimeTrace", 99 | "Ascii2D", 100 | "BaiDu", 101 | "Bing", 102 | "Copyseeker", 103 | "EHentai", 104 | "Google", 105 | "GoogleLens", 106 | "Iqdb", 107 | "Lenso", 108 | "Network", 109 | "SauceNAO", 110 | "Tineye", 111 | "TraceMoe", 112 | "Yandex", 113 | ] 114 | -------------------------------------------------------------------------------- /PicImageSearch/utils.py: -------------------------------------------------------------------------------- 1 | import re 2 | from pathlib import Path 3 | from typing import Any, Optional, Union 4 | 5 | from lxml.html import fromstring 6 | from pyquery import PyQuery 7 | 8 | 9 | def deep_get(dictionary: dict[str, Any], keys: str) -> Optional[Any]: 10 | """Retrieves a value from a nested dictionary using a dot-separated string of keys. 11 | 12 | This function supports both dictionary key access and list index access: 13 | - Simple key: 'key1.key2' 14 | - List index: 'key1[0]' 15 | - Combined: 'key1[0].key2' 16 | 17 | Args: 18 | dictionary (dict[str, Any]): The nested dictionary to search in. 19 | keys (str): A dot-separated string of keys, which can include list indices in square brackets. 20 | 21 | Returns: 22 | The value if found, None if any key in the path doesn't exist or if the path is invalid. 23 | 24 | Examples: 25 | >>> data = {'a': {'b': [{'c': 1}]}} 26 | >>> deep_get(data, 'a.b[0].c') 27 | 1 28 | >>> deep_get(data, 'a.b[1]') 29 | None 30 | """ 31 | for key in keys.split("."): 32 | if list_search := re.search(r"(\S+)?\[(\d+)]", key): 33 | try: 34 | if list_search[1]: 35 | dictionary = dictionary[list_search[1]] 36 | dictionary = dictionary[int(list_search[2])] # pyright: ignore[reportArgumentType] 37 | except (KeyError, IndexError): 38 | return None 39 | else: 40 | try: 41 | dictionary = dictionary[key] 42 | except (KeyError, TypeError): 43 | return None 44 | return dictionary 45 | 46 | 47 | def read_file(file: Union[str, bytes, Path]) -> bytes: 48 | """Reads file content and returns it as bytes. 49 | 50 | This function handles different input types: 51 | - Path-like objects (str or Path) 52 | - Bytes data (returns directly) 53 | 54 | Args: 55 | file (Union[str, bytes, Path]): The input to read from. Can be: 56 | - A string path to the file 57 | - A Path object 58 | - Bytes data 59 | 60 | Returns: 61 | The file content as bytes. 62 | 63 | Raises: 64 | FileNotFoundError: If the specified file path doesn't exist. 65 | OSError: If any I/O related errors occur during file reading. 66 | 67 | Note: 68 | If the input is already bytes, it will be returned without modification. 69 | """ 70 | if isinstance(file, bytes): 71 | return file 72 | 73 | if not Path(file).exists(): 74 | raise FileNotFoundError(f"The file {file} does not exist.") 75 | 76 | try: 77 | with open(file, "rb") as f: 78 | return f.read() 79 | except OSError as e: 80 | raise OSError(f"An I/O error occurred while reading the file {file}: {e}") from e 81 | 82 | 83 | def parse_html(html: str) -> PyQuery: 84 | """Parses HTML content into a PyQuery object using UTF-8 encoding. 85 | 86 | This function creates a PyQuery object from HTML string content, 87 | ensuring proper UTF-8 encoding during parsing. 88 | 89 | Args: 90 | html (str): The HTML content to parse as a string. 91 | 92 | Returns: 93 | A PyQuery object representing the parsed HTML document. 94 | 95 | Note: 96 | Uses lxml's HTMLParser with explicit UTF-8 encoding to prevent 97 | potential character encoding issues. 98 | """ 99 | return PyQuery(fromstring(html)) 100 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: PicImageSearch 2 | site_url: https://pic-image-search.kituin.fun 3 | site_description: >- 4 | PicImageSearch is an aggregated image search engine for reverse image search. 5 | 6 | repo_url: https://github.com/kitUIN/PicImageSearch 7 | repo_name: kitUIN/PicImageSearch 8 | edit_uri: edit/main/docs/ 9 | 10 | theme: 11 | name: material 12 | icon: 13 | edit: material/pencil 14 | features: 15 | - content.action.edit 16 | - content.action.view 17 | - content.code.copy 18 | - navigation.expand 19 | - navigation.footer 20 | - navigation.top 21 | palette: 22 | - media: (prefers-color-scheme) 23 | toggle: 24 | icon: material/brightness-auto 25 | name: Switch to light mode 26 | - media: '(prefers-color-scheme: light)' 27 | scheme: default 28 | toggle: 29 | icon: material/brightness-7 30 | name: Switch to dark mode 31 | - media: '(prefers-color-scheme: dark)' 32 | scheme: slate 33 | toggle: 34 | icon: material/brightness-4 35 | name: Switch to light mode 36 | 37 | extra_css: 38 | - stylesheets/extra.css 39 | 40 | markdown_extensions: 41 | - attr_list 42 | - md_in_html 43 | 44 | nav: 45 | - Introduction: index.md 46 | - Docs: 47 | - Engines: 48 | - AnimeTrace: engines/anime-trace.md 49 | - Ascii2D: engines/ascii2d.md 50 | - Baidu: engines/baidu.md 51 | - Bing: engines/bing.md 52 | - Copyseeker: engines/copyseeker.md 53 | - EHentai: engines/ehentai.md 54 | - Google: engines/google.md 55 | - GoogleLens: engines/google-lens.md 56 | - IQDB: engines/iqdb.md 57 | - Lenso: engines/lenso.md 58 | - SauceNAO: engines/saucenao.md 59 | - Tineye: engines/tineye.md 60 | - TraceMoe: engines/tracemoe.md 61 | - Yandex: engines/yandex.md 62 | - Model: 63 | - AnimeTrace: model/anime-trace.md 64 | - Ascii2D: model/ascii2d.md 65 | - Baidu: model/baidu.md 66 | - Bing: model/bing.md 67 | - Copyseeker: model/copyseeker.md 68 | - EHentai: model/ehentai.md 69 | - Google: model/google.md 70 | - GoogleLens: model/google-lens.md 71 | - IQDB: model/iqdb.md 72 | - Lenso: model/lenso.md 73 | - SauceNAO: model/saucenao.md 74 | - Tineye: model/tineye.md 75 | - TraceMoe: model/tracemoe.md 76 | - Yandex: model/yandex.md 77 | 78 | plugins: 79 | - search 80 | - mkdocstrings: 81 | default_handler: python 82 | handlers: 83 | python: 84 | options: 85 | show_category_heading: true 86 | show_if_no_docstring: true 87 | show_root_heading: false 88 | show_source: true 89 | paths: [.] 90 | - i18n: 91 | docs_structure: folder 92 | languages: 93 | - name: English 94 | locale: en 95 | build: true 96 | default: true 97 | - name: 简体中文 98 | locale: zh 99 | build: true 100 | nav_translations: 101 | Introduction: 介绍 102 | Docs: 文档 103 | - name: Русский 104 | locale: ru 105 | build: true 106 | nav_translations: 107 | Introduction: Введение 108 | Docs: Документация 109 | - name: 日本語 110 | locale: ja 111 | build: true 112 | nav_translations: 113 | Introduction: 紹介 114 | Docs: ドキュメント 115 | 116 | watch: 117 | - PicImageSearch 118 | -------------------------------------------------------------------------------- /PicImageSearch/model/tineye.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from typing_extensions import override 4 | 5 | from ..types import DomainInfo 6 | from .base import BaseSearchItem, BaseSearchResponse 7 | 8 | 9 | class TineyeItem(BaseSearchItem): 10 | """Represents a single Tineye search result item. 11 | 12 | A class that processes and stores individual search result data from Tineye reverse image search. 13 | 14 | Attributes: 15 | thumbnail (str): URL of the thumbnail image. 16 | image_url (str): Direct URL to the full-size image. 17 | url (str): URL of the webpage where the image was found (backlink). 18 | domain (str): Domain of the webpage. 19 | size (list[int]): Dimensions of the image as [width, height]. 20 | crawl_date (str): Timestamp indicating when the image was crawled by Tineye. 21 | """ 22 | 23 | def __init__(self, data: dict[str, Any], **kwargs: Any): 24 | """Initializes a TineyeItem with data from a search result. 25 | 26 | Args: 27 | data (dict[str, Any]): A dictionary containing the search result data. 28 | """ 29 | super().__init__(data, **kwargs) 30 | 31 | @override 32 | def _parse_data(self, data: dict[str, Any], **kwargs: Any) -> None: 33 | """Parses the raw data for a single Tineye search result.""" 34 | self.thumbnail: str = data["image_url"] 35 | self.image_url: str = data["backlinks"][0]["url"] 36 | self.url: str = data["backlinks"][0]["backlink"] 37 | self.domain: str = data["domain"] 38 | self.size: list[int] = [data["width"], data["height"]] 39 | self.crawl_date: str = data["backlinks"][0]["crawl_date"] 40 | 41 | 42 | class TineyeResponse(BaseSearchResponse[TineyeItem]): 43 | """Represents a complete Tineye search response. 44 | 45 | Attributes: 46 | origin (dict): The raw JSON response data from Tineye. 47 | raw (list[TineyeItem]): List of TineyeItem objects, each representing a search result. 48 | domains (dict[str, int]): A dictionary where keys are the domains where the image was found, 49 | and values are the number of matches found on that domain. Only available after the initial search. 50 | query_hash (str): Unique identifier for the search query. Used for pagination. 51 | status_code (int): HTTP status code of the response. 52 | page_number (int): Current page number. 53 | url (str): URL of the initial search results page. 54 | """ 55 | 56 | def __init__( 57 | self, 58 | resp_data: dict[str, Any], 59 | resp_url: str, 60 | domains: list[DomainInfo], 61 | page_number: int = 1, 62 | ): 63 | """Initializes a TineyeResponse object with response data and metadata. 64 | 65 | Args: 66 | resp_data (dict[str, Any]): 67 | resp_url (str): 68 | page_number (int): 69 | """ 70 | super().__init__( 71 | resp_data, 72 | resp_url, 73 | domains=domains, 74 | page_number=page_number, 75 | ) 76 | self.domains: list[DomainInfo] = domains 77 | self.page_number: int = page_number 78 | 79 | @override 80 | def _parse_response(self, resp_data: dict[str, Any], **kwargs: Any) -> None: 81 | """Parses the raw JSON response from Tineye.""" 82 | self.query_hash: str = resp_data["query_hash"] 83 | self.status_code: int = resp_data["status_code"] 84 | self.total_pages: int = resp_data["total_pages"] 85 | matches = resp_data["matches"] 86 | self.raw: list[TineyeItem] = [TineyeItem(i) for i in matches] if matches else [] 87 | -------------------------------------------------------------------------------- /PicImageSearch/engines/iqdb.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Optional, Union 3 | 4 | from typing_extensions import override 5 | 6 | from ..model import IqdbResponse 7 | from ..utils import read_file 8 | from .base import BaseSearchEngine 9 | 10 | 11 | class Iqdb(BaseSearchEngine[IqdbResponse]): 12 | """API client for the Iqdb image search engine. 13 | 14 | A client implementation for performing reverse image searches using Iqdb's services. 15 | Supports both anime-style images (iqdb.org) and real-life images (3d.iqdb.org). 16 | 17 | Attributes: 18 | base_url (str): The base URL for Iqdb searches, determined by is_3d parameter. 19 | 20 | Note: 21 | - For anime/artwork images, uses iqdb.org 22 | - For real-life/3D images, uses 3d.iqdb.org 23 | """ 24 | 25 | def __init__( 26 | self, 27 | is_3d: bool = False, 28 | **request_kwargs: Any, 29 | ): 30 | """Initializes an Iqdb API client with request configuration. 31 | 32 | Args: 33 | is_3d (bool): If True, searches on 3d.iqdb.org for real-life images; otherwise, iqdb.org for anime images. 34 | **request_kwargs (Any): Additional arguments for network requests. 35 | """ 36 | base_url = "https://3d.iqdb.org" if is_3d else "https://iqdb.org" 37 | super().__init__(base_url, **request_kwargs) 38 | 39 | @override 40 | async def search( 41 | self, 42 | url: Optional[str] = None, 43 | file: Union[str, bytes, Path, None] = None, 44 | force_gray: bool = False, 45 | **kwargs: Any, 46 | ) -> IqdbResponse: 47 | """Performs a reverse image search on Iqdb. 48 | 49 | This method supports two ways of searching: 50 | 1. Search by image URL 51 | 2. Search by uploading a local image file 52 | 53 | Args: 54 | url (Optional[str]): URL of the image to search. 55 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 56 | force_gray (bool): If True, ignores color information during search, useful for 57 | finding similar images with different color schemes. 58 | **kwargs (Any): Additional arguments passed to the parent class. 59 | 60 | Returns: 61 | IqdbResponse: An object containing: 62 | - Search results from various supported image databases 63 | - Additional metadata about the search 64 | - The final search URL 65 | 66 | Raises: 67 | ValueError: If neither `url` nor `file` is provided. 68 | 69 | Note: 70 | - Only one of `url` or `file` should be provided. 71 | - The search behavior differs based on the is_3d parameter set during initialization: 72 | - is_3d=False: Searches anime/artwork images on iqdb.org 73 | - is_3d=True: Searches real-life images on 3d.iqdb.org 74 | - The force_gray option can help find visually similar images regardless of coloring 75 | """ 76 | data: dict[str, Any] = {} 77 | files: Optional[dict[str, Any]] = None 78 | 79 | if force_gray: 80 | data["forcegray"] = "on" 81 | 82 | if url: 83 | data["url"] = url 84 | elif file: 85 | files = {"file": read_file(file)} 86 | else: 87 | raise ValueError("Either 'url' or 'file' must be provided") 88 | 89 | resp = await self._send_request( 90 | method="post", 91 | data=data, 92 | files=files, 93 | ) 94 | 95 | return IqdbResponse(resp.text, resp.url) 96 | -------------------------------------------------------------------------------- /PicImageSearch/model/copyseeker.py: -------------------------------------------------------------------------------- 1 | from typing import Any, Optional 2 | 3 | from typing_extensions import override 4 | 5 | from .base import BaseSearchItem, BaseSearchResponse 6 | 7 | 8 | class CopyseekerItem(BaseSearchItem): 9 | """Represents a single Copyseeker search result item. 10 | 11 | A structured representation of an individual search result from Copyseeker's API. 12 | 13 | Attributes: 14 | origin (dict): The raw, unprocessed data of the search result. 15 | url (str): Direct URL to the webpage containing the matched image. 16 | title (str): Title of the webpage where the image was found. 17 | thumbnail (str): URL of the main thumbnail image. 18 | thumbnail_list (list[str]): List of URLs for additional related thumbnail images. 19 | website_rank (float): Numerical ranking score of the website (0.0 to 1.0). 20 | """ 21 | 22 | def __init__(self, data: dict[str, Any], **kwargs: Any): 23 | """Initializes a CopyseekerItem with data from a search result. 24 | 25 | Args: 26 | data (dict[str, Any]): A dictionary containing the search result data. 27 | """ 28 | super().__init__(data, **kwargs) 29 | 30 | @override 31 | def _parse_data(self, data: dict[str, Any], **kwargs: Any) -> None: 32 | self.url: str = data["url"] 33 | self.title: str = data["title"] 34 | self.thumbnail: str = data.get("mainImage", "") 35 | self.thumbnail_list: list[str] = data.get("otherImages", []) 36 | self.website_rank: float = data.get("rank", 0.0) 37 | 38 | 39 | class CopyseekerResponse(BaseSearchResponse[CopyseekerItem]): 40 | """Encapsulates a complete Copyseeker reverse image search response. 41 | 42 | Provides a structured interface to access and analyze the search results 43 | and metadata returned by Copyseeker's API. 44 | 45 | Attributes: 46 | id (str): Unique identifier for this search request. 47 | image_url (str): URL of the image that was searched. 48 | best_guess_label (Optional[str]): AI-generated label describing the image content. 49 | entities (Optional[str]): Detected objects or concepts in the image. 50 | total (int): Total number of matching results found. 51 | exif (dict[str, Any]): EXIF metadata extracted from the searched image. 52 | raw (list[CopyseekerItem]): List of individual search results, each as a CopyseekerItem. 53 | similar_image_urls (list[str]): URLs of visually similar images found. 54 | url (str): URL to view these search results on Copyseeker's website. 55 | 56 | Note: 57 | - The 'raw' attribute contains the detailed search results, each parsed into 58 | a CopyseekerItem object for easier access. 59 | - EXIF data is only available when searching with an actual image file, 60 | not when searching with an image URL. 61 | """ 62 | 63 | def __init__(self, resp_data: dict[str, Any], resp_url: str, **kwargs: Any) -> None: 64 | """Initializes with the response data. 65 | 66 | Args: 67 | resp_data (dict[str, Any]): A dictionary containing the parsed response data from Copyseeker. 68 | resp_url (str): URL to the search result page. 69 | """ 70 | super().__init__(resp_data, resp_url, **kwargs) 71 | 72 | @override 73 | def _parse_response(self, resp_data: dict[str, Any], **kwargs: Any) -> None: 74 | """Parse search response data.""" 75 | self.id: str = resp_data["id"] 76 | self.image_url: str = resp_data["imageUrl"] 77 | self.best_guess_label: Optional[str] = resp_data.get("bestGuessLabel") 78 | self.entities: Optional[str] = resp_data.get("entities") 79 | self.total: int = resp_data["totalLinksFound"] 80 | self.exif: dict[str, Any] = resp_data.get("exif", {}) 81 | self.raw: list[CopyseekerItem] = [CopyseekerItem(page) for page in resp_data.get("pages", [])] 82 | self.similar_image_urls: list[str] = resp_data.get("visuallySimilarImages", []) 83 | -------------------------------------------------------------------------------- /PicImageSearch/model/baidu.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from typing_extensions import override 4 | 5 | from ..utils import deep_get 6 | from .base import BaseSearchItem, BaseSearchResponse 7 | 8 | 9 | class BaiDuItem(BaseSearchItem): 10 | """Represents a single BaiDu search result item. 11 | 12 | A class that processes and stores individual search result data from BaiDu image search. 13 | 14 | Attributes: 15 | origin (dict): The raw, unprocessed data of the search result item. 16 | thumbnail (str): URL of the thumbnail image. 17 | url (str): URL of the webpage containing the original image. 18 | """ 19 | 20 | def __init__(self, data: dict[str, Any], **kwargs: Any) -> None: 21 | """Initialize a BaiDu search result item. 22 | 23 | Args: 24 | data (dict[str, Any]): A dictionary containing the raw search result data from BaiDu. 25 | **kwargs (Any): Additional keyword arguments passed to the parent class. 26 | """ 27 | super().__init__(data, **kwargs) 28 | 29 | @override 30 | def _parse_data(self, data: dict[str, Any], **kwargs: Any) -> None: 31 | """Parse the raw search result data into structured attributes. 32 | 33 | Args: 34 | data (dict[str, Any]): Raw dictionary data from BaiDu search result. 35 | **kwargs (Any): Additional keyword arguments (unused). 36 | 37 | Note: 38 | Some previously supported attributes have been deprecated: 39 | - similarity: Percentage of image similarity 40 | - title: Title of the source webpage 41 | """ 42 | # deprecated attributes 43 | # self.similarity: float = round(float(data["simi"]) * 100, 2) 44 | self.title: str = deep_get(data, "title[0]") or "" 45 | self.thumbnail: str = data.get("image_src") or data.get("thumbUrl") or "" 46 | self.url: str = data.get("url") or data.get("fromUrl") or "" 47 | 48 | 49 | class BaiDuResponse(BaseSearchResponse[BaiDuItem]): 50 | """Encapsulates a complete BaiDu reverse image search response. 51 | 52 | A class that handles and stores the full response from a BaiDu reverse image search, 53 | including multiple search results. 54 | 55 | Attributes: 56 | origin (dict): The complete raw response data from BaiDu. 57 | raw (list[BaiDuItem]): List of processed search results as BaiDuItem instances. 58 | exact_matches (list[BaiDuItem]): List of exact same image results as BaiDuItem instances. 59 | url (str): URL of the search results page on BaiDu. 60 | """ 61 | 62 | def __init__(self, resp_data: dict[str, Any], resp_url: str, **kwargs: Any): 63 | """Initialize a BaiDu search response. 64 | 65 | Args: 66 | resp_data (dict[str, Any]): The raw JSON response from BaiDu's API. 67 | resp_url (str): The URL of the search results page. 68 | **kwargs (Any): Additional keyword arguments passed to the parent class. 69 | """ 70 | super().__init__(resp_data, resp_url, **kwargs) 71 | 72 | @override 73 | def _parse_response(self, resp_data: dict[str, Any], **kwargs: Any) -> None: 74 | """Parse the raw response data into a list of search result items. 75 | 76 | Args: 77 | resp_data (dict[str, Any]): Raw response dictionary from BaiDu's API. 78 | **kwargs (Any): Additional keyword arguments (unused). 79 | 80 | Note: 81 | If resp_data is empty or invalid, an empty list will be returned. 82 | """ 83 | self.raw: list[BaiDuItem] = [] 84 | self.exact_matches: list[BaiDuItem] = [] 85 | 86 | # Parse same image results if available 87 | if same_data := resp_data.get("same"): 88 | if "list" in same_data: 89 | self.exact_matches.extend(BaiDuItem(i) for i in same_data["list"] if "url" in i and "image_src" in i) 90 | 91 | # Parse similar image results 92 | if data_list := deep_get(resp_data, "data.list"): 93 | self.raw.extend([BaiDuItem(i) for i in data_list]) 94 | -------------------------------------------------------------------------------- /docs/zh/index.md: -------------------------------------------------------------------------------- 1 | # PicImageSearch 2 | 3 | **聚合识图引擎 用于以图搜源** 4 | 5 | 6 | license 10 | 11 | 12 | pypi 13 | 14 | python 15 | 16 | release 20 | 21 | 22 | release 26 | 27 | 28 | ## PicImageSearch 是什么? 29 | 30 | PicImageSearch 是一个强大的 Python 图片搜索库,它整合了多个主流的图片逆向搜索引擎,为开发者提供统一且简洁的 API 接口,让图片搜索功能的开发变得更加便捷。 31 | 32 | ## 项目历程 33 | 34 | ### 项目起源 35 | 36 | 本项目最初源于我在 [OPQ](https://github.com/opq-osc/OPQ) QQ 机器人平台开发图片搜索功能时的需求。 37 | 当时发现市面上的图片搜索服务 API 较为零散,各自实现方式不一,给开发带来了诸多不便。 38 | 为了解决这个问题,我开发了 PicImageSearch,将多个优秀的搜图引擎整合到统一的接口中,大大简化了开发流程。 39 | 40 | ### 项目发展 41 | 42 | 在项目发展过程中,[Neko Aria](https://github.com/NekoAria) 在接触 [NoneBot2](https://github.com/nonebot/nonebot2) 平台后加入了开发团队。 43 | 他对代码进行了全面的重构,引入了更现代的设计理念,显著提升了项目的可维护性和可扩展性。 44 | 目前,项目由 Neko Aria 主要负责维护,持续为社区提供更好的图片搜索解决方案。 45 | 46 | ## 支持的识图引擎 47 | 48 |
49 | 50 | - ![AnimeTrace](images/anime-trace.png){ .lg .middle } AnimeTrace 51 | 52 | --- 53 | 54 | 以图识番 - 在线 AI 识番引擎 | 日漫识别 | 动漫查询 | 动漫基因库 55 | 56 | - ![Ascii2D](images/ascii2d.png){ .lg .middle } Ascii2D 57 | 58 | --- 59 | 60 | 二次元画像詳細検索 61 | 62 | - ![BaiDu](images/baidu.png){ .lg .middle } BaiDu 63 | 64 | --- 65 | 66 | 百度图片 67 | 68 | - ![Bing](images/bing.png){ .lg .middle } Bing 69 | 70 | --- 71 | 72 | Bing Images 73 | 74 | - ![Copyseeker](images/copyseeker.png){ .lg .middle } Copyseeker 75 | 76 | --- 77 | 78 | The Best Free AI Powered Reverse Image Search Like No Other 79 | 80 | - ![E-hentai](images/e-hentai.png){ .lg .middle } E-hentai 81 | 82 | --- 83 | 84 | E-Hentai Galleries 85 | 86 | - ![Google](images/google.png){ .lg .middle } Google 87 | 88 | --- 89 | 90 | Google Images 91 | 92 | - ![Google Lens](images/google-lens.png){ .lg .middle } Google Lens 93 | 94 | --- 95 | 96 | Google Lens 97 | 98 | - ![Iqdb](images/iqdb.png){ .lg .middle } Iqdb 99 | 100 | --- 101 | 102 | Multi-service image search 103 | 104 | - ![Lenso](images/lenso.png){ .lg .middle } Lenso 105 | 106 | --- 107 | 108 | Lenso.ai - AI Reverse Image Search 109 | 110 | - ![SauceNAO](images/saucenao.png){ .lg .middle } SauceNAO 111 | 112 | --- 113 | 114 | SauceNAO Reverse Image Search 115 | 116 | - ![Tineye](images/tineye.png){ .lg .middle } Tineye 117 | 118 | --- 119 | 120 | TinEye Reverse Image Search 121 | 122 | - ![TraceMoe](images/tracemoe.png){ .lg .middle } TraceMoe 123 | 124 | --- 125 | 126 | Anime Scene Search Engine 127 | 128 | - ![Yandex](images/yandex.png){ .lg .middle } Yandex 129 | 130 | --- 131 | 132 | Yandex Images 133 | 134 |
135 | 136 | ## 项目贡献者 137 | 138 |
139 | 140 | - ![Neko Aria](https://github.com/NekoAria.png){ .lg .middle } Neko Aria 141 | 142 | --- 143 | 144 | 项目维护者 145 | 146 | - ![kitUIN](https://github.com/kitUIN.png){ .lg .middle } kitUIN 147 | 148 | --- 149 | 150 | 项目创建者 151 | 152 | - ![Peloxerat](https://github.com/Peloxerat.png){ .lg .middle } Peloxerat 153 | 154 | --- 155 | 156 | 项目贡献者 157 | 158 | - ![lleans](https://github.com/lleans.png){ .lg .middle } lleans 159 | 160 | --- 161 | 162 | 项目贡献者 163 | 164 | - ![chinoll](https://github.com/chinoll.png){ .lg .middle } chinoll 165 | 166 | --- 167 | 168 | 项目贡献者 169 | 170 | - ![Nachtalb](https://github.com/Nachtalb.png){ .lg .middle } Nachtalb 171 | 172 | --- 173 | 174 | 项目贡献者 175 | 176 |
177 | -------------------------------------------------------------------------------- /PicImageSearch/engines/ascii2d.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Optional, Union 3 | 4 | from typing_extensions import override 5 | 6 | from ..model import Ascii2DResponse 7 | from ..utils import read_file 8 | from .base import BaseSearchEngine 9 | 10 | 11 | class Ascii2D(BaseSearchEngine[Ascii2DResponse]): 12 | """API client for the Ascii2D image search engine. 13 | 14 | Ascii2D provides two search modes: 15 | 1. Color search: Finds images with similar color combinations (default mode) 16 | 2. Feature search: Finds images with similar visual features (bovw mode) 17 | 18 | Attributes: 19 | base_url (str): The base URL for Ascii2D searches. 20 | bovw (bool): A flag to enable feature search mode. 21 | 22 | Note: 23 | - Color search (bovw=False) is recommended for finding visually similar images 24 | - Feature search (bovw=True) is better for: 25 | * Cropped images 26 | * Rotated images 27 | * Images with different color schemes 28 | - Feature search may be less accurate with heavily modified images 29 | """ 30 | 31 | def __init__( 32 | self, 33 | base_url: str = "https://ascii2d.net", 34 | bovw: bool = False, 35 | **request_kwargs: Any, 36 | ): 37 | """Initializes an Ascii2D API client with specified configurations. 38 | 39 | Args: 40 | base_url (str): The base URL for Ascii2D searches. 41 | bovw (bool): If True, use feature search; otherwise, use color combination search. 42 | **request_kwargs (Any): Additional arguments for network requests. 43 | """ 44 | base_url = f"{base_url}/search" 45 | super().__init__(base_url, **request_kwargs) 46 | self.bovw: bool = bovw 47 | 48 | @override 49 | async def search( 50 | self, 51 | url: Optional[str] = None, 52 | file: Union[str, bytes, Path, None] = None, 53 | **kwargs: Any, 54 | ) -> Ascii2DResponse: 55 | """Performs a reverse image search on Ascii2D. 56 | 57 | This method supports two ways of searching: 58 | 1. Search by image URL 59 | 2. Search by uploading a local image file 60 | 61 | The search process involves: 62 | 1. Initial submission of the image (URL or file) 63 | 2. Optional switch to feature search mode if bovw=True 64 | 3. Parsing and returning the search results 65 | 66 | Args: 67 | url (Optional[str]): URL of the image to search. 68 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 69 | **kwargs (Any): Additional arguments passed to the parent class. 70 | 71 | Returns: 72 | Ascii2DResponse: An object containing: 73 | - Search results with similar images 74 | - Source information and metadata 75 | - The final search URL 76 | 77 | Raises: 78 | ValueError: If neither `url` nor `file` is provided. 79 | 80 | Note: 81 | - Only one of `url` or `file` should be provided 82 | - Feature search (bovw) may take longer to process 83 | """ 84 | data: Optional[dict[str, Any]] = None 85 | files: Optional[dict[str, Any]] = None 86 | endpoint: str = "uri" if url else "file" 87 | 88 | if url: 89 | data = {"uri": url} 90 | elif file: 91 | files = {"file": read_file(file)} 92 | else: 93 | raise ValueError("Either 'url' or 'file' must be provided") 94 | 95 | resp = await self._send_request( 96 | method="post", 97 | endpoint=endpoint, 98 | data=data, 99 | files=files, 100 | ) 101 | 102 | # If 'bovw' is enabled, switch to feature search mode. 103 | if self.bovw: 104 | resp = await self._send_request(method="get", url=resp.url.replace("/color/", "/bovw/")) 105 | 106 | return Ascii2DResponse(resp.text, resp.url) 107 | -------------------------------------------------------------------------------- /PicImageSearch/model/anime_trace.py: -------------------------------------------------------------------------------- 1 | from typing import Any, NamedTuple 2 | 3 | from typing_extensions import override 4 | 5 | from .base import BaseSearchItem, BaseSearchResponse 6 | 7 | 8 | class Character(NamedTuple): 9 | """Represents a character identified in AnimeTrace search results. 10 | 11 | Contains information about the character's name and the work they appear in. 12 | 13 | Attributes: 14 | name (str): The name of the character. 15 | work (str): The title of the work the character appears in. 16 | """ 17 | 18 | name: str 19 | work: str 20 | 21 | 22 | class AnimeTraceItem(BaseSearchItem): 23 | """Represents a single AnimeTrace search result item. 24 | 25 | This class processes and structures individual search results from AnimeTrace, 26 | providing easy access to various metadata about the found character. 27 | 28 | Attributes: 29 | origin (dict): The raw JSON data of the search result. 30 | box (list[float]): Bounding box coordinates of the detected character [x1, y1, x2, y2]. 31 | box_id (str): Unique identifier for the detected box. 32 | characters (list[Character]): List of possible character matches with their source works. 33 | """ 34 | 35 | def __init__(self, data: dict[str, Any], **kwargs: Any): 36 | """Initializes an AnimeTraceItem with data from a search result. 37 | 38 | Args: 39 | data (dict[str, Any]): A dictionary containing the search result data. 40 | **kwargs (Any): Additional keyword arguments passed to the parent class. 41 | """ 42 | super().__init__(data, **kwargs) 43 | 44 | @override 45 | def _parse_data(self, data: dict[str, Any], **kwargs: Any) -> None: 46 | """Parse search result data. 47 | 48 | Args: 49 | data (dict[str, Any]): The data to parse. 50 | **kwargs (Any): Additional keyword arguments (unused). 51 | """ 52 | self.box: list[float] = data["box"] 53 | self.box_id: str = data["box_id"] 54 | 55 | # Parse character data 56 | character_data = data["character"] 57 | self.characters: list[Character] = [] 58 | 59 | for char_info in character_data: 60 | character = Character(char_info["character"], char_info["work"]) 61 | self.characters.append(character) 62 | 63 | 64 | class AnimeTraceResponse(BaseSearchResponse[AnimeTraceItem]): 65 | """Encapsulates a complete AnimeTrace API response. 66 | 67 | This class processes and structures the full response from an AnimeTrace search, 68 | including all detected characters and their information. 69 | 70 | Attributes: 71 | raw (list[AnimeTraceItem]): List of processed search result items. 72 | origin (dict[str, Any]): The raw JSON response data. 73 | code (int): API response code (0 for success). 74 | ai (bool): Whether the result was generated by AI. 75 | trace_id (str): Unique identifier for the trace request. 76 | """ 77 | 78 | def __init__(self, resp_data: dict[str, Any], resp_url: str, **kwargs: Any) -> None: 79 | """Initializes with the response data. 80 | 81 | Args: 82 | resp_data (dict[str, Any]): A dictionary containing the parsed response data from AnimeTrace. 83 | resp_url (str): URL to the search result page. 84 | **kwargs (Any): Additional keyword arguments passed to the parent class. 85 | """ 86 | super().__init__(resp_data, resp_url, **kwargs) 87 | 88 | @override 89 | def _parse_response(self, resp_data: dict[str, Any], **kwargs: Any) -> None: 90 | """Parse search response data. 91 | 92 | Args: 93 | resp_data (dict[str, Any]): The response data to parse. 94 | **kwargs (Any): Additional keyword arguments. 95 | """ 96 | self.code: int = resp_data["code"] 97 | self.ai: bool = resp_data.get("ai", False) 98 | self.trace_id: str = resp_data["trace_id"] 99 | 100 | # Process results 101 | results = resp_data["data"] 102 | self.raw: list[AnimeTraceItem] = [AnimeTraceItem(item) for item in results] 103 | -------------------------------------------------------------------------------- /demo/code/bing.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 4 | from PicImageSearch import Bing, Network 5 | from PicImageSearch.model import BingResponse 6 | from PicImageSearch.sync import Bing as BingSync 7 | 8 | url = f"{IMAGE_BASE_URL}/test08.jpg" 9 | file = get_image_path("test08.jpg") 10 | 11 | 12 | @logger.catch() 13 | async def demo_async() -> None: 14 | async with Network(proxies=PROXIES) as client: 15 | bing = Bing(client=client) 16 | # resp = await bing.search(url=url) 17 | resp = await bing.search(file=file) 18 | show_result(resp) 19 | 20 | 21 | @logger.catch() 22 | def demo_sync() -> None: 23 | bing = BingSync(proxies=PROXIES) 24 | resp = bing.search(url=url) 25 | # resp = bing.search(file=file) 26 | show_result(resp) # pyright: ignore[reportArgumentType] 27 | 28 | 29 | def show_result(resp: BingResponse) -> None: 30 | logger.info(f"Search URL: {resp.url}") 31 | 32 | if resp.pages_including: 33 | logger.info("Pages Including:") 34 | for page_item in resp.pages_including: 35 | logger.info(f" Name: {page_item.name}") 36 | logger.info(f" URL: {page_item.url}") 37 | logger.info(f" Thumbnail URL: {page_item.thumbnail}") 38 | logger.info(f" Image URL: {page_item.image_url}") 39 | logger.info("-" * 20) 40 | 41 | if resp.visual_search: 42 | logger.info("Visual Search:") 43 | for visual_item in resp.visual_search: 44 | logger.info(f" Name: {visual_item.name}") 45 | logger.info(f" URL: {visual_item.url}") 46 | logger.info(f" Thumbnail URL: {visual_item.thumbnail}") 47 | logger.info(f" Image URL: {visual_item.image_url}") 48 | logger.info("-" * 20) 49 | 50 | if resp.related_searches: 51 | logger.info("Related Searches:") 52 | for search_item in resp.related_searches: 53 | logger.info(f" Text: {search_item.text}") 54 | logger.info(f" Thumbnail URL: {search_item.thumbnail}") 55 | logger.info("-" * 20) 56 | 57 | if resp.travel: 58 | logger.info("Travel:") 59 | logger.info(f" Destination: {resp.travel.destination_name}") 60 | logger.info(f" Travel Guide URL: {resp.travel.travel_guide_url}") 61 | 62 | if resp.travel.attractions: 63 | logger.info(" Attractions:") 64 | for attraction in resp.travel.attractions: 65 | logger.info(f" Title: {attraction.title}") 66 | logger.info(f" URL: {attraction.url}") 67 | logger.info(f" Requery URL: {attraction.search_url}") 68 | logger.info(f" Interest Types: {', '.join(attraction.interest_types)}") 69 | logger.info("-" * 20) 70 | 71 | if resp.travel.travel_cards: 72 | logger.info(" Travel Cards:") 73 | for card in resp.travel.travel_cards: 74 | logger.info(f" Card Type: {card.card_type}") 75 | logger.info(f" Title: {card.title}") 76 | logger.info(f" Click URL: {card.url}") 77 | logger.info(f" Image URL: {card.image_url}") 78 | logger.info(f" Image Source URL: {card.image_source_url}") 79 | logger.info("-" * 20) 80 | 81 | if resp.entities: 82 | logger.info("Entities:") 83 | for entity in resp.entities: 84 | logger.info(f" Name: {entity.name}") 85 | logger.info(f" Thumbnail URL: {entity.thumbnail}") 86 | logger.info(f" Description: {entity.description}") 87 | logger.info(f" Short Description: {entity.short_description}") 88 | if entity.profiles: 89 | logger.info(" Profiles:") 90 | for profile in entity.profiles: 91 | logger.info(f" {profile.get('social_network')}: {profile.get('url')}") 92 | 93 | logger.info("-" * 20) 94 | 95 | if resp.best_guess: 96 | logger.info(f"Best Guess: {resp.best_guess}") 97 | 98 | 99 | if __name__ == "__main__": 100 | asyncio.run(demo_async()) 101 | # demo_sync() 102 | -------------------------------------------------------------------------------- /docs/ja/index.md: -------------------------------------------------------------------------------- 1 | # PicImageSearch 2 | 3 | **逆画像検索エンジンの集大成** 4 | 5 | 6 | license 10 | 11 | 12 | pypi 13 | 14 | python 15 | 16 | release 20 | 21 | 22 | release 26 | 27 | 28 | ## PicImageSearch とは何ですか? 29 | 30 | PicImageSearch は強力な Python 画像検索ライブラリで、複数の主要な画像逆検索エンジンを統合し、開発者に統一かつ簡潔な API インターフェースを提供し、画像検索機能の開発をより便利にします。 31 | 32 | ## プロジェクトの歴史 33 | 34 | ### プロジェクトの起源 35 | 36 | このプロジェクトは、私が [OPQ](https://github.com/opq-osc/OPQ) QQ ロボットプラットフォームで画像検索機能を開発する必要があったことから始まりました。 37 | 当時、市場に出回っている画像検索サービス API は比較的分散しており、それぞれの実装方法が異なり、開発に多くの不便をもたらしていました。 38 | この問題を解決するために、私は PicImageSearch を開発し、複数の優れた画像検索エンジンを統一インターフェースに統合し、開発プロセスを大幅に簡素化しました。 39 | 40 | ### プロジェクトの発展 41 | 42 | プロジェクトの発展過程で、[Neko Aria](https://github.com/NekoAria) は [NoneBot2](https://github.com/nonebot/nonebot2) プラットフォームに触れた後、開発チームに参加しました。 43 | 彼はコードを全面的にリファクタリングし、より現代的な設計理念を導入し、プロジェクトの保守性と拡張性を大幅に向上させました。 44 | 現在、プロジェクトは主に Neko Aria によって維持されており、コミュニティにより良い画像検索ソリューションを提供し続けています。 45 | 46 | ## サポートされている逆画像検索エンジン 47 | 48 |
49 | 50 | - ![AnimeTrace](images/anime-trace.png){ .lg .middle } AnimeTrace 51 | 52 | --- 53 | 54 | 以图识番 - 在线 AI 识番引擎 | 日漫识别 | 动漫查询 | 动漫基因库 55 | 56 | - ![Ascii2D](images/ascii2d.png){ .lg .middle } Ascii2D 57 | 58 | --- 59 | 60 | 二次元画像詳細検索 61 | 62 | - ![BaiDu](images/baidu.png){ .lg .middle } BaiDu 63 | 64 | --- 65 | 66 | 百度图片 67 | 68 | - ![Bing](images/bing.png){ .lg .middle } Bing 69 | 70 | --- 71 | 72 | Bing Images 73 | 74 | - ![Copyseeker](images/copyseeker.png){ .lg .middle } Copyseeker 75 | 76 | --- 77 | 78 | The Best Free AI Powered Reverse Image Search Like No Other 79 | 80 | - ![E-hentai](images/e-hentai.png){ .lg .middle } E-hentai 81 | 82 | --- 83 | 84 | E-Hentai Galleries 85 | 86 | - ![Google](images/google.png){ .lg .middle } Google 87 | 88 | --- 89 | 90 | Google Images 91 | 92 | - ![Google Lens](images/google-lens.png){ .lg .middle } Google Lens 93 | 94 | --- 95 | 96 | Google Lens 97 | 98 | - ![Iqdb](images/iqdb.png){ .lg .middle } Iqdb 99 | 100 | --- 101 | 102 | Multi-service image search 103 | 104 | - ![Lenso](images/lenso.png){ .lg .middle } Lenso 105 | 106 | --- 107 | 108 | Lenso.ai - AI Reverse Image Search 109 | 110 | - ![SauceNAO](images/saucenao.png){ .lg .middle } SauceNAO 111 | 112 | --- 113 | 114 | SauceNAO Reverse Image Search 115 | 116 | - ![Tineye](images/tineye.png){ .lg .middle } Tineye 117 | 118 | --- 119 | 120 | TinEye Reverse Image Search 121 | 122 | - ![TraceMoe](images/tracemoe.png){ .lg .middle } TraceMoe 123 | 124 | --- 125 | 126 | Anime Scene Search Engine 127 | 128 | - ![Yandex](images/yandex.png){ .lg .middle } Yandex 129 | 130 | --- 131 | 132 | Yandex Images 133 | 134 |
135 | 136 | ## このプロジェクトの貢献者 137 | 138 |
139 | 140 | - ![Neko Aria](https://github.com/NekoAria.png){ .lg .middle } Neko Aria 141 | 142 | --- 143 | 144 | プロジェクトのメイン管理者 145 | 146 | - ![kitUIN](https://github.com/kitUIN.png){ .lg .middle } kitUIN 147 | 148 | --- 149 | 150 | プロジェクトオーナー 151 | 152 | - ![Peloxerat](https://github.com/Peloxerat.png){ .lg .middle } Peloxerat 153 | 154 | --- 155 | 156 | プロジェクト貢献者 157 | 158 | - ![lleans](https://github.com/lleans.png){ .lg .middle } lleans 159 | 160 | --- 161 | 162 | プロジェクト貢献者 163 | 164 | - ![chinoll](https://github.com/chinoll.png){ .lg .middle } chinoll 165 | 166 | --- 167 | 168 | プロジェクト貢献者 169 | 170 | - ![Nachtalb](https://github.com/Nachtalb.png){ .lg .middle } Nachtalb 171 | 172 | --- 173 | 174 | プロジェクト貢献者 175 | 176 |
177 | -------------------------------------------------------------------------------- /PicImageSearch/engines/base.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | from pathlib import Path 3 | from typing import Any, Generic, Optional, TypeVar, Union 4 | 5 | from ..model.base import BaseSearchResponse 6 | from ..network import RESP, HandOver 7 | 8 | ResponseT = TypeVar("ResponseT") 9 | T = TypeVar("T", bound=BaseSearchResponse[Any]) 10 | 11 | 12 | class BaseSearchEngine(HandOver, ABC, Generic[T]): 13 | """Base search engine class providing common functionality for all reverse image search engines. 14 | 15 | This abstract base class implements the core functionality shared by all image search engines, 16 | including network request handling and basic parameter validation. 17 | 18 | Attributes: 19 | base_url (str): The base URL endpoint for the search engine's API. 20 | """ 21 | 22 | base_url: str 23 | 24 | def __init__(self, base_url: str, **request_kwargs: Any): 25 | """Initialize the base search engine. 26 | 27 | Args: 28 | base_url (str): The base URL for the search engine's API endpoint. 29 | **request_kwargs (Any): Additional parameters for network requests, such as: 30 | - headers: Custom HTTP headers 31 | - proxies: Proxy settings 32 | - timeout: Request timeout settings 33 | - etc. 34 | """ 35 | super().__init__(**request_kwargs) 36 | self.base_url = base_url 37 | 38 | @abstractmethod 39 | async def search( 40 | self, 41 | url: Optional[str] = None, 42 | file: Union[str, bytes, Path, None] = None, 43 | **kwargs: Any, 44 | ) -> T: 45 | """Perform a reverse image search. 46 | 47 | This abstract method must be implemented by all search engine classes. 48 | Supports searching by either image URL or local file. 49 | 50 | Args: 51 | url (Optional[str]): URL of the image to search. Must be a valid HTTP/HTTPS URL. 52 | file (Union[str, bytes, Path, None]): Local image file to search. Can be: 53 | - A string path to the image 54 | - Raw bytes of the image 55 | - A Path object pointing to the image 56 | **kwargs (Any): Additional search parameters specific to each search engine. 57 | 58 | Returns: 59 | T: Search results. The specific return type depends on the implementing class. 60 | 61 | Raises: 62 | ValueError: If neither 'url' nor 'file' is provided. 63 | NotImplementedError: If the method is not implemented by the subclass. 64 | """ 65 | raise NotImplementedError 66 | 67 | async def _send_request(self, method: str, endpoint: str = "", url: str = "", **kwargs: Any) -> RESP: 68 | """Send an HTTP request and return the response. 69 | 70 | A utility method that handles both GET and POST requests to the search engine's API. 71 | 72 | Args: 73 | method (str): HTTP method, must be either 'get' or 'post' (case-insensitive). 74 | endpoint (str): API endpoint to append to the base URL. If empty, uses base_url directly. 75 | url (str): Full URL for the request. Overrides base_url and endpoint if provided. 76 | **kwargs (Any): Additional parameters for the request, such as: 77 | - params: URL parameters for GET requests 78 | - data: Form data for POST requests 79 | - files: Files to upload 80 | - headers: Custom HTTP headers 81 | - etc. 82 | 83 | Returns: 84 | RESP: A dataclass containing: 85 | - text: The response body as text 86 | - url: The final URL after any redirects 87 | - status_code: The HTTP status code 88 | 89 | Raises: 90 | ValueError: If an unsupported HTTP method is specified. 91 | """ 92 | request_url = url or (f"{self.base_url}/{endpoint}" if endpoint else self.base_url) 93 | 94 | method = method.lower() 95 | if method == "get": 96 | # Files are not valid for GET requests 97 | kwargs.pop("files", None) 98 | return await self.get(request_url, **kwargs) 99 | elif method == "post": 100 | return await self.post(request_url, **kwargs) 101 | else: 102 | raise ValueError(f"Unsupported HTTP method: {method}") 103 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Any 3 | 4 | import pytest 5 | 6 | 7 | def pytest_configure(config): 8 | """Configure test environment""" 9 | # Create test configuration directory 10 | os.makedirs("tests/config", exist_ok=True) 11 | # Create vcr cassettes directory 12 | os.makedirs("tests/cassettes", exist_ok=True) 13 | 14 | # Import modules required by vcr 15 | import vcr.stubs.httpx_stubs 16 | from vcr.request import Request as VcrRequest 17 | 18 | # Add monkey patch to fix VCR handling of binary requests 19 | def patched_make_vcr_request(httpx_request, **kwargs): 20 | # Use binary data directly, don't attempt UTF-8 decoding 21 | body = httpx_request.read() 22 | uri = str(httpx_request.url) 23 | headers = dict(httpx_request.headers) 24 | return VcrRequest(httpx_request.method, uri, body, headers) 25 | 26 | # Apply monkey patch 27 | vcr.stubs.httpx_stubs._make_vcr_request = patched_make_vcr_request 28 | 29 | 30 | def pytest_addoption(parser): 31 | """Add command line options""" 32 | parser.addoption( 33 | "--test-config-file", 34 | action="store", 35 | default="tests/config/test_config.json", 36 | help="Test configuration file path", 37 | ) 38 | 39 | 40 | # VCR related configuration 41 | @pytest.fixture(scope="module", autouse=True) 42 | def vcr_config(): 43 | """Configure pytest-vcr""" 44 | return { 45 | # cassette file storage location 46 | "cassette_library_dir": "tests/cassettes", 47 | # mode setting 48 | "record_mode": "once", 49 | } 50 | 51 | 52 | @pytest.fixture(scope="session") 53 | def test_config(request) -> dict[str, Any]: 54 | """Load test configuration""" 55 | import json 56 | 57 | config_file = request.config.getoption("--test-config-file") 58 | 59 | if os.path.exists(config_file): 60 | with open(config_file, encoding="utf-8") as f: 61 | return json.load(f) 62 | return {} 63 | 64 | 65 | @pytest.fixture(scope="session") 66 | def test_image_path() -> str: 67 | """Test image path""" 68 | return "demo/images/test01.jpg" 69 | 70 | 71 | # Add an image mapping dictionary to specify different test images for different engines 72 | @pytest.fixture(scope="session") 73 | def engine_image_path_mapping() -> dict[str, str]: 74 | """Map engine names to corresponding test image paths""" 75 | base_path = "demo/images" 76 | return { 77 | "animetrace": f"{base_path}/test05.jpg", 78 | "ascii2d": f"{base_path}/test01.jpg", 79 | "baidu": f"{base_path}/test02.jpg", 80 | "bing": f"{base_path}/test08.jpg", 81 | "copyseeker": f"{base_path}/test05.jpg", 82 | "ehentai": f"{base_path}/test06.jpg", 83 | "google": f"{base_path}/test03.jpg", 84 | "googlelens": f"{base_path}/test05.jpg", 85 | "iqdb": f"{base_path}/test01.jpg", 86 | "saucenao": f"{base_path}/test01.jpg", 87 | "tineye": f"{base_path}/test07.jpg", 88 | "tracemoe": f"{base_path}/test05.jpg", 89 | "yandex": f"{base_path}/test06.jpg", 90 | } 91 | 92 | 93 | @pytest.fixture(scope="session") 94 | def engine_image_url_mapping() -> dict[str, str]: 95 | """Map engine names to corresponding test image URLs""" 96 | base_url = "https://raw.githubusercontent.com/kitUIN/PicImageSearch/main/demo/images" 97 | return { 98 | "animetrace": f"{base_url}/test05.jpg", 99 | "ascii2d": f"{base_url}/test01.jpg", 100 | "baidu": f"{base_url}/test02.jpg", 101 | "bing": f"{base_url}/test08.jpg", 102 | "copyseeker": f"{base_url}/test05.jpg", 103 | "ehentai": f"{base_url}/test06.jpg", 104 | "google": f"{base_url}/test03.jpg", 105 | "googlelens": f"{base_url}/test05.jpg", 106 | "iqdb": f"{base_url}/test01.jpg", 107 | "saucenao": f"{base_url}/test01.jpg", 108 | "tineye": f"{base_url}/test07.jpg", 109 | "tracemoe": f"{base_url}/test05.jpg", 110 | "yandex": f"{base_url}/test06.jpg", 111 | } 112 | 113 | 114 | # Configuration check functions for each engine 115 | def has_ascii2d_config(config: dict[str, Any]) -> bool: 116 | return bool(config.get("ascii2d", {}).get("base_url")) 117 | 118 | 119 | def has_google_config(config: dict[str, Any]) -> bool: 120 | return bool(config.get("google", {}).get("cookies")) 121 | 122 | 123 | def has_saucenao_config(config: dict[str, Any]) -> bool: 124 | return bool(config.get("saucenao", {}).get("api_key")) 125 | -------------------------------------------------------------------------------- /PicImageSearch/engines/ehentai.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from typing import Any, Optional, Union 3 | 4 | from typing_extensions import override 5 | 6 | from ..model import EHentaiResponse 7 | from ..utils import read_file 8 | from .base import BaseSearchEngine 9 | 10 | 11 | class EHentai(BaseSearchEngine[EHentaiResponse]): 12 | """API client for the EHentai image search engine. 13 | 14 | Used for performing reverse image searches using EHentai service. 15 | 16 | Attributes: 17 | base_url (str): The base URL for EHentai searches. 18 | is_ex (bool): If True, search on exhentai.org; otherwise, use e-hentai.org. 19 | covers (bool): A flag to search only for covers. 20 | similar (bool): A flag to enable similarity scanning. 21 | exp (bool): A flag to include results from expunged galleries. 22 | """ 23 | 24 | def __init__( 25 | self, 26 | is_ex: bool = False, 27 | covers: bool = False, 28 | similar: bool = True, 29 | exp: bool = False, 30 | **request_kwargs: Any, 31 | ): 32 | """Initializes an EHentai API client with specified configurations. 33 | 34 | Args: 35 | is_ex (bool): If True, search on exhentai.org; otherwise, use e-hentai.org. 36 | covers (bool): If True, search only for covers; otherwise, search all images. 37 | similar (bool): If True, enable similarity scanning for more results. 38 | exp (bool): If True, include results from expunged galleries. 39 | **request_kwargs (Any): Additional arguments for network requests (e.g., cookies, proxies). 40 | 41 | Note: 42 | - For exhentai.org searches (is_ex=True), valid cookies must be provided in request_kwargs. 43 | - The base URL is automatically selected based on the is_ex parameter. 44 | """ 45 | base_url = "https://upld.exhentai.org" if is_ex else "https://upld.e-hentai.org" 46 | super().__init__(base_url, **request_kwargs) 47 | self.is_ex: bool = is_ex 48 | self.covers: bool = covers 49 | self.similar: bool = similar 50 | self.exp: bool = exp 51 | 52 | @override 53 | async def search( 54 | self, 55 | url: Optional[str] = None, 56 | file: Union[str, bytes, Path, None] = None, 57 | **kwargs: Any, 58 | ) -> EHentaiResponse: 59 | """Performs a reverse image search on EHentai/ExHentai. 60 | 61 | This method supports two ways of searching: 62 | 1. Search by image URL 63 | 2. Search by uploading a local image file 64 | 65 | Args: 66 | url (Optional[str]): URL of the image to search. 67 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 68 | **kwargs (Any): Additional arguments passed to the parent class. 69 | 70 | Returns: 71 | EHentaiResponse: Contains search results and metadata, including: 72 | - Similar gallery entries 73 | - Gallery URLs and titles 74 | - Similarity scores 75 | - Additional metadata from the search results 76 | 77 | Raises: 78 | ValueError: If neither `url` nor `file` is provided. 79 | RuntimeError: If searching on ExHentai without proper authentication. 80 | 81 | Note: 82 | - Only one of `url` or `file` should be provided. 83 | - For ExHentai searches, valid cookies must be provided in the request_kwargs. 84 | - Search behavior is affected by the covers, similar, and exp flags set during initialization. 85 | """ 86 | endpoint = "upld/image_lookup.php" if self.is_ex else "image_lookup.php" 87 | data: dict[str, Any] = {"f_sfile": "File Search"} 88 | 89 | if url: 90 | files = {"sfile": await self.download(url)} 91 | elif file: 92 | files = {"sfile": read_file(file)} 93 | else: 94 | raise ValueError("Either 'url' or 'file' must be provided") 95 | 96 | if self.covers: 97 | data["fs_covers"] = "on" 98 | if self.similar: 99 | data["fs_similar"] = "on" 100 | if self.exp: 101 | data["fs_exp"] = "on" 102 | 103 | resp = await self._send_request( 104 | method="post", 105 | endpoint=endpoint, 106 | data=data, 107 | files=files, 108 | ) 109 | 110 | return EHentaiResponse(resp.text, resp.url) 111 | -------------------------------------------------------------------------------- /demo/code/tineye.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Optional 3 | 4 | from demo.code.config import IMAGE_BASE_URL, PROXIES, get_image_path, logger 5 | from PicImageSearch import Network, Tineye 6 | from PicImageSearch.model import TineyeItem, TineyeResponse 7 | from PicImageSearch.sync import Tineye as TineyeSync 8 | 9 | url = f"{IMAGE_BASE_URL}/test07.jpg" 10 | file = get_image_path("test07.jpg") 11 | 12 | # TinEye search parameters 13 | show_unavailable_domains = False 14 | domain = "" # Example: domain='wikipedia.org' 15 | tags = "" # Example: tags="stock,collection" 16 | sort = "score" # Example: "size" or "crawl_date" 17 | order = "desc" # Example: "asc" 18 | 19 | 20 | @logger.catch() 21 | async def demo_async() -> None: 22 | async with Network(proxies=PROXIES) as client: 23 | tineye = Tineye(client=client) 24 | # resp = await tineye.search( 25 | # url=url, 26 | # show_unavailable_domains=show_unavailable_domains, 27 | # domain=domain, 28 | # tags=tags, 29 | # sort=sort, 30 | # order=order, 31 | # ) 32 | resp = await tineye.search( 33 | file=file, 34 | show_unavailable_domains=show_unavailable_domains, 35 | domain=domain, 36 | tags=tags, 37 | sort=sort, 38 | order=order, 39 | ) 40 | show_result(resp, "Initial Search") 41 | 42 | if resp.total_pages > 1: 43 | resp2 = await tineye.next_page(resp) 44 | show_result(resp2, "Next Page") 45 | 46 | if resp2: 47 | resp3 = await tineye.next_page(resp2) 48 | show_result(resp3, "Next Page") 49 | 50 | if resp3: 51 | resp4 = await tineye.pre_page(resp3) 52 | show_result(resp4, "Previous Page") 53 | 54 | 55 | @logger.catch() 56 | def demo_sync() -> None: 57 | tineye = TineyeSync(proxies=PROXIES) 58 | resp = tineye.search( 59 | url=url, 60 | show_unavailable_domains=show_unavailable_domains, 61 | domain=domain, 62 | tags=tags, 63 | sort=sort, 64 | order=order, 65 | ) 66 | # resp = tineye.search( 67 | # file=file, 68 | # show_unavailable_domains=show_unavailable_domains, 69 | # domain=domain, 70 | # tags=tags, 71 | # sort=sort, 72 | # order=order, 73 | # ) 74 | show_result(resp, "Initial Search") # pyright: ignore[reportArgumentType] 75 | 76 | if resp.total_pages > 1: # pyright: ignore[reportAttributeAccessIssue] 77 | resp2 = tineye.next_page(resp) # pyright: ignore[reportArgumentType] 78 | show_result(resp2, "Next Page") # pyright: ignore[reportArgumentType] 79 | 80 | if resp2: # pyright: ignore[reportUnnecessaryComparison] 81 | resp3 = tineye.next_page(resp2) # pyright: ignore[reportArgumentType] 82 | show_result(resp3, "Next Page") # pyright: ignore[reportArgumentType] 83 | 84 | if resp3: # pyright: ignore[reportUnnecessaryComparison] 85 | resp4 = tineye.pre_page(resp3) # pyright: ignore[reportArgumentType] 86 | show_result(resp4, "Previous Page") # pyright: ignore[reportArgumentType] 87 | 88 | 89 | def show_result(resp: Optional[TineyeResponse], title: str = "") -> None: 90 | if resp and not resp.raw: 91 | logger.info(f"Origin Response: {resp.origin}") 92 | 93 | if not resp or not resp.raw: 94 | logger.info(f"{title}: No results found.") 95 | return 96 | # logger.info(f"Domains: {resp.domains}") 97 | logger.info(f"{title}:") 98 | logger.info(f" Status Code: {resp.status_code}") 99 | logger.info(f" Query Hash: {resp.query_hash}") # image hash 100 | logger.info(f" Total Pages: {resp.total_pages}") 101 | logger.info(f" Current Page: {resp.page_number}") 102 | logger.info(" Results:") 103 | 104 | for i, item in enumerate(resp.raw): 105 | show_match_details(i, item) 106 | 107 | 108 | def show_match_details(match_index: int, match_item: TineyeItem) -> None: 109 | logger.info(f" Match {match_index + 1}:") 110 | logger.info(f" Thumbnail URL: {match_item.thumbnail}") 111 | logger.info(f" Image URL: {match_item.image_url}") 112 | logger.info(f" URL(Backlink): {match_item.url}") 113 | logger.info(f" Domain: {match_item.domain}") 114 | logger.info(f" Size: {match_item.size[0]}x{match_item.size[1]}") 115 | logger.info(f" Crawl Date: {match_item.crawl_date}") 116 | logger.info("-" * 50) 117 | 118 | 119 | if __name__ == "__main__": 120 | asyncio.run(demo_async()) 121 | # demo_sync() 122 | -------------------------------------------------------------------------------- /PicImageSearch/engines/anime_trace.py: -------------------------------------------------------------------------------- 1 | from json import loads as json_loads 2 | from pathlib import Path 3 | from typing import Any, Optional, Union 4 | 5 | from typing_extensions import override 6 | 7 | from ..model import AnimeTraceResponse 8 | from ..utils import read_file 9 | from .base import BaseSearchEngine 10 | 11 | 12 | class AnimeTrace(BaseSearchEngine[AnimeTraceResponse]): 13 | """API client for the AnimeTrace image search engine. 14 | 15 | Used for performing anime character recognition searches using AnimeTrace service. 16 | 17 | Attributes: 18 | base_url (str): The base URL for AnimeTrace API. 19 | is_multi (Optional[int]): Whether to show multiple results, 0 or 1. 20 | ai_detect (Optional[int]): Whether to enable AI image detection, 1 for yes, 2 for no. 21 | """ 22 | 23 | def __init__( 24 | self, 25 | base_url: str = "https://api.animetrace.com", 26 | endpoint: str = "v1/search", 27 | is_multi: Optional[int] = None, 28 | ai_detect: Optional[int] = None, 29 | **request_kwargs: Any, 30 | ): 31 | """Initializes an AnimeTrace API client with specified configurations. 32 | 33 | Args: 34 | base_url (str): The base URL for AnimeTrace API, defaults to 'https://api.animetrace.com'. 35 | endpoint (str): The endpoint for AnimeTrace API, defaults to 'v1/search'. 36 | is_multi (Optional[int]): Whether to show multiple results, 0 or 1, defaults to None. 37 | ai_detect (Optional[int]): Whether to enable AI image detection, 1 for yes, 2 for no, defaults to None. 38 | **request_kwargs (Any): Additional arguments passed to the HTTP client. 39 | """ 40 | base_url = f"{base_url}/{endpoint}" 41 | super().__init__(base_url, **request_kwargs) 42 | self.is_multi: Optional[int] = is_multi 43 | self.ai_detect: Optional[int] = ai_detect 44 | 45 | @override 46 | async def search( 47 | self, 48 | url: Optional[str] = None, 49 | file: Union[str, bytes, Path, None] = None, 50 | base64: Optional[str] = None, 51 | model: Optional[str] = None, 52 | **kwargs: Any, 53 | ) -> AnimeTraceResponse: 54 | """Performs an anime character recognition search on AnimeTrace. 55 | 56 | This method supports three ways of searching: 57 | 1. Search by image URL 58 | 2. Search by uploading a local image file 59 | 3. Search by providing a base64 encoded image 60 | 61 | Args: 62 | url (Optional[str]): URL of the image to search. 63 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 64 | base64 (Optional[str]): Base64 encoded image data. 65 | model (Optional[str]): Recognition model to use, defaults to None. 66 | Available models: 'anime_model_lovelive', 'pre_stable', 'anime', 'full_game_model_kira'. 67 | **kwargs (Any): Additional arguments passed to the request. 68 | 69 | Returns: 70 | AnimeTraceResponse: An object containing: 71 | - Detected characters with their source works 72 | - Bounding box coordinates 73 | - Additional metadata (trace_id, AI detection flag) 74 | 75 | Raises: 76 | ValueError: If none of `url`, `file`, or `base64` is provided. 77 | 78 | Note: 79 | - Only one of `url`, `file`, or `base64` should be provided. 80 | - URL and base64 searches use JSON POST requests. 81 | - File uploads use multipart/form-data POST requests. 82 | """ 83 | params: dict[str, Any] = {} 84 | if self.is_multi: 85 | params["is_multi"] = self.is_multi 86 | if self.ai_detect: 87 | params["ai_detect"] = self.ai_detect 88 | if model: 89 | params["model"] = model 90 | 91 | if url: 92 | data = {"url": url, **params} 93 | resp = await self._send_request( 94 | method="post", 95 | json=data, 96 | ) 97 | elif file: 98 | files = {"file": read_file(file)} 99 | resp = await self._send_request( 100 | method="post", 101 | files=files, 102 | data=params or None, 103 | ) 104 | elif base64: 105 | data = {"base64": base64, **params} 106 | resp = await self._send_request( 107 | method="post", 108 | json=data, 109 | ) 110 | else: 111 | raise ValueError("One of 'url', 'file', or 'base64' must be provided") 112 | 113 | return AnimeTraceResponse(json_loads(resp.text), resp.url) 114 | -------------------------------------------------------------------------------- /PicImageSearch/model/ehentai.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from pyquery import PyQuery 4 | from typing_extensions import override 5 | 6 | from ..utils import parse_html 7 | from .base import BaseSearchItem, BaseSearchResponse 8 | 9 | 10 | class EHentaiItem(BaseSearchItem): 11 | """Represents a single e-hentai gallery item from search results. 12 | 13 | Attributes: 14 | origin (PyQuery): The raw PyQuery data of the search result item. 15 | thumbnail (str): URL of the gallery's thumbnail image. 16 | url (str): Direct URL to the gallery page. 17 | title (str): Title of the gallery. 18 | type (str): Category/type of the gallery (e.g., 'Doujinshi', 'Manga', etc.). 19 | date (str): Upload date of the gallery. 20 | tags (list[str]): List of tags associated with the gallery. 21 | """ 22 | 23 | def __init__(self, data: PyQuery, **kwargs: Any): 24 | """Initializes an EHentaiItem with data from a search result. 25 | 26 | Args: 27 | data (PyQuery): A PyQuery instance containing the search result item's data. 28 | """ 29 | super().__init__(data, **kwargs) 30 | 31 | @override 32 | def _parse_data(self, data: PyQuery, **kwargs: Any) -> None: 33 | """Initialize and parse the gallery data from search results. 34 | 35 | Args: 36 | data (PyQuery): PyQuery object containing the gallery's HTML data. 37 | **kwargs (Any): Additional keyword arguments (unused). 38 | """ 39 | self._arrange(data) 40 | 41 | def _arrange(self, data: PyQuery) -> None: 42 | """Extract and organize gallery information from the PyQuery data. 43 | 44 | Processes the HTML data to extract various gallery attributes including: 45 | - Title and URL 46 | - Thumbnail image URL 47 | - Gallery type/category 48 | - Upload date 49 | - Associated tags 50 | 51 | Args: 52 | data (PyQuery): PyQuery object containing the gallery's HTML data. 53 | """ 54 | glink = data.find(".glink") 55 | self.title: str = glink.text() 56 | 57 | if glink.parent("div"): 58 | self.url: str = glink.parent("div").parent("a").attr("href") 59 | else: 60 | self.url = glink.parent("a").attr("href") 61 | 62 | thumbnail = data.find(".glthumb img") or data.find(".gl1e img") or data.find(".gl3t img") 63 | self.thumbnail: str = thumbnail.attr("data-src") or thumbnail.attr("src") 64 | _type = data.find(".cs") or data.find(".cn") 65 | self.type: str = _type.eq(0).text() or "" 66 | self.date: str = data.find("[id^='posted']").eq(0).text() or "" 67 | 68 | self.tags: list[str] = [] 69 | for i in data.find("div[class=gt],div[class=gtl]").items(): 70 | if tag := i.attr("title"): 71 | self.tags.append(tag) 72 | 73 | 74 | class EHentaiResponse(BaseSearchResponse[EHentaiItem]): 75 | """Represents the complete response from an e-hentai reverse image search. 76 | 77 | This class processes and organizes the search results from e-hentai, 78 | handling both filtered and unfiltered results in different HTML layouts. 79 | 80 | Attributes: 81 | origin (PyQuery): The raw PyQuery data of the entire response. 82 | raw (list[EHentaiItem]): List of parsed gallery items from the search. 83 | url (str): URL of the search results page. 84 | """ 85 | 86 | def __init__(self, resp_data: str, resp_url: str, **kwargs: Any): 87 | """Initializes with the response text and URL. 88 | 89 | Args: 90 | resp_data (str): The text of the response. 91 | resp_url (str): URL to the search result page. 92 | """ 93 | super().__init__(resp_data, resp_url, **kwargs) 94 | 95 | @override 96 | def _parse_response(self, resp_data: str, **kwargs: Any) -> None: 97 | """Parse the HTML response data from e-hentai search. 98 | 99 | Handles different result layouts: 100 | - Table layout (.itg > tr) 101 | - Grid layout (.itg > .gl1t) 102 | - No results case 103 | 104 | Args: 105 | resp_data (str): Raw HTML string from the search response. 106 | **kwargs (Any): Additional keyword arguments (unused). 107 | """ 108 | data = parse_html(resp_data) 109 | self.origin: PyQuery = data 110 | if "No unfiltered results" in resp_data: 111 | self.raw: list[EHentaiItem] = [] 112 | elif tr_items := data.find(".itg").children("tr").items(): 113 | self.raw = [EHentaiItem(i) for i in tr_items if i.children("td")] 114 | else: 115 | gl1t_items = data.find(".itg").children(".gl1t").items() 116 | self.raw = [EHentaiItem(i) for i in gl1t_items] 117 | -------------------------------------------------------------------------------- /demo/code/google_lens.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from typing import Union 3 | 4 | from demo.code.config import GOOGLE_COOKIES, IMAGE_BASE_URL, PROXIES, get_image_path, logger 5 | from PicImageSearch import GoogleLens, Network 6 | from PicImageSearch.model import GoogleLensExactMatchesResponse, GoogleLensResponse 7 | from PicImageSearch.sync import GoogleLens as GoogleLensSync 8 | 9 | url = f"{IMAGE_BASE_URL}/test05.jpg" 10 | file = get_image_path("test05.jpg") 11 | 12 | 13 | @logger.catch() 14 | async def demo_async() -> None: 15 | async with Network(proxies=PROXIES, cookies=GOOGLE_COOKIES) as client: 16 | google_lens_all = GoogleLens(client=client, search_type="all", q="anime", hl="en", country="US") 17 | resp_all = await google_lens_all.search(url=url) 18 | show_result(resp_all, search_type="all") 19 | 20 | # google_lens_products = GoogleLens( 21 | # client=client, search_type="products", q="anime", hl="en", country="GB" 22 | # ) 23 | # resp_products = await google_lens_products.search(file=file) 24 | # show_result(resp_products, search_type="products") 25 | 26 | # google_lens_visual = GoogleLens( 27 | # client=client, search_type="visual_matches", hl="zh", country="CN" 28 | # ) 29 | # resp_visual = await google_lens_visual.search(url=url) 30 | # show_result(resp_visual, search_type="visual_matches") 31 | 32 | # google_lens_exact = GoogleLens( 33 | # client=client, search_type="exact_matches", hl="ru", country="RU" 34 | # ) 35 | # resp_exact = await google_lens_exact.search(file=file) 36 | # show_result(resp_exact, search_type="exact_matches") 37 | 38 | 39 | @logger.catch() 40 | def demo_sync() -> None: 41 | google_lens_all = GoogleLensSync( 42 | proxies=PROXIES, 43 | cookies=GOOGLE_COOKIES, 44 | search_type="all", 45 | q="anime", 46 | hl="en", 47 | country="US", 48 | ) 49 | resp_all = google_lens_all.search(url=url) 50 | show_result(resp_all, search_type="sync_all") # pyright: ignore[reportArgumentType] 51 | 52 | # google_lens_products = GoogleLensSync( 53 | # proxies=PROXIES, 54 | # cookies=GOOGLE_COOKIES, 55 | # search_type="products", 56 | # q="anime", 57 | # hl="en", 58 | # country="GB", 59 | # ) 60 | # resp_products = google_lens_products.search(file=file) 61 | # show_result(resp_products, search_type="products") # pyright: ignore[reportArgumentType] 62 | 63 | # google_lens_visual = GoogleLensSync( 64 | # proxies=PROXIES, 65 | # cookies=GOOGLE_COOKIES, 66 | # search_type="visual_matches", 67 | # hl="zh", 68 | # country="CN", 69 | # ) 70 | # resp_visual = google_lens_visual.search(url=url) 71 | # show_result(resp_visual, search_type="visual_matches") # pyright: ignore[reportArgumentType] 72 | 73 | # google_lens_exact = GoogleLensSync( 74 | # proxies=PROXIES, 75 | # cookies=GOOGLE_COOKIES, 76 | # search_type="exact_matches", 77 | # hl="ru", 78 | # country="RU", 79 | # ) 80 | # resp_exact = google_lens_exact.search(file=file) 81 | # show_result(resp_exact, search_type="exact_matches") # pyright: ignore[reportArgumentType] 82 | 83 | 84 | def show_result(resp: Union[GoogleLensResponse, GoogleLensExactMatchesResponse], search_type: str) -> None: 85 | logger.info(f"Search Type: {search_type}") 86 | logger.info(f"Search URL: {resp.url}") 87 | 88 | if isinstance(resp, GoogleLensResponse): 89 | if resp.related_searches: 90 | logger.info("Related Searches:") 91 | for rs in resp.related_searches: 92 | logger.info(f" Title: {rs.title}") 93 | logger.info(f" URL: {rs.url}") 94 | logger.info(f" Thumbnail: {rs.thumbnail}") 95 | logger.info("-" * 20) 96 | 97 | if resp.raw: 98 | logger.info("Visual Matches:") 99 | for item in resp.raw: 100 | logger.info(f" Title: {item.title}") 101 | logger.info(f" URL: {item.url}") 102 | logger.info(f" Site Name: {item.site_name}") 103 | logger.info(f" Thumbnail: {item.thumbnail}") 104 | logger.info("-" * 20) 105 | 106 | elif resp.raw: 107 | logger.info("Exact Matches:") 108 | for item in resp.raw: 109 | logger.info(f" Title: {item.title}") 110 | logger.info(f" URL: {item.url}") 111 | logger.info(f" Site Name: {item.site_name}") 112 | logger.info(f" Size: {item.size}") 113 | logger.info(f" Thumbnail: {item.thumbnail}") 114 | logger.info("-" * 20) 115 | logger.info("-" * 50) 116 | 117 | 118 | if __name__ == "__main__": 119 | asyncio.run(demo_async()) 120 | # demo_sync() 121 | -------------------------------------------------------------------------------- /docs/ru/index.md: -------------------------------------------------------------------------------- 1 | # PicImageSearch 2 | 3 | **Агрегатор Обратного Поиска Изображений** 4 | 5 | 6 | license 10 | 11 | 12 | pypi 13 | 14 | python 15 | 16 | release 20 | 21 | 22 | release 26 | 27 | 28 | ## Что такое PicImageSearch? 29 | 30 | PicImageSearch — это мощная библиотека Python для поиска изображений, которая интегрирует несколько основных систем обратного поиска изображений, предоставляя разработчикам единый и простой интерфейс API, что делает разработку функций поиска изображений более удобной. 31 | 32 | ## История проекта 33 | 34 | ### Происхождение проекта 35 | 36 | Этот проект изначально возник из моей потребности разработать функцию поиска изображений на платформе QQ-робота [OPQ](https://github.com/opq-osc/OPQ). 37 | Тогда я обнаружил, что API сервисов поиска изображений на рынке были неполными и реализованы по-разному, что создавало множество неудобств для разработки. 38 | Чтобы решить эту проблему, я разработал PicImageSearch, который интегрирует несколько отличных поисковых систем изображений в единый интерфейс, значительно упрощая процесс разработки. 39 | 40 | ### Развитие проекта 41 | 42 | В процессе развития проекта [Neko Aria](https://github.com/NekoAria) присоединился к команде разработчиков после знакомства с платформой [NoneBot2](https://github.com/nonebot/nonebot2). 43 | Он полностью переработал код, внедрив более современные концепции дизайна, что значительно улучшило поддерживаемость и расширяемость проекта. 44 | В настоящее время проект в основном поддерживается Neko Aria, продолжая предоставлять сообществу лучшие решения для поиска изображений. 45 | 46 | ## Поддерживаемые поисковые системы обратного поиска изображений 47 | 48 |
49 | 50 | - ![AnimeTrace](images/anime-trace.png){ .lg .middle } AnimeTrace 51 | 52 | --- 53 | 54 | 以图识番 - 在线 AI 识番引擎 | 日漫识别 | 动漫查询 | 动漫基因库 55 | 56 | - ![Ascii2D](images/ascii2d.png){ .lg .middle } Ascii2D 57 | 58 | --- 59 | 60 | 二次元画像詳細検索 61 | 62 | - ![BaiDu](images/baidu.png){ .lg .middle } BaiDu 63 | 64 | --- 65 | 66 | 百度图片 67 | 68 | - ![Bing](images/bing.png){ .lg .middle } Bing 69 | 70 | --- 71 | 72 | Bing Images 73 | 74 | - ![Copyseeker](images/copyseeker.png){ .lg .middle } Copyseeker 75 | 76 | --- 77 | 78 | The Best Free AI Powered Reverse Image Search Like No Other 79 | 80 | - ![E-hentai](images/e-hentai.png){ .lg .middle } E-hentai 81 | 82 | --- 83 | 84 | E-Hentai Galleries 85 | 86 | - ![Google](images/google.png){ .lg .middle } Google 87 | 88 | --- 89 | 90 | Google Images 91 | 92 | - ![Google Lens](images/google-lens.png){ .lg .middle } Google Lens 93 | 94 | --- 95 | 96 | Google Lens 97 | 98 | - ![Iqdb](images/iqdb.png){ .lg .middle } Iqdb 99 | 100 | --- 101 | 102 | Multi-service image search 103 | 104 | - ![Lenso](images/lenso.png){ .lg .middle } Lenso 105 | 106 | --- 107 | 108 | Lenso.ai - AI Reverse Image Search 109 | 110 | - ![SauceNAO](images/saucenao.png){ .lg .middle } SauceNAO 111 | 112 | --- 113 | 114 | SauceNAO Reverse Image Search 115 | 116 | - ![Tineye](images/tineye.png){ .lg .middle } Tineye 117 | 118 | --- 119 | 120 | TinEye Reverse Image Search 121 | 122 | - ![TraceMoe](images/tracemoe.png){ .lg .middle } TraceMoe 123 | 124 | --- 125 | 126 | Anime Scene Search Engine 127 | 128 | - ![Yandex](images/yandex.png){ .lg .middle } Yandex 129 | 130 | --- 131 | 132 | Yandex Images 133 | 134 |
135 | 136 | ## Участники этого проекта 137 | 138 |
139 | 140 | - ![Neko Aria](https://github.com/NekoAria.png){ .lg .middle } Neko Aria 141 | 142 | --- 143 | 144 | Основной Сопровождающий Проекта 145 | 146 | - ![kitUIN](https://github.com/kitUIN.png){ .lg .middle } kitUIN 147 | 148 | --- 149 | 150 | Владелец Проекта 151 | 152 | - ![Peloxerat](https://github.com/Peloxerat.png){ .lg .middle } Peloxerat 153 | 154 | --- 155 | 156 | Участник Проекта 157 | 158 | - ![lleans](https://github.com/lleans.png){ .lg .middle } lleans 159 | 160 | --- 161 | 162 | Участник Проекта 163 | 164 | - ![chinoll](https://github.com/chinoll.png){ .lg .middle } chinoll 165 | 166 | --- 167 | 168 | Участник Проекта 169 | 170 | - ![Nachtalb](https://github.com/Nachtalb.png){ .lg .middle } Nachtalb 171 | 172 | --- 173 | 174 | Участник Проекта 175 | 176 |
177 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # PicImageSearch 2 | 3 | **Aggregated Image Search Engine for Reverse Image Search** 4 | 5 | 6 | license 10 | 11 | 12 | pypi 13 | 14 | python 15 | 16 | release 20 | 21 | 22 | release 26 | 27 | 28 | ## What is PicImageSearch? 29 | 30 | PicImageSearch is a powerful Python image search library that integrates multiple mainstream reverse image search engines, providing developers with a unified and concise API interface, making the development of image search functions more convenient. 31 | 32 | ## Project History 33 | 34 | ### Project Origin 35 | 36 | This project initially originated from my need to develop an image search function on the [OPQ](https://github.com/opq-osc/OPQ) QQ robot platform. 37 | At that time, I found that the image search service APIs on the market were relatively scattered and implemented in different ways, which brought many inconveniences to development. 38 | To solve this problem, I developed PicImageSearch, which integrates multiple excellent image search engines into a unified interface, greatly simplifying the development process. 39 | 40 | ### Project Development 41 | 42 | During the development of the project, [Neko Aria](https://github.com/NekoAria) joined the development team after being introduced to the [NoneBot2](https://github.com/nonebot/nonebot2) platform. 43 | He comprehensively refactored the code, introducing more modern design concepts, significantly improving the maintainability and scalability of the project. 44 | Currently, the project is mainly maintained by Neko Aria, continuously providing the community with better image search solutions. 45 | 46 | ## Supported Reverse Image Search Engines 47 | 48 |
49 | 50 | - ![AnimeTrace](images/anime-trace.png){ .lg .middle } AnimeTrace 51 | 52 | --- 53 | 54 | 以图识番 - 在线 AI 识番引擎 | 日漫识别 | 动漫查询 | 动漫基因库 55 | 56 | - ![Ascii2D](images/ascii2d.png){ .lg .middle } Ascii2D 57 | 58 | --- 59 | 60 | 二次元画像詳細検索 61 | 62 | - ![BaiDu](images/baidu.png){ .lg .middle } BaiDu 63 | 64 | --- 65 | 66 | 百度图片 67 | 68 | - ![Bing](images/bing.png){ .lg .middle } Bing 69 | 70 | --- 71 | 72 | Bing Images 73 | 74 | - ![Copyseeker](images/copyseeker.png){ .lg .middle } Copyseeker 75 | 76 | --- 77 | 78 | The Best Free AI Powered Reverse Image Search Like No Other 79 | 80 | - ![E-hentai](images/e-hentai.png){ .lg .middle } E-hentai 81 | 82 | --- 83 | 84 | E-Hentai Galleries 85 | 86 | - ![Google](images/google.png){ .lg .middle } Google 87 | 88 | --- 89 | 90 | Google Images 91 | 92 | - ![Google Lens](images/google-lens.png){ .lg .middle } Google Lens 93 | 94 | --- 95 | 96 | Google Lens 97 | 98 | - ![Iqdb](images/iqdb.png){ .lg .middle } Iqdb 99 | 100 | --- 101 | 102 | Multi-service image search 103 | 104 | - ![Lenso](images/lenso.png){ .lg .middle } Lenso 105 | 106 | --- 107 | 108 | Lenso.ai - AI Reverse Image Search 109 | 110 | - ![SauceNAO](images/saucenao.png){ .lg .middle } SauceNAO 111 | 112 | --- 113 | 114 | SauceNAO Reverse Image Search 115 | 116 | - ![Tineye](images/tineye.png){ .lg .middle } Tineye 117 | 118 | --- 119 | 120 | TinEye Reverse Image Search 121 | 122 | - ![TraceMoe](images/tracemoe.png){ .lg .middle } TraceMoe 123 | 124 | --- 125 | 126 | Anime Scene Search Engine 127 | 128 | - ![Yandex](images/yandex.png){ .lg .middle } Yandex 129 | 130 | --- 131 | 132 | Yandex Images 133 | 134 |
135 | 136 | ## Contributors to this project 137 | 138 |
139 | 140 | - ![Neko Aria](https://github.com/NekoAria.png){ .lg .middle } Neko Aria 141 | 142 | --- 143 | 144 | Project Maintainer 145 | 146 | - ![kitUIN](https://github.com/kitUIN.png){ .lg .middle } kitUIN 147 | 148 | --- 149 | 150 | Project Owner 151 | 152 | - ![Peloxerat](https://github.com/Peloxerat.png){ .lg .middle } Peloxerat 153 | 154 | --- 155 | 156 | Project Contributor 157 | 158 | - ![lleans](https://github.com/lleans.png){ .lg .middle } lleans 159 | 160 | --- 161 | 162 | Project Contributor 163 | 164 | - ![chinoll](https://github.com/chinoll.png){ .lg .middle } chinoll 165 | 166 | --- 167 | 168 | Project Contributor 169 | 170 | - ![Nachtalb](https://github.com/Nachtalb.png){ .lg .middle } Nachtalb 171 | 172 | --- 173 | 174 | Project Contributor 175 | 176 |
177 | -------------------------------------------------------------------------------- /PicImageSearch/model/yandex.py: -------------------------------------------------------------------------------- 1 | from json import loads as json_loads 2 | from typing import Any 3 | 4 | from pyquery import PyQuery 5 | from typing_extensions import override 6 | 7 | from ..exceptions import ParsingError 8 | from ..utils import deep_get, parse_html 9 | from .base import BaseSearchItem, BaseSearchResponse 10 | 11 | 12 | class YandexItem(BaseSearchItem): 13 | """Represents a single Yandex search result item. 14 | 15 | A structured representation of an individual image search result from Yandex, 16 | containing detailed information about the found image and its source. 17 | 18 | Attributes: 19 | url (str): Direct URL to the webpage containing the image. 20 | title (str): Title or heading associated with the image. 21 | thumbnail (str): URL of the image thumbnail, properly formatted with https if needed. 22 | source (str): Domain name of the website hosting the image. 23 | content (str): Descriptive text or context surrounding the image. 24 | size (str): Image dimensions in "widthxheight" format. 25 | """ 26 | 27 | def __init__(self, data: dict[str, Any], **kwargs: Any): 28 | """Initializes a YandexItem with data from a search result. 29 | 30 | Args: 31 | data (dict[str, Any]): A dictionary containing the search result data. 32 | """ 33 | super().__init__(data, **kwargs) 34 | 35 | @override 36 | def _parse_data(self, data: dict[str, Any], **kwargs: Any) -> None: 37 | """Parses raw search result data into structured attributes. 38 | 39 | Processes the raw dictionary data from Yandex and extracts relevant information 40 | into instance attributes. 41 | 42 | Args: 43 | data (dict[str, Any]): Dictionary containing the raw search result data from Yandex. 44 | **kwargs (Any): Additional keyword arguments (unused). 45 | 46 | Note: 47 | The thumbnail URL is automatically prefixed with 'https:' if it starts 48 | with '//' to ensure proper formatting. 49 | """ 50 | self.url: str = data["url"] 51 | self.title: str = data["title"] 52 | thumb_url: str = data["thumb"]["url"] 53 | self.thumbnail: str = f"https:{thumb_url}" if thumb_url.startswith("//") else thumb_url 54 | self.source: str = data["domain"] 55 | self.content: str = data["description"] 56 | original_image = data["originalImage"] 57 | self.size: str = f"{original_image['width']}x{original_image['height']}" 58 | 59 | 60 | class YandexResponse(BaseSearchResponse[YandexItem]): 61 | """Encapsulates a complete Yandex reverse image search response. 62 | 63 | Processes and stores the full response from a Yandex reverse image search, 64 | including all found image results and metadata. 65 | 66 | Attributes: 67 | raw (list[YandexItem]): List of parsed search results as YandexItem instances. 68 | url (str): URL of the search results page. 69 | origin (PyQuery): PyQuery object containing the raw HTML response. 70 | """ 71 | 72 | def __init__(self, resp_data: str, resp_url: str, **kwargs: Any): 73 | """Initializes with the response text and URL. 74 | 75 | Args: 76 | resp_data (str): the text of the response. 77 | resp_url (str): URL to the search result page. 78 | """ 79 | super().__init__(resp_data, resp_url, **kwargs) 80 | 81 | @override 82 | def _parse_response(self, resp_data: str, **kwargs: Any) -> None: 83 | """Parses the raw HTML response from Yandex into structured data. 84 | 85 | Extracts search results from the HTML response by locating and parsing 86 | the JSON data embedded in the page. 87 | 88 | Args: 89 | resp_data (str): Raw HTML response from Yandex search. 90 | **kwargs (Any): Additional keyword arguments (unused). 91 | 92 | Note: 93 | The method looks for a specific div element containing the search results 94 | data in JSON format, then creates YandexItem instances for each result. 95 | """ 96 | data = parse_html(resp_data) 97 | self.origin: PyQuery = data 98 | data_div = data.find('div.Root[id^="ImagesApp-"]') 99 | data_state = data_div.attr("data-state") 100 | 101 | if not data_state: 102 | raise ParsingError( 103 | message="Failed to find critical DOM attribute 'data-state'", 104 | engine="yandex", 105 | details="This usually indicates a change in the page structure or an unexpected response.", 106 | ) 107 | 108 | data_json = json_loads(str(data_state)) 109 | if sites := deep_get(data_json, "initialState.cbirSites.sites"): 110 | self.raw: list[YandexItem] = [YandexItem(site) for site in sites] 111 | else: 112 | raise ParsingError( 113 | message="Failed to extract search results from 'data-state'", 114 | engine="yandex", 115 | details="This usually indicates a change in the page structure or an unexpected response.", 116 | ) 117 | -------------------------------------------------------------------------------- /PicImageSearch/model/lenso.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from typing_extensions import override 4 | 5 | from ..utils import deep_get 6 | from .base import BaseSearchItem, BaseSearchResponse 7 | 8 | 9 | class LensoURLItem: 10 | """Represents a URL item in Lenso search results. 11 | 12 | A class that processes and stores URL-related information from a Lenso search result. 13 | 14 | Attributes: 15 | origin (dict): The raw JSON data of the URL item. 16 | image_url (str): Direct URL to the full-size image. 17 | source_url (str): URL of the webpage containing the image. 18 | title (str): Title or description of the image. 19 | lang (str): Language of the webpage. 20 | """ 21 | 22 | def __init__(self, data: dict[str, Any]) -> None: 23 | self.origin: dict[str, Any] = data 24 | self.image_url: str = data.get("imageUrl", "") 25 | self.source_url: str = data.get("sourceUrl", "") 26 | self.title: str = data.get("title") or "" 27 | self.lang: str = data.get("lang", "") 28 | 29 | 30 | class LensoResultItem(BaseSearchItem): 31 | """Represents a single Lenso search result item. 32 | 33 | Attributes: 34 | origin (dict): The raw JSON data of the search result item. 35 | title (str): Title or name of the search result. 36 | url (str): URL of the webpage containing the image. 37 | hash (str): The hash of the image. 38 | similarity (float): The similarity score (0-100). 39 | thumbnail (str): URL of the thumbnail version of the image. 40 | url_list (list[LensoURLItem]): List of related URLs. 41 | width (int): The width of the image. 42 | height (int): The height of the image. 43 | """ 44 | 45 | def __init__(self, data: dict[str, Any], **kwargs: Any) -> None: 46 | self.url_list: list[LensoURLItem] = [] 47 | self.width: int = 0 48 | self.height: int = 0 49 | super().__init__(data, **kwargs) 50 | 51 | @override 52 | def _parse_data(self, data: dict[str, Any], **kwargs: Any) -> None: 53 | """Parse search result data.""" 54 | self.origin: dict[str, Any] = data 55 | self.title: str = deep_get(data, "urlList[0].title") or "" 56 | self.url: str = deep_get(data, "urlList[0].sourceUrl") or "" 57 | self.hash: str = data.get("hash", "") 58 | 59 | distance: float = data.get("distance", 0.0) 60 | self.similarity: float = round(distance * 100, 2) 61 | 62 | self.thumbnail: str = data.get("proxyUrl", "") 63 | self.url_list = [LensoURLItem(url_data) for url_data in data.get("urlList", [])] 64 | self.width = data.get("width", 0) 65 | self.height = data.get("height", 0) 66 | 67 | 68 | class LensoResponse(BaseSearchResponse[LensoResultItem]): 69 | """Encapsulates a complete Lenso search response. 70 | 71 | Attributes: 72 | origin (dict): The raw JSON response data from Lenso. 73 | raw (list[LensoResultItem]): List of all search results. 74 | url (str): URL of the search results page. 75 | similar (list[LensoResultItem]): Similar image results. 76 | duplicates (list[LensoResultItem]): Duplicate image results. 77 | places (list[LensoResultItem]): Place recognition results. 78 | related (list[LensoResultItem]): Related image results. 79 | people (list[LensoResultItem]): People recognition results. 80 | detected_faces (list[Any]): Detected faces in the image. 81 | """ 82 | 83 | def __init__(self, resp_data: dict[str, Any], resp_url: str, **kwargs: Any) -> None: 84 | """Initializes with the response data. 85 | 86 | Args: 87 | resp_data (dict[str, Any]): A dictionary containing the parsed response data from Lenso's API. 88 | resp_url (str): URL of the search results page. 89 | **kwargs (Any): Additional keyword arguments. 90 | """ 91 | self.raw: list[LensoResultItem] = [] 92 | self.duplicates: list[LensoResultItem] = [] 93 | self.similar: list[LensoResultItem] = [] 94 | self.places: list[LensoResultItem] = [] 95 | self.related: list[LensoResultItem] = [] 96 | self.people: list[LensoResultItem] = [] 97 | self.detected_faces: list[Any] = [] 98 | super().__init__(resp_data, resp_url, **kwargs) 99 | 100 | @override 101 | def _parse_response(self, resp_data: dict[str, Any], **kwargs: Any) -> None: 102 | """Parse the raw response data into structured results. 103 | 104 | Args: 105 | resp_data (dict[str, Any]): Raw response dictionary from Lenso's API. 106 | **kwargs (Any): Additional keyword arguments (unused). 107 | """ 108 | self.detected_faces = resp_data.get("detectedFaces", []) 109 | 110 | results_data = resp_data.get("results", {}) 111 | result_types = { 112 | "duplicates": self.duplicates, 113 | "similar": self.similar, 114 | "places": self.places, 115 | "related": self.related, 116 | "people": self.people, 117 | } 118 | 119 | for result_type, result_list in result_types.items(): 120 | result_list.extend(LensoResultItem(item) for item in results_data.get(result_type, [])) 121 | self.raw.extend(result_list) 122 | -------------------------------------------------------------------------------- /PicImageSearch/engines/baidu.py: -------------------------------------------------------------------------------- 1 | from json import loads as json_loads 2 | from pathlib import Path 3 | from typing import Any, Optional, Union 4 | 5 | from lxml.html import fromstring 6 | from pyquery import PyQuery 7 | from typing_extensions import override 8 | 9 | from ..model import BaiDuResponse 10 | from ..utils import deep_get, read_file 11 | from .base import BaseSearchEngine 12 | 13 | 14 | class BaiDu(BaseSearchEngine[BaiDuResponse]): 15 | """API client for the BaiDu image search engine. 16 | 17 | Used for performing reverse image searches using BaiDu service. 18 | 19 | Attributes: 20 | base_url (str): The base URL for BaiDu searches. 21 | """ 22 | 23 | def __init__(self, **request_kwargs: Any): 24 | """Initializes a BaiDu API client with specified configurations. 25 | 26 | Args: 27 | **request_kwargs (Any): Additional arguments for network requests. 28 | """ 29 | base_url = "https://graph.baidu.com" 30 | super().__init__(base_url, **request_kwargs) 31 | 32 | @staticmethod 33 | def _extract_card_data(data: PyQuery) -> list[dict[str, Any]]: 34 | """Extracts 'window.cardData' from the BaiDu search response page. 35 | 36 | This method parses the JavaScript content in the page to find and extract 37 | the 'window.cardData' object, which contains the search results. 38 | 39 | Args: 40 | data (PyQuery): A PyQuery object containing the parsed HTML page. 41 | 42 | Returns: 43 | list[dict[str, Any]]: A list of card data dictionaries, where each dictionary 44 | contains information about a search result. Returns an empty list if 45 | no card data is found. 46 | 47 | Note: 48 | The method searches for specific script tags containing 'window.cardData' 49 | and extracts the JSON data between the first '[' and last ']' characters. 50 | """ 51 | for script in data("script").items(): 52 | script_text = script.text() 53 | if script_text and "window.cardData" in script_text: 54 | start = script_text.find("[") 55 | end = script_text.rfind("]") + 1 56 | return json_loads(script_text[start:end]) 57 | return [] 58 | 59 | @override 60 | async def search( 61 | self, 62 | url: Optional[str] = None, 63 | file: Union[str, bytes, Path, None] = None, 64 | **kwargs: Any, 65 | ) -> BaiDuResponse: 66 | """Performs a reverse image search on BaiDu. 67 | 68 | This method supports two ways of searching: 69 | 1. Search by image URL 70 | 2. Search by uploading a local image file 71 | 72 | The search process involves multiple steps: 73 | 1. Upload the image or submit the URL to BaiDu 74 | 2. Follow the returned URL to get the search results page 75 | 3. Extract and parse the card data from the page 76 | 4. If similar images are found, fetch the detailed results 77 | 78 | Args: 79 | url (Optional[str]): URL of the image to search. 80 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 81 | **kwargs (Any): Additional arguments passed to the parent class. 82 | 83 | Returns: 84 | BaiDuResponse: An object containing the search results and metadata. 85 | Returns empty results if no matches are found or if the 'noresult' 86 | card is present. 87 | 88 | Raises: 89 | ValueError: If neither `url` nor `file` is provided. 90 | 91 | Note: 92 | - Only one of `url` or `file` should be provided. 93 | - The search process involves multiple HTTP requests to BaiDu's API. 94 | - The response format varies depending on whether matches are found. 95 | """ 96 | data = {"from": "pc"} 97 | 98 | if url: 99 | files = {"image": await self.download(url)} 100 | elif file: 101 | files = {"image": read_file(file)} 102 | else: 103 | raise ValueError("Either 'url' or 'file' must be provided") 104 | 105 | resp = await self._send_request( 106 | method="post", 107 | endpoint="upload", 108 | headers={"Acs-Token": ""}, 109 | data=data, 110 | files=files, 111 | ) 112 | data_url = deep_get(json_loads(resp.text), "data.url") 113 | if not data_url: 114 | return BaiDuResponse({}, resp.url) 115 | 116 | resp = await self._send_request(method="get", url=data_url) 117 | 118 | data = PyQuery(fromstring(resp.text)) 119 | card_data = self._extract_card_data(data) 120 | same_data = None 121 | 122 | for card in card_data: 123 | if card.get("cardName") == "noresult": 124 | return BaiDuResponse({}, data_url) 125 | if card.get("cardName") == "same": 126 | same_data = card["tplData"] 127 | if card.get("cardName") == "simipic": 128 | next_url = card["tplData"]["firstUrl"] 129 | resp = await self._send_request(method="get", url=next_url) 130 | resp_data = json_loads(resp.text) 131 | 132 | if same_data: 133 | resp_data["same"] = same_data 134 | 135 | return BaiDuResponse(resp_data, data_url) 136 | 137 | return BaiDuResponse({}, data_url) 138 | -------------------------------------------------------------------------------- /PicImageSearch/engines/copyseeker.py: -------------------------------------------------------------------------------- 1 | from json import loads as json_loads 2 | from pathlib import Path 3 | from typing import Any, Optional, Union 4 | 5 | from typing_extensions import override 6 | 7 | from ..constants import COPYSEEKER_CONSTANTS 8 | from ..model import CopyseekerResponse 9 | from ..utils import read_file 10 | from .base import BaseSearchEngine 11 | 12 | 13 | class Copyseeker(BaseSearchEngine[CopyseekerResponse]): 14 | """API client for the Copyseeker image search engine. 15 | 16 | Used for performing reverse image searches using Copyseeker service. 17 | 18 | Attributes: 19 | base_url (str): The base URL for Copyseeker searches. 20 | """ 21 | 22 | def __init__(self, base_url: str = "https://copyseeker.net", **request_kwargs: Any): 23 | """Initializes a Copyseeker API client. 24 | 25 | Args: 26 | base_url (str): The base URL for Copyseeker searches. 27 | **request_kwargs (Any): Additional arguments for network requests. 28 | """ 29 | super().__init__(base_url, **request_kwargs) 30 | 31 | async def _get_discovery_id( 32 | self, url: Optional[str] = None, file: Union[str, bytes, Path, None] = None 33 | ) -> Optional[str]: 34 | """Retrieves a discovery ID from Copyseeker for image search. 35 | 36 | This method handles two search scenarios: 37 | 1. Search by image URL 38 | 2. Search by uploading a local image file 39 | 40 | Args: 41 | url (Optional[str]): URL of the image to search. 42 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 43 | 44 | Returns: 45 | Optional[str]: The discovery ID if successful, None otherwise. 46 | 47 | Note: 48 | - The discovery ID is required for retrieving search results. 49 | """ 50 | 51 | headers = {"content-type": "text/plain;charset=UTF-8", "next-action": COPYSEEKER_CONSTANTS["SET_COOKIE_TOKEN"]} 52 | data = "[]" 53 | discovery_id = None 54 | resp = None 55 | 56 | # Set cookie token 57 | await self._send_request( 58 | method="post", 59 | headers=headers, 60 | data=data, 61 | ) 62 | 63 | if url: 64 | data = [{"discoveryType": "ReverseImageSearch", "imageUrl": url}] 65 | headers = {"next-action": COPYSEEKER_CONSTANTS["URL_SEARCH_TOKEN"]} 66 | 67 | resp = await self._send_request( 68 | method="post", 69 | headers=headers, 70 | json=data, 71 | ) 72 | 73 | elif file: 74 | files = { 75 | "1_file": ("image.jpg", read_file(file), "image/jpeg"), 76 | "1_discoveryType": (None, "ReverseImageSearch"), 77 | "0": (None, '["$K1"]'), 78 | } 79 | headers = {"next-action": COPYSEEKER_CONSTANTS["FILE_UPLOAD_TOKEN"]} 80 | 81 | resp = await self._send_request( 82 | method="post", 83 | headers=headers, 84 | files=files, 85 | ) 86 | 87 | if resp: 88 | for line in resp.text.splitlines(): 89 | line = line.strip() 90 | if line.startswith("1:{"): 91 | discovery_id = json_loads(line[2:]).get("discoveryId") 92 | break 93 | 94 | return discovery_id 95 | 96 | @override 97 | async def search( 98 | self, 99 | url: Optional[str] = None, 100 | file: Union[str, bytes, Path, None] = None, 101 | **kwargs: Any, 102 | ) -> CopyseekerResponse: 103 | """Performs a reverse image search on Copyseeker. 104 | 105 | This method supports two ways of searching: 106 | 1. Search by image URL 107 | 2. Search by uploading a local image file 108 | 109 | The search process involves two steps: 110 | 1. Obtaining a discovery ID 111 | 2. Retrieving search results using the discovery ID 112 | 113 | Args: 114 | url (Optional[str]): URL of the image to search. 115 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 116 | **kwargs (Any): Additional arguments passed to the parent class. 117 | 118 | Returns: 119 | CopyseekerResponse: An object containing search results and metadata. 120 | Returns an empty response if discovery ID cannot be obtained. 121 | 122 | Raises: 123 | ValueError: If neither `url` nor `file` is provided. 124 | 125 | Note: 126 | - Only one of `url` or `file` should be provided. 127 | - The search process involves multiple HTTP requests to Copyseeker's API. 128 | """ 129 | if not url and not file: 130 | raise ValueError("Either 'url' or 'file' must be provided") 131 | 132 | discovery_id = await self._get_discovery_id(url, file) 133 | if discovery_id is None: 134 | return CopyseekerResponse({}, "") 135 | 136 | data = [{"discoveryId": discovery_id, "hasBlocker": False}] 137 | headers = {"next-action": COPYSEEKER_CONSTANTS["GET_RESULTS_TOKEN"]} 138 | 139 | resp = await self._send_request( 140 | method="post", 141 | endpoint="discovery", 142 | headers=headers, 143 | json=data, 144 | ) 145 | 146 | resp_json = {} 147 | 148 | for line in resp.text.splitlines(): 149 | line = line.strip() 150 | if line.startswith("1:{"): 151 | resp_json = json_loads(line[2:]) 152 | break 153 | 154 | return CopyseekerResponse(resp_json, resp.url) 155 | -------------------------------------------------------------------------------- /PicImageSearch/model/google.py: -------------------------------------------------------------------------------- 1 | from re import compile 2 | from typing import Any, Optional 3 | 4 | from pyquery import PyQuery 5 | from typing_extensions import override 6 | 7 | from ..exceptions import ParsingError 8 | from ..utils import parse_html 9 | from .base import BaseSearchItem, BaseSearchResponse 10 | 11 | 12 | class GoogleItem(BaseSearchItem): 13 | """Represents a single Google search result item. 14 | 15 | A class that processes and stores individual search result data from Google reverse image search. 16 | 17 | Attributes: 18 | origin (PyQuery): The raw PyQuery object containing the search result data. 19 | title (str): The title text of the search result. 20 | url (str): The URL link to the search result page. 21 | thumbnail (Optional[str]): Base64 encoded thumbnail image, if available. 22 | content (str): Descriptive text or context surrounding the image. 23 | """ 24 | 25 | def __init__(self, data: PyQuery, thumbnail: Optional[str]): 26 | """Initializes a GoogleItem with data from a search result. 27 | 28 | Args: 29 | data (PyQuery): A PyQuery instance containing the search result item's data. 30 | thumbnail (Optional[str]): Optional base64 encoded thumbnail image. 31 | """ 32 | super().__init__(data, thumbnail=thumbnail) 33 | 34 | @override 35 | def _parse_data(self, data: PyQuery, **kwargs: Any) -> None: 36 | """Parse search result data.""" 37 | self.title: str = data("h3").text() 38 | self.url: str = data("a").eq(0).attr("href") 39 | self.thumbnail: str = kwargs.get("thumbnail") or "" 40 | self.content: str = data("div.VwiC3b").text() 41 | 42 | 43 | class GoogleResponse(BaseSearchResponse[GoogleItem]): 44 | """Encapsulates a Google reverse image search response. 45 | 46 | Processes and stores the complete response from a Google reverse image search, 47 | including pagination information and individual search results. 48 | 49 | Attributes: 50 | origin (PyQuery): The raw PyQuery object containing the full response data. 51 | page_number (int): Current page number in the search results. 52 | url (str): URL of the current search result page. 53 | pages (list[str]): List of URLs for all available result pages. 54 | raw (list[GoogleItem]): List of processed search result items. 55 | """ 56 | 57 | def __init__( 58 | self, 59 | resp_data: str, 60 | resp_url: str, 61 | page_number: int = 1, 62 | pages: Optional[list[str]] = None, 63 | ): 64 | """Initializes with the response text and URL. 65 | 66 | Args: 67 | resp_data (str): The text of the response. 68 | resp_url (str): URL to the search result page. 69 | page_number (int): The current page number in the search results. 70 | pages (Optional[list[str]]): List of URLs to pages of search results. 71 | """ 72 | super().__init__(resp_data, resp_url, page_number=page_number, pages=pages) 73 | 74 | @override 75 | def _parse_response(self, resp_data: str, **kwargs: Any) -> None: 76 | """Parse search response data.""" 77 | data = parse_html(resp_data) 78 | self.origin: PyQuery = data 79 | self.page_number: int = kwargs["page_number"] 80 | 81 | if pages := kwargs.get("pages"): 82 | self.pages: list[str] = pages 83 | else: 84 | self.pages = [f"https://www.google.com{i.attr('href')}" for i in data.find('a[aria-label~="Page"]').items()] 85 | self.pages.insert(0, kwargs["resp_url"]) 86 | 87 | script_list = list(data.find("script").items()) 88 | thumbnail_dict: dict[str, str] = self.create_thumbnail_dict(script_list) 89 | 90 | search_items = data.find("#search .wHYlTd") 91 | self.raw: list[GoogleItem] = [ 92 | GoogleItem(i, thumbnail_dict.get(i('img[id^="dimg_"]').attr("id"))) for i in search_items.items() 93 | ] 94 | 95 | if thumbnail_dict and not self.raw: 96 | raise ParsingError( 97 | message="Failed to extract search results despite finding thumbnails", 98 | engine="google", 99 | details="This usually indicates a change in the page structure.", 100 | ) 101 | 102 | @staticmethod 103 | def create_thumbnail_dict(script_list: list[PyQuery]) -> dict[str, str]: 104 | """Creates a mapping of image IDs to their base64 encoded thumbnails. 105 | 106 | Processes script tags from Google's search results to extract thumbnail images 107 | and their corresponding IDs. 108 | 109 | Args: 110 | script_list (list[PyQuery]): List of PyQuery objects containing script elements 111 | from the search results page. 112 | 113 | Returns: 114 | dict[str, str]: A dictionary where: 115 | - Keys are image IDs (format: 'dimg_*') 116 | - Values are base64 encoded image strings 117 | 118 | Note: 119 | - Handles multiple image formats (jpeg, jpg, png, gif) 120 | - Automatically fixes escaped base64 strings by replacing '\x3d' with '=' 121 | """ 122 | thumbnail_dict = {} 123 | base_64_regex = compile(r"data:image/(?:jpeg|jpg|png|gif);base64,[^'\"]+") 124 | id_regex = compile(r"dimg_[^'\"]+") 125 | 126 | for script in script_list: 127 | base_64_match = base_64_regex.findall(script.text()) 128 | if not base_64_match: 129 | continue 130 | 131 | # extract and adjust base64 encoded thumbnails 132 | base64: str = base_64_match[0] 133 | id_list: list[str] = id_regex.findall(script.text()) 134 | 135 | for _id in id_list: 136 | thumbnail_dict[_id] = base64.replace(r"\x3d", "=") 137 | 138 | return thumbnail_dict 139 | -------------------------------------------------------------------------------- /PicImageSearch/engines/saucenao.py: -------------------------------------------------------------------------------- 1 | from json import loads as json_loads 2 | from pathlib import Path 3 | from typing import Any, Optional, Union 4 | 5 | from httpx import QueryParams 6 | from typing_extensions import override 7 | 8 | from ..model import SauceNAOResponse 9 | from ..utils import read_file 10 | from .base import BaseSearchEngine 11 | 12 | 13 | class SauceNAO(BaseSearchEngine[SauceNAOResponse]): 14 | """API client for the SauceNAO image search engine. 15 | 16 | Used for performing reverse image searches using SauceNAO service. 17 | 18 | Attributes: 19 | base_url (str): The base URL for SauceNAO searches. 20 | params (dict[str, Any]): The query parameters for SauceNAO search. 21 | """ 22 | 23 | def __init__( 24 | self, 25 | base_url: str = "https://saucenao.com", 26 | api_key: Optional[str] = None, 27 | numres: int = 5, 28 | hide: int = 0, 29 | minsim: int = 30, 30 | output_type: int = 2, 31 | testmode: int = 0, 32 | dbmask: Optional[int] = None, 33 | dbmaski: Optional[int] = None, 34 | db: int = 999, 35 | dbs: Optional[list[int]] = None, 36 | **request_kwargs: Any, 37 | ): 38 | """Initializes a SauceNAO API client with specified configurations. 39 | 40 | Args: 41 | base_url (str): The base URL for SauceNAO searches, defaults to 'https://saucenao.com'. 42 | api_key (Optional[str]): API key for SauceNAO API access, required for full API functionality. 43 | numres (int): Number of results to return (1-40), defaults to 5. 44 | hide (int): Content filtering level (0-3), defaults to 0. 45 | 0: Show all results 46 | 1: Hide expected explicit results 47 | 2: Hide expected questionable results 48 | 3: Hide all but expected safe results 49 | minsim (int): Minimum similarity percentage for results (0-100), defaults to 30. 50 | output_type (int): Output format of search results, defaults to 2. 51 | 0: HTML 52 | 1: XML 53 | 2: JSON 54 | testmode (int): If 1, performs a dry-run search without using search quota. 55 | dbmask (Optional[int]): Bitmask for enabling specific databases. 56 | dbmaski (Optional[int]): Bitmask for disabling specific databases. 57 | db (int): Database index to search from (0-999), defaults to 999 (all databases). 58 | dbs (Optional[list[int]]): List of specific database indices to search from. 59 | **request_kwargs (Any): Additional arguments passed to the HTTP client. 60 | 61 | Note: 62 | - API documentation: https://saucenao.com/user.php?page=search-api 63 | - Database indices: https://saucenao.com/tools/examples/api/index_details.txt 64 | - Using API key is recommended to avoid rate limits and access more features. 65 | - When `dbs` is provided, it takes precedence over `db` parameter. 66 | """ 67 | base_url = f"{base_url}/search.php" 68 | super().__init__(base_url, **request_kwargs) 69 | params: dict[str, Any] = { 70 | "testmode": testmode, 71 | "numres": numres, 72 | "output_type": output_type, 73 | "hide": hide, 74 | "db": db, 75 | "minsim": minsim, 76 | } 77 | if api_key is not None: 78 | params["api_key"] = api_key 79 | if dbmask is not None: 80 | params["dbmask"] = dbmask 81 | if dbmaski is not None: 82 | params["dbmaski"] = dbmaski 83 | self.params: QueryParams = QueryParams(params) 84 | if dbs is not None: 85 | self.params = self.params.remove("db") 86 | for i in dbs: 87 | self.params = self.params.add("dbs[]", i) 88 | 89 | @override 90 | async def search( 91 | self, 92 | url: Optional[str] = None, 93 | file: Union[str, bytes, Path, None] = None, 94 | **kwargs: Any, 95 | ) -> SauceNAOResponse: 96 | """Performs a reverse image search on SauceNAO. 97 | 98 | This method supports two ways of searching: 99 | 1. Search by image URL 100 | 2. Search by uploading a local image file 101 | 102 | Args: 103 | url (Optional[str]): URL of the image to search. 104 | file (Union[str, bytes, Path, None]): Local image file, can be a path string, bytes data, or Path object. 105 | **kwargs (Any): Additional arguments passed to the parent class. 106 | 107 | Returns: 108 | SauceNAOResponse: An object containing: 109 | - Search results with similarity scores 110 | - Source information and thumbnails 111 | - Additional metadata (status code, search quotas) 112 | 113 | Raises: 114 | ValueError: If neither `url` nor `file` is provided. 115 | 116 | Note: 117 | - Only one of `url` or `file` should be provided. 118 | - API limits vary based on account type and API key usage. 119 | - Free accounts are limited to: 120 | * 150 searches per day 121 | * 4 searches per 30 seconds 122 | - Results are sorted by similarity score in descending order. 123 | """ 124 | params = self.params 125 | files: Optional[dict[str, Any]] = None 126 | 127 | if url: 128 | params = params.add("url", url) 129 | elif file: 130 | files = {"file": read_file(file)} 131 | else: 132 | raise ValueError("Either 'url' or 'file' must be provided") 133 | 134 | resp = await self._send_request( 135 | method="post", 136 | params=params, 137 | files=files, 138 | ) 139 | 140 | resp_json = json_loads(resp.text) 141 | resp_json.update({"status_code": resp.status_code}) 142 | 143 | return SauceNAOResponse(resp_json, resp.url) 144 | -------------------------------------------------------------------------------- /.github/scripts/python_eol_check.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Python EOL Check Script 4 | 5 | This script checks if the Python version used in the project is approaching its End of Life (EOL) date. 6 | If the EOL date is within 30 days, it creates a GitHub issue to notify the maintainers. 7 | """ 8 | 9 | import argparse 10 | import sys 11 | from datetime import datetime 12 | 13 | import httpx 14 | from github import Github 15 | 16 | 17 | def get_python_version(): 18 | """ 19 | Read the Python version from the .python-version file. 20 | 21 | Returns: 22 | str: The Python version. 23 | """ 24 | try: 25 | with open(".python-version") as f: 26 | return f.read().strip() 27 | except FileNotFoundError: 28 | print("Error: .python-version file not found.") 29 | sys.exit(1) 30 | 31 | 32 | def get_eol_info(python_version: str): 33 | """ 34 | Get the EOL information for the specified Python version. 35 | 36 | Args: 37 | python_version (str): The Python version to check. 38 | 39 | Returns: 40 | dict: The EOL information. 41 | """ 42 | url = f"https://endoflife.date/api/python/{python_version}.json" 43 | try: 44 | response = httpx.get(url) 45 | response.raise_for_status() 46 | return response.json() 47 | except Exception as e: 48 | print(f"Error fetching EOL information: {e}") 49 | sys.exit(1) 50 | 51 | 52 | def calculate_days_until_eol(eol_date: str) -> int: 53 | """ 54 | Calculate the number of days until the EOL date. 55 | 56 | Args: 57 | eol_date (str): The EOL date in YYYY-MM-DD format. 58 | 59 | Returns: 60 | int: The number of days until the EOL date. 61 | """ 62 | current_date = datetime.now().date() 63 | eol_date_obj = datetime.strptime(eol_date, "%Y-%m-%d").date() 64 | return (eol_date_obj - current_date).days 65 | 66 | 67 | def check_existing_issue(github_client: Github, repo_owner: str, repo_name: str, python_version: str) -> bool: 68 | """ 69 | Check if an issue about the Python EOL already exists. 70 | 71 | Args: 72 | github_client (Github): The GitHub client. 73 | repo_owner (str): The repository owner. 74 | repo_name (str): The repository name. 75 | python_version (str): The Python version. 76 | 77 | Returns: 78 | bool: True if an issue already exists, False otherwise. 79 | """ 80 | repo = github_client.get_repo(f"{repo_owner}/{repo_name}") 81 | issues = repo.get_issues(state="open", creator="github-actions[bot]") 82 | 83 | for issue in issues: 84 | if f"Python {python_version}" in issue.title and "End of Life date" in issue.title: 85 | print(f"Issue already exists: #{issue.number}") 86 | return True 87 | 88 | return False 89 | 90 | 91 | def create_issue( 92 | github_client: Github, repo_owner: str, repo_name: str, python_version: str, eol_date: str, days_until_eol: int 93 | ) -> int: 94 | """ 95 | Create a GitHub issue about the Python EOL. 96 | 97 | Args: 98 | github_client (Github): The GitHub client. 99 | repo_owner (str): The repository owner. 100 | repo_name (str): The repository name. 101 | python_version (str): The Python version. 102 | eol_date (str): The EOL date. 103 | days_until_eol (int): The number of days until the EOL date. 104 | 105 | Returns: 106 | int: The issue number. 107 | """ 108 | repo = github_client.get_repo(f"{repo_owner}/{repo_name}") 109 | 110 | issue_title = f"Python {python_version} will reach End of Life date on {eol_date}" 111 | issue_body = f""" 112 | # Python Version End of Life Reminder 113 | 114 | The current Python version {python_version} used in this project will reach its **End of Life date** on **{eol_date}**, 115 | which is **{days_until_eol}** days from now. 116 | 117 | Please consider upgrading to a newer Python version to ensure the security and stability of the project. 118 | 119 | ## Related Information 120 | 121 | - [Python Version End of Life Information](https://endoflife.date/python) 122 | - [Python Official Download Page](https://www.python.org/downloads/) 123 | 124 | *This issue was created by an automated workflow* 125 | """ 126 | 127 | issue = repo.create_issue(title=issue_title, body=issue_body, labels=["python", "maintenance"]) 128 | 129 | print(f"Issue created: #{issue.number}") 130 | return issue.number 131 | 132 | 133 | def main(): 134 | """ 135 | Main function to check Python EOL and create an issue if needed. 136 | """ 137 | parser = argparse.ArgumentParser(description="Check Python EOL and create GitHub issue if needed.") 138 | parser.add_argument("--repo-owner", required=True, help="Repository owner") 139 | parser.add_argument("--repo-name", required=True, help="Repository name") 140 | parser.add_argument("--github-token", required=True, help="GitHub token") 141 | args = parser.parse_args() 142 | 143 | # Get Python version 144 | python_version = get_python_version() 145 | print(f"Python version: {python_version}") 146 | 147 | # Get EOL information 148 | eol_info = get_eol_info(python_version) 149 | eol_date = eol_info["eol"] 150 | print(f"EOL date: {eol_date}") 151 | 152 | # Calculate days until EOL 153 | days_until_eol = calculate_days_until_eol(eol_date) 154 | print(f"Days until EOL: {days_until_eol}") 155 | 156 | # Check if we need to create an issue 157 | if days_until_eol <= 30: 158 | print("Python version is approaching EOL, checking if issue already exists...") 159 | 160 | # Initialize GitHub client 161 | github_client = Github(args.github_token) 162 | 163 | # Check if issue already exists 164 | if _issue_exists := check_existing_issue(github_client, args.repo_owner, args.repo_name, python_version): 165 | print("Issue already exists, skipping creation.") 166 | else: 167 | # Create issue if it doesn't exist 168 | print("Creating issue...") 169 | create_issue(github_client, args.repo_owner, args.repo_name, python_version, eol_date, days_until_eol) 170 | else: 171 | print(f"Python version is not approaching EOL (more than 30 days left: {days_until_eol} days).") 172 | 173 | 174 | if __name__ == "__main__": 175 | main() 176 | --------------------------------------------------------------------------------