├── .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 |
11 |
12 |
13 |
14 |
15 |

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 | - 或使用清华镜像:`pip install PicImageSearch -i https://pypi.tuna.tsinghua.edu.cn/simple`
60 |
61 | ## 星标历史
62 |
63 | [](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 |
11 |
12 |
13 |
14 |
15 |

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)
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 |
11 |
12 |
13 |
14 |
15 |

16 |
17 |
18 |
19 |
20 |
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 | [](https://starchart.cc/kitUIN/PicImageSearch)
64 |
--------------------------------------------------------------------------------
/docs/ru/README.ru-RU.md:
--------------------------------------------------------------------------------
1 |
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)
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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
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 | - { .lg .middle } AnimeTrace
51 |
52 | ---
53 |
54 | 以图识番 - 在线 AI 识番引擎 | 日漫识别 | 动漫查询 | 动漫基因库
55 |
56 | - { .lg .middle } Ascii2D
57 |
58 | ---
59 |
60 | 二次元画像詳細検索
61 |
62 | - { .lg .middle } BaiDu
63 |
64 | ---
65 |
66 | 百度图片
67 |
68 | - { .lg .middle } Bing
69 |
70 | ---
71 |
72 | Bing Images
73 |
74 | - { .lg .middle } Copyseeker
75 |
76 | ---
77 |
78 | The Best Free AI Powered Reverse Image Search Like No Other
79 |
80 | - { .lg .middle } E-hentai
81 |
82 | ---
83 |
84 | E-Hentai Galleries
85 |
86 | - { .lg .middle } Google
87 |
88 | ---
89 |
90 | Google Images
91 |
92 | - { .lg .middle } Google Lens
93 |
94 | ---
95 |
96 | Google Lens
97 |
98 | - { .lg .middle } Iqdb
99 |
100 | ---
101 |
102 | Multi-service image search
103 |
104 | - { .lg .middle } Lenso
105 |
106 | ---
107 |
108 | Lenso.ai - AI Reverse Image Search
109 |
110 | - { .lg .middle } SauceNAO
111 |
112 | ---
113 |
114 | SauceNAO Reverse Image Search
115 |
116 | - { .lg .middle } Tineye
117 |
118 | ---
119 |
120 | TinEye Reverse Image Search
121 |
122 | - { .lg .middle } TraceMoe
123 |
124 | ---
125 |
126 | Anime Scene Search Engine
127 |
128 | - { .lg .middle } Yandex
129 |
130 | ---
131 |
132 | Yandex Images
133 |
134 |
135 |
136 | ## 项目贡献者
137 |
138 |
139 |
140 | - { .lg .middle } Neko Aria
141 |
142 | ---
143 |
144 | 项目维护者
145 |
146 | - { .lg .middle } kitUIN
147 |
148 | ---
149 |
150 | 项目创建者
151 |
152 | - { .lg .middle } Peloxerat
153 |
154 | ---
155 |
156 | 项目贡献者
157 |
158 | - { .lg .middle } lleans
159 |
160 | ---
161 |
162 | 项目贡献者
163 |
164 | - { .lg .middle } chinoll
165 |
166 | ---
167 |
168 | 项目贡献者
169 |
170 | - { .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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
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 | - { .lg .middle } AnimeTrace
51 |
52 | ---
53 |
54 | 以图识番 - 在线 AI 识番引擎 | 日漫识别 | 动漫查询 | 动漫基因库
55 |
56 | - { .lg .middle } Ascii2D
57 |
58 | ---
59 |
60 | 二次元画像詳細検索
61 |
62 | - { .lg .middle } BaiDu
63 |
64 | ---
65 |
66 | 百度图片
67 |
68 | - { .lg .middle } Bing
69 |
70 | ---
71 |
72 | Bing Images
73 |
74 | - { .lg .middle } Copyseeker
75 |
76 | ---
77 |
78 | The Best Free AI Powered Reverse Image Search Like No Other
79 |
80 | - { .lg .middle } E-hentai
81 |
82 | ---
83 |
84 | E-Hentai Galleries
85 |
86 | - { .lg .middle } Google
87 |
88 | ---
89 |
90 | Google Images
91 |
92 | - { .lg .middle } Google Lens
93 |
94 | ---
95 |
96 | Google Lens
97 |
98 | - { .lg .middle } Iqdb
99 |
100 | ---
101 |
102 | Multi-service image search
103 |
104 | - { .lg .middle } Lenso
105 |
106 | ---
107 |
108 | Lenso.ai - AI Reverse Image Search
109 |
110 | - { .lg .middle } SauceNAO
111 |
112 | ---
113 |
114 | SauceNAO Reverse Image Search
115 |
116 | - { .lg .middle } Tineye
117 |
118 | ---
119 |
120 | TinEye Reverse Image Search
121 |
122 | - { .lg .middle } TraceMoe
123 |
124 | ---
125 |
126 | Anime Scene Search Engine
127 |
128 | - { .lg .middle } Yandex
129 |
130 | ---
131 |
132 | Yandex Images
133 |
134 |
135 |
136 | ## このプロジェクトの貢献者
137 |
138 |
139 |
140 | - { .lg .middle } Neko Aria
141 |
142 | ---
143 |
144 | プロジェクトのメイン管理者
145 |
146 | - { .lg .middle } kitUIN
147 |
148 | ---
149 |
150 | プロジェクトオーナー
151 |
152 | - { .lg .middle } Peloxerat
153 |
154 | ---
155 |
156 | プロジェクト貢献者
157 |
158 | - { .lg .middle } lleans
159 |
160 | ---
161 |
162 | プロジェクト貢献者
163 |
164 | - { .lg .middle } chinoll
165 |
166 | ---
167 |
168 | プロジェクト貢献者
169 |
170 | - { .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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
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 | - { .lg .middle } AnimeTrace
51 |
52 | ---
53 |
54 | 以图识番 - 在线 AI 识番引擎 | 日漫识别 | 动漫查询 | 动漫基因库
55 |
56 | - { .lg .middle } Ascii2D
57 |
58 | ---
59 |
60 | 二次元画像詳細検索
61 |
62 | - { .lg .middle } BaiDu
63 |
64 | ---
65 |
66 | 百度图片
67 |
68 | - { .lg .middle } Bing
69 |
70 | ---
71 |
72 | Bing Images
73 |
74 | - { .lg .middle } Copyseeker
75 |
76 | ---
77 |
78 | The Best Free AI Powered Reverse Image Search Like No Other
79 |
80 | - { .lg .middle } E-hentai
81 |
82 | ---
83 |
84 | E-Hentai Galleries
85 |
86 | - { .lg .middle } Google
87 |
88 | ---
89 |
90 | Google Images
91 |
92 | - { .lg .middle } Google Lens
93 |
94 | ---
95 |
96 | Google Lens
97 |
98 | - { .lg .middle } Iqdb
99 |
100 | ---
101 |
102 | Multi-service image search
103 |
104 | - { .lg .middle } Lenso
105 |
106 | ---
107 |
108 | Lenso.ai - AI Reverse Image Search
109 |
110 | - { .lg .middle } SauceNAO
111 |
112 | ---
113 |
114 | SauceNAO Reverse Image Search
115 |
116 | - { .lg .middle } Tineye
117 |
118 | ---
119 |
120 | TinEye Reverse Image Search
121 |
122 | - { .lg .middle } TraceMoe
123 |
124 | ---
125 |
126 | Anime Scene Search Engine
127 |
128 | - { .lg .middle } Yandex
129 |
130 | ---
131 |
132 | Yandex Images
133 |
134 |
135 |
136 | ## Участники этого проекта
137 |
138 |
139 |
140 | - { .lg .middle } Neko Aria
141 |
142 | ---
143 |
144 | Основной Сопровождающий Проекта
145 |
146 | - { .lg .middle } kitUIN
147 |
148 | ---
149 |
150 | Владелец Проекта
151 |
152 | - { .lg .middle } Peloxerat
153 |
154 | ---
155 |
156 | Участник Проекта
157 |
158 | - { .lg .middle } lleans
159 |
160 | ---
161 |
162 | Участник Проекта
163 |
164 | - { .lg .middle } chinoll
165 |
166 | ---
167 |
168 | Участник Проекта
169 |
170 | - { .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 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
20 |
21 |
22 |
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 | - { .lg .middle } AnimeTrace
51 |
52 | ---
53 |
54 | 以图识番 - 在线 AI 识番引擎 | 日漫识别 | 动漫查询 | 动漫基因库
55 |
56 | - { .lg .middle } Ascii2D
57 |
58 | ---
59 |
60 | 二次元画像詳細検索
61 |
62 | - { .lg .middle } BaiDu
63 |
64 | ---
65 |
66 | 百度图片
67 |
68 | - { .lg .middle } Bing
69 |
70 | ---
71 |
72 | Bing Images
73 |
74 | - { .lg .middle } Copyseeker
75 |
76 | ---
77 |
78 | The Best Free AI Powered Reverse Image Search Like No Other
79 |
80 | - { .lg .middle } E-hentai
81 |
82 | ---
83 |
84 | E-Hentai Galleries
85 |
86 | - { .lg .middle } Google
87 |
88 | ---
89 |
90 | Google Images
91 |
92 | - { .lg .middle } Google Lens
93 |
94 | ---
95 |
96 | Google Lens
97 |
98 | - { .lg .middle } Iqdb
99 |
100 | ---
101 |
102 | Multi-service image search
103 |
104 | - { .lg .middle } Lenso
105 |
106 | ---
107 |
108 | Lenso.ai - AI Reverse Image Search
109 |
110 | - { .lg .middle } SauceNAO
111 |
112 | ---
113 |
114 | SauceNAO Reverse Image Search
115 |
116 | - { .lg .middle } Tineye
117 |
118 | ---
119 |
120 | TinEye Reverse Image Search
121 |
122 | - { .lg .middle } TraceMoe
123 |
124 | ---
125 |
126 | Anime Scene Search Engine
127 |
128 | - { .lg .middle } Yandex
129 |
130 | ---
131 |
132 | Yandex Images
133 |
134 |
135 |
136 | ## Contributors to this project
137 |
138 |
139 |
140 | - { .lg .middle } Neko Aria
141 |
142 | ---
143 |
144 | Project Maintainer
145 |
146 | - { .lg .middle } kitUIN
147 |
148 | ---
149 |
150 | Project Owner
151 |
152 | - { .lg .middle } Peloxerat
153 |
154 | ---
155 |
156 | Project Contributor
157 |
158 | - { .lg .middle } lleans
159 |
160 | ---
161 |
162 | Project Contributor
163 |
164 | - { .lg .middle } chinoll
165 |
166 | ---
167 |
168 | Project Contributor
169 |
170 | - { .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 |
--------------------------------------------------------------------------------