Skip to content

Commit d6915b4

Browse files
authored
Avoid storing Flickr secret-bearing URLs (#46)
1 parent 90e4750 commit d6915b4

4 files changed

Lines changed: 45 additions & 35 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,9 +76,9 @@ python3 flickr_scraper.py --search 'honeybees on flowers' --n 10 --download
7676
You should see output indicating the download progress:
7777

7878
```plaintext
79-
1/10 https://live.staticflickr.com/21/38596887_40df118fd9_o.jpg
79+
1/10 downloaded
8080
...
81-
10/10 https://live.staticflickr.com/1770/43276172331_e779b8c161_o.jpg
81+
10/10 downloaded
8282
Done. (4.1s)
8383
All images saved to /Users/glennjocher/PycharmProjects/flickr_scraper/images/honeybees_on_flowers/
8484
```
@@ -101,6 +101,8 @@ The script de-duplicates URLs within each run, but it does not keep a persistent
101101

102102
This scraper uses Flickr's JSON `photos.search` API response directly. It does not call `flickrapi.walk()`, avoiding the older `getchildren()` compatibility error reported with `flickrapi==2.4.0` on Python 3.9 and newer.
103103

104+
Photos missing Flickr's direct `url_o` field are skipped instead of constructing fallback URLs from Flickr metadata fields. This avoids propagating secret-bearing fields into download paths or logs.
105+
104106
If Flickr returns a `500` or another API error, the script reports the Flickr error code and message. These errors usually come from Flickr or invalid credentials; retry the command after checking your API key and secret.
105107

106108
## 📜 Citation

flickr_scraper.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,8 @@
1616

1717

1818
def build_photo_url(photo):
19-
"""Return the best available static image URL from a Flickr photo response."""
20-
url = photo.get("url_o") # original size
21-
return url or (
22-
f"https://farm{photo.get('farm')}.staticflickr.com/"
23-
f"{photo.get('server')}/{photo.get('id')}_{photo.get('secret')}_b.jpg"
24-
)
19+
"""Return the Flickr-provided original image URL when available."""
20+
return photo.get("url_o")
2521

2622

2723
def resolve_credentials(api_key=None, api_secret=None):
@@ -79,14 +75,18 @@ def get_urls(search="honeybees on flowers", n=10, download=False, api_key=None,
7975

8076
for photo in photo_list:
8177
url = build_photo_url(photo)
78+
if url is None:
79+
print("skipped (missing original URL)")
80+
continue
81+
8282
if url in urls:
8383
continue
8484

8585
if download:
8686
download_uri(url, dir_path)
8787

8888
urls.append(url)
89-
print(f"{len(urls)}/{n} {url}")
89+
print(f"{len(urls)}/{n} {'downloaded' if download else 'found'}")
9090

9191
if len(urls) >= n:
9292
break

tests/test_flickr_scraper.py

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import pytest
66

77
import flickr_scraper
8-
from utils.general import download_uri
8+
from utils.general import download_uri, safe_filename_from_uri
99

1010

1111
def test_build_photo_url_prefers_original_url():
@@ -21,11 +21,11 @@ def test_build_photo_url_prefers_original_url():
2121
assert flickr_scraper.build_photo_url(photo) == "https://live.staticflickr.com/photo_o.jpg"
2222

2323

24-
def test_build_photo_url_falls_back_to_large_static_url():
25-
"""Ensure a static Flickr URL is built when the original URL is unavailable."""
24+
def test_build_photo_url_skips_photos_without_original_url():
25+
"""Ensure secret-bearing fallback URLs are not built."""
2626
photo = {"farm": "1", "server": "2", "id": "3", "secret": "4"}
2727

28-
assert flickr_scraper.build_photo_url(photo) == "https://farm1.staticflickr.com/2/3_4_b.jpg"
28+
assert flickr_scraper.build_photo_url(photo) is None
2929

3030

3131
def test_resolve_credentials_requires_key_and_secret(monkeypatch):
@@ -39,8 +39,8 @@ def test_resolve_credentials_requires_key_and_secret(monkeypatch):
3939
flickr_scraper.resolve_credentials()
4040

4141

42-
def test_get_urls_uses_json_search_and_honors_limit(monkeypatch):
43-
"""Ensure get_urls uses photos.search and returns exactly the requested number of URLs."""
42+
def test_get_urls_uses_json_search_and_redacts_output(monkeypatch, capsys):
43+
"""Ensure get_urls uses photos.search, skips secret fallback URLs, and redacts output."""
4444
calls = []
4545

4646
class FakePhotos:
@@ -69,8 +69,12 @@ def __init__(self, api_key, api_secret, format):
6969
monkeypatch.setattr(flickr_scraper, "FlickrAPI", FakeFlickr)
7070

7171
urls = flickr_scraper.get_urls("bees", n=2, api_key="key", api_secret="secret", page=3)
72+
stdout = capsys.readouterr().out
7273

73-
assert urls == ["https://example.com/1.jpg", "https://farm1.staticflickr.com/2/3_4_b.jpg"]
74+
assert urls == ["https://example.com/1.jpg", "https://example.com/extra.jpg"]
75+
assert "https://example.com" not in stdout
76+
assert "3_4_b.jpg" not in stdout
77+
assert "skipped (missing original URL)" in stdout
7478
assert calls == [
7579
{
7680
"text": "bees",
@@ -132,3 +136,10 @@ def raise_for_status(self):
132136
download_uri("https://example.com/folder/photo%20name(1).jpg?size=o", tmp_path)
133137

134138
assert (Path(tmp_path) / "photo_name_1_.jpg").read_bytes() == b"image"
139+
140+
141+
def test_safe_filename_from_uri_removes_query_parameters():
142+
"""Ensure query parameters are not persisted in temporary filenames."""
143+
uri = "https://example.com/folder/photo name.jpg?secret=token&download=1"
144+
145+
assert safe_filename_from_uri(uri) == "photo_name.jpg"

utils/general.py

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,35 @@
11
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
22

33
from pathlib import Path
4+
from urllib.parse import unquote, urlsplit
45

56
import requests
67
from PIL import Image
78

89

9-
def download_uri(uri, dir="./"):
10-
"""Downloads file from URI, performing checks and renaming; supports timeout and image format suffix addition."""
11-
# Download
12-
dir = Path(dir)
13-
f = dir / Path(uri).name # filename
14-
response = requests.get(uri, timeout=10)
15-
response.raise_for_status()
16-
with open(f, "wb") as file:
17-
file.write(response.content)
18-
19-
# Rename (remove wildcard characters)
20-
src = f # original name
21-
f = Path(
22-
str(f)
23-
.replace("%20", "_")
10+
def safe_filename_from_uri(uri):
11+
"""Return a sanitized filename from a URI path without query parameters."""
12+
filename = unquote(Path(urlsplit(uri).path).name) or "download"
13+
return (
14+
filename.replace("%20", "_")
2415
.replace("%", "_")
2516
.replace("*", "_")
2617
.replace("~", "_")
2718
.replace("(", "_")
2819
.replace(")", "_")
20+
.replace(" ", "_")
2921
)
3022

31-
if "?" in str(f):
32-
f = Path(str(f)[: str(f).index("?")])
3323

34-
if src != f:
35-
src.rename(f) # rename
24+
def download_uri(uri, dir="./"):
25+
"""Downloads file from URI, performing checks and renaming; supports timeout and image format suffix addition."""
26+
# Download
27+
dir = Path(dir)
28+
f = dir / safe_filename_from_uri(uri) # filename
29+
response = requests.get(uri, timeout=10)
30+
response.raise_for_status()
31+
with open(f, "wb") as file:
32+
file.write(response.content)
3633

3734
# Add suffix (if missing)
3835
if not f.suffix:

0 commit comments

Comments
 (0)