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.
@@ -0,0 +1,17 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .venv/
7
+ venv/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+
11
+ # imgtrail state and output
12
+ imgtrail-data/
13
+ *.html
14
+ findings.json
15
+ .coverage
16
+ coverage.xml
17
+ htmlcov/
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.
@@ -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
@@ -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,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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">&#916;{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" &middot; {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