imgtrail 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- imgtrail-0.1.0/.gitignore +17 -0
- imgtrail-0.1.0/LICENSE +21 -0
- imgtrail-0.1.0/PKG-INFO +156 -0
- imgtrail-0.1.0/README.md +125 -0
- imgtrail-0.1.0/imgtrail/__init__.py +1 -0
- imgtrail-0.1.0/imgtrail/__main__.py +5 -0
- imgtrail-0.1.0/imgtrail/adapters/__init__.py +1 -0
- imgtrail-0.1.0/imgtrail/adapters/html_report.py +158 -0
- imgtrail-0.1.0/imgtrail/adapters/http_fetcher.py +37 -0
- imgtrail-0.1.0/imgtrail/adapters/local_files.py +45 -0
- imgtrail-0.1.0/imgtrail/adapters/sqlite_repository.py +211 -0
- imgtrail-0.1.0/imgtrail/adapters/vision.py +117 -0
- imgtrail-0.1.0/imgtrail/cli.py +198 -0
- imgtrail-0.1.0/imgtrail/domain.py +193 -0
- imgtrail-0.1.0/imgtrail/ports.py +75 -0
- imgtrail-0.1.0/imgtrail/py.typed +0 -0
- imgtrail-0.1.0/imgtrail/services.py +202 -0
- imgtrail-0.1.0/pyproject.toml +112 -0
- imgtrail-0.1.0/tests/__init__.py +0 -0
- imgtrail-0.1.0/tests/conftest.py +118 -0
- imgtrail-0.1.0/tests/test_adapters.py +388 -0
- imgtrail-0.1.0/tests/test_cli.py +115 -0
- imgtrail-0.1.0/tests/test_domain.py +106 -0
- imgtrail-0.1.0/tests/test_services.py +303 -0
imgtrail-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Endika Iglesias
|
|
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.
|
imgtrail-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: imgtrail
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find out where else on the web your own photos show up
|
|
5
|
+
Project-URL: Homepage, https://github.com/Endika/imgtrail
|
|
6
|
+
Project-URL: Repository, https://github.com/Endika/imgtrail
|
|
7
|
+
Project-URL: Issues, https://github.com/Endika/imgtrail/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Endika/imgtrail/blob/main/CHANGELOG.md
|
|
9
|
+
Author-email: Endika Iglesias <endika2@gmail.com>
|
|
10
|
+
License-Expression: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: instagram,osint,phash,privacy,reverse-image-search
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Environment :: Console
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
23
|
+
Classifier: Topic :: Security
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx>=0.27
|
|
27
|
+
Requires-Dist: imagehash>=4.3
|
|
28
|
+
Requires-Dist: pillow>=10
|
|
29
|
+
Requires-Dist: rich>=13
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# imgtrail
|
|
33
|
+
|
|
34
|
+
Find out where else on the web your own photos show up.
|
|
35
|
+
|
|
36
|
+
Point it at your Instagram data export. It hashes every photo, collapses the near-duplicates
|
|
37
|
+
so you never pay to search the same picture twice, runs each unique one through reverse image
|
|
38
|
+
search, and then **downloads every candidate and compares it against your original** before
|
|
39
|
+
putting it in the report. What you get back is a list you can trust, not a pile of URLs.
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
imgtrail scan ~/Downloads/instagram-export.zip --dry-run
|
|
43
|
+
imgtrail scan ~/Downloads/instagram-export.zip
|
|
44
|
+
imgtrail report --open
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## What it finds, and what it doesn't
|
|
48
|
+
|
|
49
|
+
It searches Google's index, so it finds your photos on **blogs, news sites, Pinterest, Tumblr,
|
|
50
|
+
forums, scraper mirrors and shops that lifted your pictures**.
|
|
51
|
+
|
|
52
|
+
It will **not** find a repost on another Instagram account. Instagram blocks crawling of post
|
|
53
|
+
images, so they aren't in anyone's index — the only way such a repost surfaces here is
|
|
54
|
+
indirectly, via one of the many "Instagram viewer" mirror sites that *are* indexed. Telegram,
|
|
55
|
+
WhatsApp, TikTok, Facebook and private accounts are invisible to it too. If your question is
|
|
56
|
+
"is someone reposting me inside Instagram", this is the wrong tool and there isn't a good one.
|
|
57
|
+
|
|
58
|
+
## Install
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
pip install imgtrail
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Getting your photos
|
|
65
|
+
|
|
66
|
+
Instagram → Settings → Accounts Centre → Your information and permissions → **Download your
|
|
67
|
+
information**. Ask for JSON, high quality. You'll get a ZIP; hand it straight to `imgtrail scan`.
|
|
68
|
+
No scraping, nothing against the terms of service, no rate limits.
|
|
69
|
+
|
|
70
|
+
A plain folder of images works just as well.
|
|
71
|
+
|
|
72
|
+
## Getting an API key
|
|
73
|
+
|
|
74
|
+
imgtrail uses Google Cloud Vision's `WEB_DETECTION`. Create a project at
|
|
75
|
+
[console.cloud.google.com](https://console.cloud.google.com), enable the **Cloud Vision API**,
|
|
76
|
+
then Credentials → Create credentials → API key.
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
export IMGTRAIL_API_KEY=AIza...
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**The first 1,000 images each month are free**, then $3.50 per 1,000. A typical profile costs
|
|
83
|
+
nothing. Run `--dry-run` first and it will tell you exactly how many searches it would make and
|
|
84
|
+
what they would cost before spending anything.
|
|
85
|
+
|
|
86
|
+
## How the verification works
|
|
87
|
+
|
|
88
|
+
Reverse image search returns a lot of near-misses. For every candidate, imgtrail downloads the
|
|
89
|
+
image and compares perceptual hashes against your original:
|
|
90
|
+
|
|
91
|
+
| Hamming distance | Verdict | Meaning |
|
|
92
|
+
|---|---|---|
|
|
93
|
+
| ≤ 8 | `confirmed` | The same image, possibly recompressed |
|
|
94
|
+
| ≤ 16 | `likely` | Cropped, filtered or heavily edited |
|
|
95
|
+
| > 16 | `rejected` | Not your photo |
|
|
96
|
+
|
|
97
|
+
Only `confirmed` and `likely` reach the report. `visuallySimilarImages` is dropped entirely —
|
|
98
|
+
it means "semantically alike", not "this is your photo", and it drowns the report in noise.
|
|
99
|
+
|
|
100
|
+
## Commands
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
imgtrail scan SOURCE index, dedupe, search and verify — resumable
|
|
104
|
+
--dry-run count the searches and their cost, call nothing
|
|
105
|
+
--limit N search at most N unique photos
|
|
106
|
+
--threshold N pHash distance for "same photo" (default 6)
|
|
107
|
+
--ignore-domain DOMAIN exclude a domain from results (repeatable)
|
|
108
|
+
--no-verify skip the download-and-compare pass
|
|
109
|
+
imgtrail report --open build the HTML report and open it
|
|
110
|
+
imgtrail status what's in the database so far
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
State lives in `./imgtrail-data`. Everything is idempotent: re-running `scan` searches only
|
|
114
|
+
what it hasn't searched before, so an interrupted run costs nothing to resume.
|
|
115
|
+
|
|
116
|
+
## Privacy
|
|
117
|
+
|
|
118
|
+
Your photos are sent to Google Cloud Vision, and nowhere else. Nothing is uploaded to any
|
|
119
|
+
server of mine — there isn't one. The database, the extracted export and the report all stay
|
|
120
|
+
on your machine.
|
|
121
|
+
|
|
122
|
+
## Architecture
|
|
123
|
+
|
|
124
|
+
Ports and adapters, sized to the problem: the rules sit in the middle and know nothing
|
|
125
|
+
about Google, SQLite or HTTP, so swapping a search backend touches exactly one file.
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
domain.py fingerprints, grouping, verdicts, what counts as "your own platform"
|
|
129
|
+
— pure; no I/O, no SQL, no network
|
|
130
|
+
ports.py the boundaries: PhotoSource, ImageLoader, SearchEngine, ImageFetcher,
|
|
131
|
+
PhotoRepository, MatchRepository, ReportWriter
|
|
132
|
+
services.py the use cases: index, plan, search, verify, report
|
|
133
|
+
adapters/ the details: sqlite_repository, vision, http_fetcher, local_files, html_report
|
|
134
|
+
cli.py the composition root — the one module that knows every layer
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Adding TinEye or Yandex means writing one `SearchEngine` and wiring it in `cli.py`. Nothing
|
|
138
|
+
in `domain.py` or `services.py` changes.
|
|
139
|
+
|
|
140
|
+
## Development
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
uv sync --all-groups
|
|
144
|
+
uv run pytest # 85 tests, no network, no mocks
|
|
145
|
+
uv run ruff check .
|
|
146
|
+
uv run ruff format .
|
|
147
|
+
uv run mypy # strict, and it passes on the tests too
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The test doubles are real implementations, not mocks: an in-memory `DictPhotoSource`, a
|
|
151
|
+
`FakeSearchEngine` that records what it was asked, and — where the wire itself is what needs
|
|
152
|
+
testing — a real local HTTP server speaking Vision's JSON.
|
|
153
|
+
|
|
154
|
+
## Licence
|
|
155
|
+
|
|
156
|
+
MIT
|
imgtrail-0.1.0/README.md
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# imgtrail
|
|
2
|
+
|
|
3
|
+
Find out where else on the web your own photos show up.
|
|
4
|
+
|
|
5
|
+
Point it at your Instagram data export. It hashes every photo, collapses the near-duplicates
|
|
6
|
+
so you never pay to search the same picture twice, runs each unique one through reverse image
|
|
7
|
+
search, and then **downloads every candidate and compares it against your original** before
|
|
8
|
+
putting it in the report. What you get back is a list you can trust, not a pile of URLs.
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
imgtrail scan ~/Downloads/instagram-export.zip --dry-run
|
|
12
|
+
imgtrail scan ~/Downloads/instagram-export.zip
|
|
13
|
+
imgtrail report --open
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## What it finds, and what it doesn't
|
|
17
|
+
|
|
18
|
+
It searches Google's index, so it finds your photos on **blogs, news sites, Pinterest, Tumblr,
|
|
19
|
+
forums, scraper mirrors and shops that lifted your pictures**.
|
|
20
|
+
|
|
21
|
+
It will **not** find a repost on another Instagram account. Instagram blocks crawling of post
|
|
22
|
+
images, so they aren't in anyone's index — the only way such a repost surfaces here is
|
|
23
|
+
indirectly, via one of the many "Instagram viewer" mirror sites that *are* indexed. Telegram,
|
|
24
|
+
WhatsApp, TikTok, Facebook and private accounts are invisible to it too. If your question is
|
|
25
|
+
"is someone reposting me inside Instagram", this is the wrong tool and there isn't a good one.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
pip install imgtrail
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Getting your photos
|
|
34
|
+
|
|
35
|
+
Instagram → Settings → Accounts Centre → Your information and permissions → **Download your
|
|
36
|
+
information**. Ask for JSON, high quality. You'll get a ZIP; hand it straight to `imgtrail scan`.
|
|
37
|
+
No scraping, nothing against the terms of service, no rate limits.
|
|
38
|
+
|
|
39
|
+
A plain folder of images works just as well.
|
|
40
|
+
|
|
41
|
+
## Getting an API key
|
|
42
|
+
|
|
43
|
+
imgtrail uses Google Cloud Vision's `WEB_DETECTION`. Create a project at
|
|
44
|
+
[console.cloud.google.com](https://console.cloud.google.com), enable the **Cloud Vision API**,
|
|
45
|
+
then Credentials → Create credentials → API key.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
export IMGTRAIL_API_KEY=AIza...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**The first 1,000 images each month are free**, then $3.50 per 1,000. A typical profile costs
|
|
52
|
+
nothing. Run `--dry-run` first and it will tell you exactly how many searches it would make and
|
|
53
|
+
what they would cost before spending anything.
|
|
54
|
+
|
|
55
|
+
## How the verification works
|
|
56
|
+
|
|
57
|
+
Reverse image search returns a lot of near-misses. For every candidate, imgtrail downloads the
|
|
58
|
+
image and compares perceptual hashes against your original:
|
|
59
|
+
|
|
60
|
+
| Hamming distance | Verdict | Meaning |
|
|
61
|
+
|---|---|---|
|
|
62
|
+
| ≤ 8 | `confirmed` | The same image, possibly recompressed |
|
|
63
|
+
| ≤ 16 | `likely` | Cropped, filtered or heavily edited |
|
|
64
|
+
| > 16 | `rejected` | Not your photo |
|
|
65
|
+
|
|
66
|
+
Only `confirmed` and `likely` reach the report. `visuallySimilarImages` is dropped entirely —
|
|
67
|
+
it means "semantically alike", not "this is your photo", and it drowns the report in noise.
|
|
68
|
+
|
|
69
|
+
## Commands
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
imgtrail scan SOURCE index, dedupe, search and verify — resumable
|
|
73
|
+
--dry-run count the searches and their cost, call nothing
|
|
74
|
+
--limit N search at most N unique photos
|
|
75
|
+
--threshold N pHash distance for "same photo" (default 6)
|
|
76
|
+
--ignore-domain DOMAIN exclude a domain from results (repeatable)
|
|
77
|
+
--no-verify skip the download-and-compare pass
|
|
78
|
+
imgtrail report --open build the HTML report and open it
|
|
79
|
+
imgtrail status what's in the database so far
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
State lives in `./imgtrail-data`. Everything is idempotent: re-running `scan` searches only
|
|
83
|
+
what it hasn't searched before, so an interrupted run costs nothing to resume.
|
|
84
|
+
|
|
85
|
+
## Privacy
|
|
86
|
+
|
|
87
|
+
Your photos are sent to Google Cloud Vision, and nowhere else. Nothing is uploaded to any
|
|
88
|
+
server of mine — there isn't one. The database, the extracted export and the report all stay
|
|
89
|
+
on your machine.
|
|
90
|
+
|
|
91
|
+
## Architecture
|
|
92
|
+
|
|
93
|
+
Ports and adapters, sized to the problem: the rules sit in the middle and know nothing
|
|
94
|
+
about Google, SQLite or HTTP, so swapping a search backend touches exactly one file.
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
domain.py fingerprints, grouping, verdicts, what counts as "your own platform"
|
|
98
|
+
— pure; no I/O, no SQL, no network
|
|
99
|
+
ports.py the boundaries: PhotoSource, ImageLoader, SearchEngine, ImageFetcher,
|
|
100
|
+
PhotoRepository, MatchRepository, ReportWriter
|
|
101
|
+
services.py the use cases: index, plan, search, verify, report
|
|
102
|
+
adapters/ the details: sqlite_repository, vision, http_fetcher, local_files, html_report
|
|
103
|
+
cli.py the composition root — the one module that knows every layer
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Adding TinEye or Yandex means writing one `SearchEngine` and wiring it in `cli.py`. Nothing
|
|
107
|
+
in `domain.py` or `services.py` changes.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
uv sync --all-groups
|
|
113
|
+
uv run pytest # 85 tests, no network, no mocks
|
|
114
|
+
uv run ruff check .
|
|
115
|
+
uv run ruff format .
|
|
116
|
+
uv run mypy # strict, and it passes on the tests too
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The test doubles are real implementations, not mocks: an in-memory `DictPhotoSource`, a
|
|
120
|
+
`FakeSearchEngine` that records what it was asked, and — where the wire itself is what needs
|
|
121
|
+
testing — a real local HTTP server speaking Vision's JSON.
|
|
122
|
+
|
|
123
|
+
## Licence
|
|
124
|
+
|
|
125
|
+
MIT
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Concrete implementations of the ports. Nothing here is imported by the domain."""
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Renders a report as a self-contained HTML page, or as JSON."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import html
|
|
7
|
+
import io
|
|
8
|
+
import json
|
|
9
|
+
from dataclasses import asdict
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
from imgtrail.domain import Report, Verdict
|
|
15
|
+
from imgtrail.ports import ImageLoader
|
|
16
|
+
|
|
17
|
+
THUMBNAIL_PX = 160
|
|
18
|
+
BADGE_CLASS = {Verdict.CONFIRMED: "ok", Verdict.LIKELY: "warn"}
|
|
19
|
+
|
|
20
|
+
CSS = """
|
|
21
|
+
:root { --bg:#fbfbfa; --fg:#1a1a19; --muted:#6b6b68; --card:#fff; --line:#e5e4e0;
|
|
22
|
+
--ok:#1a7f4b; --ok-bg:#e6f4ec; --warn:#8a5a00; --warn-bg:#fdf3e0; --accent:#c2410c; }
|
|
23
|
+
@media (prefers-color-scheme: dark) {
|
|
24
|
+
:root { --bg:#141413; --fg:#eeeeec; --muted:#9a9a96; --card:#1e1e1c; --line:#33332f;
|
|
25
|
+
--ok:#6ee7a8; --ok-bg:#12291d; --warn:#f0c674; --warn-bg:#2b2110; --accent:#fb923c; }
|
|
26
|
+
}
|
|
27
|
+
* { box-sizing:border-box; }
|
|
28
|
+
body { margin:0; padding:2.5rem 1.5rem; background:var(--bg); color:var(--fg);
|
|
29
|
+
font:15px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
|
30
|
+
.wrap { max-width:900px; margin:0 auto; }
|
|
31
|
+
h1 { font-size:1.6rem; margin:0 0 .3rem; letter-spacing:-.02em; }
|
|
32
|
+
.sub { color:var(--muted); margin:0 0 2rem; }
|
|
33
|
+
.stats { display:flex; flex-wrap:wrap; gap:2rem; padding:1rem 1.25rem; margin-bottom:2rem;
|
|
34
|
+
background:var(--card); border:1px solid var(--line); border-radius:10px; }
|
|
35
|
+
.stat b { display:block; font-size:1.5rem; letter-spacing:-.02em; }
|
|
36
|
+
.stat span { color:var(--muted); font-size:.8rem; text-transform:uppercase; letter-spacing:.06em; }
|
|
37
|
+
.card { display:flex; gap:1.25rem; padding:1.25rem; margin-bottom:1rem; background:var(--card);
|
|
38
|
+
border:1px solid var(--line); border-radius:10px; }
|
|
39
|
+
.card img { width:120px; height:120px; object-fit:cover; border-radius:8px; flex:none;
|
|
40
|
+
background:var(--line); }
|
|
41
|
+
.card h2 { font-size:.95rem; margin:0 0 .1rem; }
|
|
42
|
+
.card .meta { color:var(--muted); font-size:.8rem; margin:0 0 .8rem; }
|
|
43
|
+
.card > div { min-width:0; flex:1; }
|
|
44
|
+
ul { list-style:none; margin:0; padding:0; }
|
|
45
|
+
li { padding:.45rem 0; border-top:1px solid var(--line); font-size:.9rem;
|
|
46
|
+
display:flex; gap:.6rem; align-items:baseline; flex-wrap:wrap; }
|
|
47
|
+
a { color:var(--accent); text-decoration:none; word-break:break-all; }
|
|
48
|
+
a:hover { text-decoration:underline; }
|
|
49
|
+
.badge { font-size:.7rem; padding:.12rem .45rem; border-radius:99px; flex:none;
|
|
50
|
+
text-transform:uppercase; letter-spacing:.05em; font-weight:600; }
|
|
51
|
+
.badge.ok { color:var(--ok); background:var(--ok-bg); }
|
|
52
|
+
.badge.warn { color:var(--warn); background:var(--warn-bg); }
|
|
53
|
+
.dom { font-weight:600; }
|
|
54
|
+
.empty { padding:3rem; text-align:center; color:var(--muted); background:var(--card);
|
|
55
|
+
border:1px solid var(--line); border-radius:10px; }
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class HtmlReportWriter:
|
|
60
|
+
def __init__(self, loader: ImageLoader, thumbnail_px: int = THUMBNAIL_PX) -> None:
|
|
61
|
+
self._loader = loader
|
|
62
|
+
self._px = thumbnail_px
|
|
63
|
+
|
|
64
|
+
def write(self, report: Report, destination: Path) -> None:
|
|
65
|
+
destination.write_text(self._render(report), encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
def _thumbnail(self, reference: str) -> str:
|
|
68
|
+
try:
|
|
69
|
+
with Image.open(io.BytesIO(self._loader.load(reference))) as opened:
|
|
70
|
+
converted = opened.convert("RGB")
|
|
71
|
+
converted.thumbnail((self._px, self._px))
|
|
72
|
+
buffer = io.BytesIO()
|
|
73
|
+
converted.save(buffer, format="PNG", optimize=True)
|
|
74
|
+
except (OSError, ValueError):
|
|
75
|
+
return ""
|
|
76
|
+
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
|
|
77
|
+
|
|
78
|
+
def _render(self, report: Report) -> str:
|
|
79
|
+
escape = html.escape
|
|
80
|
+
cards = []
|
|
81
|
+
for finding in report.findings:
|
|
82
|
+
items = []
|
|
83
|
+
for match in finding.matches:
|
|
84
|
+
distance = (
|
|
85
|
+
f'<span class="meta">Δ{match.distance}</span>'
|
|
86
|
+
if match.distance is not None
|
|
87
|
+
else ""
|
|
88
|
+
)
|
|
89
|
+
label = escape((match.title or match.target)[:110])
|
|
90
|
+
items.append(
|
|
91
|
+
f'<li><span class="badge {BADGE_CLASS[match.verdict]}">'
|
|
92
|
+
f"{escape(match.verdict.value)}</span>"
|
|
93
|
+
f'<span class="dom">{escape(match.domain or "")}</span>'
|
|
94
|
+
f'<a href="{escape(match.target)}" target="_blank" rel="noreferrer">'
|
|
95
|
+
f"{label}</a>{distance}</li>"
|
|
96
|
+
)
|
|
97
|
+
copies = (
|
|
98
|
+
f" · {finding.copies} copies in your profile" if finding.copies > 1 else ""
|
|
99
|
+
)
|
|
100
|
+
cards.append(
|
|
101
|
+
f'<div class="card"><img src="{self._thumbnail(finding.photo.path)}" alt="">'
|
|
102
|
+
f"<div><h2>{escape(Path(finding.photo.path).name)}</h2>"
|
|
103
|
+
f'<p class="meta">{len(finding.matches)} matches{copies}</p>'
|
|
104
|
+
f"<ul>{''.join(items)}</ul></div></div>"
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
body = "".join(cards) or (
|
|
108
|
+
'<div class="empty">None of your photos turned up on another site.</div>'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
summary, tally = report.summary, report.summary.tally
|
|
112
|
+
tiles = [
|
|
113
|
+
("Photos", summary.photos),
|
|
114
|
+
("Unique", summary.unique),
|
|
115
|
+
("Searched", summary.searched),
|
|
116
|
+
("Confirmed", tally.get(Verdict.CONFIRMED, 0)),
|
|
117
|
+
("Likely", tally.get(Verdict.LIKELY, 0)),
|
|
118
|
+
("Discarded", tally.get(Verdict.REJECTED, 0) + tally.get(Verdict.UNREACHABLE, 0)),
|
|
119
|
+
]
|
|
120
|
+
stats = "".join(f'<div class="stat"><b>{v}</b><span>{k}</span></div>' for k, v in tiles)
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
'<!doctype html><html lang="en"><head><meta charset="utf-8">'
|
|
124
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">'
|
|
125
|
+
f'<title>imgtrail</title><style>{CSS}</style></head><body><div class="wrap">'
|
|
126
|
+
"<h1>Where did your photos end up?</h1>"
|
|
127
|
+
'<p class="sub">Verified matches outside Instagram.</p>'
|
|
128
|
+
f'<div class="stats">{stats}</div>{body}</div></body></html>'
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class JsonReportWriter:
|
|
133
|
+
def write(self, report: Report, destination: Path) -> None:
|
|
134
|
+
payload = {
|
|
135
|
+
"summary": {
|
|
136
|
+
"photos": report.summary.photos,
|
|
137
|
+
"unique": report.summary.unique,
|
|
138
|
+
"searched": report.summary.searched,
|
|
139
|
+
"tally": {v.value: n for v, n in report.summary.tally.items()},
|
|
140
|
+
},
|
|
141
|
+
"findings": [
|
|
142
|
+
{
|
|
143
|
+
"photo": finding.photo.path,
|
|
144
|
+
"copies": finding.copies,
|
|
145
|
+
"matches": [
|
|
146
|
+
{
|
|
147
|
+
**asdict(m),
|
|
148
|
+
"kind": m.kind.value,
|
|
149
|
+
"verdict": m.verdict.value,
|
|
150
|
+
"domain": m.domain,
|
|
151
|
+
}
|
|
152
|
+
for m in finding.matches
|
|
153
|
+
],
|
|
154
|
+
}
|
|
155
|
+
for finding in report.findings
|
|
156
|
+
],
|
|
157
|
+
}
|
|
158
|
+
destination.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Downloads candidate images so they can be checked against the original."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
MAX_BYTES = 25 * 1024 * 1024
|
|
8
|
+
USER_AGENT = "Mozilla/5.0 (compatible; imgtrail/0.1; +reverse-image-verification)"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class HttpImageFetcher:
|
|
12
|
+
def __init__(self, timeout: float = 20.0, client: httpx.Client | None = None) -> None:
|
|
13
|
+
self._client = client or httpx.Client(
|
|
14
|
+
timeout=timeout, headers={"User-Agent": USER_AGENT}, follow_redirects=True
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
def close(self) -> None:
|
|
18
|
+
self._client.close()
|
|
19
|
+
|
|
20
|
+
def __enter__(self) -> HttpImageFetcher:
|
|
21
|
+
return self
|
|
22
|
+
|
|
23
|
+
def __exit__(self, *exc: object) -> None:
|
|
24
|
+
self.close()
|
|
25
|
+
|
|
26
|
+
def fetch(self, url: str) -> bytes | None:
|
|
27
|
+
"""None whenever the URL does not yield an image we can compare."""
|
|
28
|
+
try:
|
|
29
|
+
response = self._client.get(url)
|
|
30
|
+
response.raise_for_status()
|
|
31
|
+
except httpx.HTTPError:
|
|
32
|
+
return None
|
|
33
|
+
if not response.headers.get("content-type", "").startswith("image/"):
|
|
34
|
+
return None
|
|
35
|
+
if len(response.content) > MAX_BYTES:
|
|
36
|
+
return None
|
|
37
|
+
return response.content
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Reads photos from an Instagram export archive or any folder of images."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import zipfile
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
EXTENSIONS = frozenset({".jpg", ".jpeg", ".png", ".webp", ".heic", ".bmp"})
|
|
10
|
+
|
|
11
|
+
SKIP_DIRECTORIES = frozenset({"profile", "other", "messages", "stories_activity", "avatars"})
|
|
12
|
+
"""The export ships avatars and other people's content next to your own posts."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FileImageLoader:
|
|
16
|
+
"""Reads a photo back from disk. A reference is an absolute path."""
|
|
17
|
+
|
|
18
|
+
def load(self, reference: str) -> bytes:
|
|
19
|
+
return Path(reference).read_bytes()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LocalPhotoSource(FileImageLoader):
|
|
23
|
+
"""Implements both PhotoSource and ImageLoader; a reference is an absolute path."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, source: Path, workspace: Path) -> None:
|
|
26
|
+
self._source = source
|
|
27
|
+
self._workspace = workspace
|
|
28
|
+
|
|
29
|
+
def photos(self) -> Iterator[str]:
|
|
30
|
+
root = self._unpacked()
|
|
31
|
+
for path in sorted(root.rglob("*")):
|
|
32
|
+
if path.suffix.lower() not in EXTENSIONS:
|
|
33
|
+
continue
|
|
34
|
+
if SKIP_DIRECTORIES & {part.lower() for part in path.parts}:
|
|
35
|
+
continue
|
|
36
|
+
yield str(path.resolve())
|
|
37
|
+
|
|
38
|
+
def _unpacked(self) -> Path:
|
|
39
|
+
if self._source.is_file() and self._source.suffix.lower() == ".zip":
|
|
40
|
+
target = self._workspace / "extracted"
|
|
41
|
+
if not target.exists():
|
|
42
|
+
with zipfile.ZipFile(self._source) as archive:
|
|
43
|
+
archive.extractall(target)
|
|
44
|
+
return target
|
|
45
|
+
return self._source
|