diff --git a/README.md b/README.md index 03d1e62..5837d0d 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,7 @@ Provider and query may be given as positionals after `s` / `-s`, or with `-P` / | `moveast` | [Moveast](https://moveast.me) | Free CC0 travel and documentary photography by João Pacheco | Direct high-resolution photo downloads. | | `cupcake` | [Cupcake](https://cupcake.nilssonlee.se) | Free CC0 stock photography by Jonas Nilsson Lee | Direct master resolution photo downloads. | | `freenaturestock` | [Free Nature Stock](https://freenaturestock.com) | Free CC0 nature & wildlife stock photography by Adrian Pelletier | Direct master full-resolution photo downloads (up to 20+ MB). | +| `goodfreephotos` | [Good Free Photos](https://goodfreephotos.com) | Free CC0 public domain photos, state parks, and landmarks by Yinan Chen | Direct master full-resolution photo downloads. | List providers at runtime via `ast help` (printed under **PROVIDERS**). @@ -310,6 +311,7 @@ src/ moveast.zig cupcake.zig freenaturestock.zig + goodfreephotos.zig assets/ README banner and static assets docs/ Provider implementation notes install.sh curl | bash installer (Linux release binaries) diff --git a/docs/providers.md b/docs/providers.md index dc5b6b8..144844f 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -246,3 +246,16 @@ This document summarizes the network endpoints, transport models, and image down * Direct full-resolution master photo URLs constructed via Cloudflare CDN (`https://cdn.freenaturestock.com/photos/freenaturestock-{id}.jpg`). * **Download**: Direct GET on master original camera photo JPEG files (up to 20+ MB). * **License**: CC0 / 100% Free Public Domain. + +--- + +## 21. Good Free Photos (`goodfreephotos.com`) + +* **Search**: `GET https://www.goodfreephotos.com/page/search/?words={query}` +* **Format**: Comprehensive public domain photo archive by Yinan Chen categorized by state parks, countries, and landmarks. +* **Extraction**: + * Clean image IDs extracted from photo slug. + * Alt title descriptions extracted and HTML unescaped. + * Master full-resolution URLs constructed by rewriting `/cache/` to `/albums/` and stripping thumbnail geometry tokens (`_200_w200_..._thumb`). +* **Download**: Direct GET on master original camera photo JPEG files. +* **License**: CC0 / 100% Free Public Domain. diff --git a/docs/stock_photo_sites.md b/docs/stock_photo_sites.md index e206f05..3c2899e 100644 --- a/docs/stock_photo_sites.md +++ b/docs/stock_photo_sites.md @@ -8,7 +8,7 @@ This document tracks all 24 stock photo websites listed in the [GrayGrids 21+ Be - **Total Sites Listed**: 24 - **Implemented & Working**: 13 (`unsplash`, `isorepublic`, `picjumbo`, `foodiesfeed`, `picography`, `gratisography`, `startupstockphotos`, `burst`, `jaymantri`, `publicdomainarchive`, `magdeleine`, `splitshire`, `deviantart`) -- *(Additional Providers Implemented in `ast`)*: `aura` ([Aura.build](https://www.aura.build)), `negativespace` ([NegativeSpace.co](https://negativespace.co)), `skitterphoto` ([Skitterphoto.com](https://skitterphoto.com)), `libreshot` ([LibreShot.com](https://libreshot.com)), `moveast` ([Moveast.me](https://moveast.me)), `cupcake` ([Cupcake](https://cupcake.nilssonlee.se)), `freenaturestock` ([FreeNatureStock](https://freenaturestock.com)) +- *(Additional Providers Implemented in `ast`)*: `aura` ([Aura.build](https://www.aura.build)), `negativespace` ([NegativeSpace.co](https://negativespace.co)), `skitterphoto` ([Skitterphoto.com](https://skitterphoto.com)), `libreshot` ([LibreShot.com](https://libreshot.com)), `moveast` ([Moveast.me](https://moveast.me)), `cupcake` ([Cupcake](https://cupcake.nilssonlee.se)), `freenaturestock` ([FreeNatureStock](https://freenaturestock.com)), `goodfreephotos` ([Good Free Photos](https://goodfreephotos.com)) - **Unimplemented / Blocked / Pending**: 11 --- diff --git a/src/commands/search.zig b/src/commands/search.zig index dafacdd..d2c2658 100644 --- a/src/commands/search.zig +++ b/src/commands/search.zig @@ -23,6 +23,7 @@ const libreshot = @import("../providers/libreshot.zig"); const moveast = @import("../providers/moveast.zig"); const cupcake = @import("../providers/cupcake.zig"); const freenaturestock = @import("../providers/freenaturestock.zig"); +const goodfreephotos = @import("../providers/goodfreephotos.zig"); const stdio = @import("../stdio.zig"); const Allocator = std.mem.Allocator; const Io = std.Io; @@ -104,6 +105,9 @@ fn doSearch( if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) { return freenaturestock.search(client, allocator, query, limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) { + return goodfreephotos.search(client, allocator, query, limit); + } return error.UnknownProvider; } @@ -175,6 +179,9 @@ fn doDownload( if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) { return freenaturestock.download(client, allocator, io, a, output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) { + return goodfreephotos.download(client, allocator, io, a, output_dir); + } return error.UnknownProvider; } @@ -199,6 +206,7 @@ fn doPrompt(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getPrompt(a); + if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getPrompt(a); return a.prompt orelse a.description; } @@ -223,6 +231,7 @@ fn doUrl(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getUrl(a); + if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getUrl(a); return a.image_url; } diff --git a/src/interactive.zig b/src/interactive.zig index 3bd23f1..e107fe4 100644 --- a/src/interactive.zig +++ b/src/interactive.zig @@ -23,6 +23,7 @@ const libreshot = @import("providers/libreshot.zig"); const moveast = @import("providers/moveast.zig"); const cupcake = @import("providers/cupcake.zig"); const freenaturestock = @import("providers/freenaturestock.zig"); +const goodfreephotos = @import("providers/goodfreephotos.zig"); const registry = @import("providers/registry.zig"); const stdio = @import("stdio.zig"); const Allocator = std.mem.Allocator; @@ -101,6 +102,9 @@ fn searchProvider( if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) { return freenaturestock.search(client, allocator, query, config.default_limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) { + return goodfreephotos.search(client, allocator, query, config.default_limit); + } return error.UnknownProvider; } @@ -171,6 +175,9 @@ fn downloadAsset( if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) { return freenaturestock.download(client, allocator, io, a, config.default_output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) { + return goodfreephotos.download(client, allocator, io, a, config.default_output_dir); + } return error.UnknownProvider; } @@ -195,6 +202,7 @@ fn promptOf(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getPrompt(a); + if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getPrompt(a); return a.prompt; } @@ -219,6 +227,7 @@ fn urlOf(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getUrl(a); + if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getUrl(a); return a.image_url; } diff --git a/src/providers/goodfreephotos.zig b/src/providers/goodfreephotos.zig new file mode 100644 index 0000000..9e89ec5 --- /dev/null +++ b/src/providers/goodfreephotos.zig @@ -0,0 +1,325 @@ +//! Good Free Photos provider — free CC0 public domain photography by Yinan Chen. +//! Search: GET https://www.goodfreephotos.com/page/search/?words={query} +//! Download: Direct master full-resolution JPEG/PNG from ZenPhoto albums storage. + +const std = @import("std"); +const asset_mod = @import("../asset.zig"); +const http_client = @import("../http_client.zig"); +const download_mod = @import("../download.zig"); +const Allocator = std.mem.Allocator; +const Io = std.Io; +const Asset = asset_mod.Asset; +const SearchResult = asset_mod.SearchResult; + +pub const id = "goodfreephotos"; +pub const name = "Good Free Photos"; + +fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { + if (needle.len == 0) return true; + if (haystack.len < needle.len) return false; + var i: usize = 0; + while (i + needle.len <= haystack.len) : (i += 1) { + if (std.ascii.eqlIgnoreCase(haystack[i .. i + needle.len], needle)) { + return true; + } + } + return false; +} + +fn htmlUnescape(allocator: Allocator, s: []const u8) ![]u8 { + var list: std.ArrayList(u8) = .empty; + errdefer list.deinit(allocator); + var i: usize = 0; + while (i < s.len) { + if (s[i] == '&') { + if (std.mem.startsWith(u8, s[i..], "&")) { + try list.append(allocator, '&'); + i += 5; + continue; + } + if (std.mem.startsWith(u8, s[i..], """)) { + try list.append(allocator, '"'); + i += 6; + continue; + } + if (std.mem.startsWith(u8, s[i..], "–")) { + try list.append(allocator, '-'); + i += 7; + continue; + } + if (std.mem.startsWith(u8, s[i..], "—")) { + try list.append(allocator, '-'); + i += 7; + continue; + } + if (std.mem.startsWith(u8, s[i..], "&")) { + try list.append(allocator, '&'); + i += 6; + continue; + } + if (std.mem.startsWith(u8, s[i..], "'") or std.mem.startsWith(u8, s[i..], "'")) { + try list.append(allocator, '\''); + i += if (std.mem.startsWith(u8, s[i..], "'")) 5 else 6; + continue; + } + } + try list.append(allocator, s[i]); + i += 1; + } + return list.toOwnedSlice(allocator); +} + +fn isGeometryToken(tok: []const u8) bool { + if (tok.len == 0) return false; + if (std.mem.startsWith(u8, tok, "w") or + std.mem.startsWith(u8, tok, "h") or + std.mem.startsWith(u8, tok, "cw") or + std.mem.startsWith(u8, tok, "ch")) + { + return true; + } + for (tok) |c| { + if (!std.ascii.isDigit(c)) return false; + } + return true; +} + +fn transformToMasterUrl(allocator: Allocator, src_url: []const u8) ![]u8 { + // Strip query string + var clean_src = src_url; + if (std.mem.indexOfScalar(u8, clean_src, '?')) |q_pos| { + clean_src = clean_src[0..q_pos]; + } + + // Extract path after /cache/ + const cache_marker = "/cache/"; + const cache_idx = std.mem.indexOf(u8, clean_src, cache_marker) orelse return allocator.dupe(u8, clean_src); + const sub_path = clean_src[cache_idx + cache_marker.len ..]; + + // Find extension dot + const ext_dot = std.mem.lastIndexOfScalar(u8, sub_path, '.') orelse return allocator.dupe(u8, clean_src); + const ext = sub_path[ext_dot..]; + var base = sub_path[0..ext_dot]; + + // Look for _thumb and strip geometry tokens preceding it + if (std.mem.lastIndexOf(u8, base, "_thumb")) |thumb_pos| { + var pos = thumb_pos; + while (pos > 0) { + const prev_under = std.mem.lastIndexOfScalar(u8, base[0..pos], '_') orelse break; + const token = base[prev_under + 1 .. pos]; + if (isGeometryToken(token)) { + pos = prev_under; + } else { + break; + } + } + base = base[0..pos]; + } + + return try std.fmt.allocPrint(allocator, "https://www.goodfreephotos.com/albums/{s}{s}", .{ base, ext }); +} + +fn parseSearch(allocator: Allocator, html: []const u8, query: []const u8, limit: u32) ![]Asset { + var assets: std.ArrayList(Asset) = .empty; + errdefer { + for (assets.items) |*a| a.deinit(allocator); + assets.deinit(allocator); + } + + var seen: std.StringHashMapUnmanaged(void) = .empty; + defer { + var it = seen.keyIterator(); + while (it.next()) |k| allocator.free(k.*); + seen.deinit(allocator); + } + + const trimmed_query = std.mem.trim(u8, query, " \t\r\n"); + const match_all = std.mem.eql(u8, trimmed_query, "*") or + std.ascii.eqlIgnoreCase(trimmed_query, "all") or + std.ascii.eqlIgnoreCase(trimmed_query, "photos") or + trimmed_query.len == 0; + + const img_marker = "') orelse continue; + const img_tag = html[found .. img_end + 1]; + + // Must be a cached photo thumb: /cache/..._thumb.jpg + const src_key = "src=\""; + const src_idx = std.mem.indexOf(u8, img_tag, src_key) orelse continue; + const src_start = src_idx + src_key.len; + const src_end = std.mem.indexOfScalarPos(u8, img_tag, src_start, '"') orelse continue; + const src_val = img_tag[src_start..src_end]; + + if (std.mem.indexOf(u8, src_val, "/cache/") == null or + std.mem.indexOf(u8, src_val, "_thumb") == null) + { + continue; + } + + // Extract alt title + var alt_val: []const u8 = ""; + const alt_key = "alt=\""; + if (std.mem.indexOf(u8, img_tag, alt_key)) |ai| { + const as = ai + alt_key.len; + if (std.mem.indexOfScalarPos(u8, img_tag, as, '"')) |ae| { + alt_val = img_tag[as..ae]; + } + } + + const alt_clean = try htmlUnescape(allocator, alt_val); + defer allocator.free(alt_clean); + + // Check query match against alt title or image path + var matched = match_all; + if (!matched) { + if (containsIgnoreCase(alt_clean, trimmed_query) or + containsIgnoreCase(src_val, trimmed_query)) + { + matched = true; + } + } + if (!matched) continue; + + const master_url = try transformToMasterUrl(allocator, src_val); + errdefer allocator.free(master_url); + + // Extract ID from master URL basename without extension + const last_slash = std.mem.lastIndexOfScalar(u8, master_url, '/') orelse continue; + var filename = master_url[last_slash + 1 ..]; + if (std.mem.lastIndexOfScalar(u8, filename, '.')) |dot| { + filename = filename[0..dot]; + } + + if (seen.contains(filename)) { + allocator.free(master_url); + continue; + } + try seen.put(allocator, try allocator.dupe(u8, filename), {}); + + const thumb_url = if (std.mem.startsWith(u8, src_val, "http")) + try allocator.dupe(u8, src_val) + else + try std.fmt.allocPrint(allocator, "https://www.goodfreephotos.com{s}", .{src_val}); + errdefer allocator.free(thumb_url); + + const pid = try allocator.dupe(u8, filename); + errdefer allocator.free(pid); + + const desc = if (alt_clean.len > 0) alt_clean else filename; + const desc_owned = try allocator.dupe(u8, desc); + errdefer allocator.free(desc_owned); + const prompt_owned = try allocator.dupe(u8, desc); + errdefer allocator.free(prompt_owned); + + const prov_owned = try allocator.dupe(u8, id); + errdefer allocator.free(prov_owned); + const auth_owned = try allocator.dupe(u8, "Yinan Chen"); + errdefer allocator.free(auth_owned); + + var meta_aw: Io.Writer.Allocating = .init(allocator); + defer meta_aw.deinit(); + try meta_aw.writer.print("{{\"source\":\"goodfreephotos\",\"page\":\"https://www.goodfreephotos.com/\"}}", .{}); + const meta = try meta_aw.toOwnedSlice(); + + try assets.append(allocator, .{ + .id = pid, + .description = desc_owned, + .prompt = prompt_owned, + .image_url = master_url, + .thumbnail_url = thumb_url, + .provider = prov_owned, + .author = auth_owned, + .width = null, + .height = null, + .metadata_json = meta, + }); + } + + return try assets.toOwnedSlice(allocator); +} + +pub fn search( + client: *std.http.Client, + allocator: Allocator, + query: []const u8, + limit: u32, +) !SearchResult { + const trimmed = std.mem.trim(u8, query, " \t\n\r"); + if (trimmed.len == 0) return error.EmptyQuery; + + const enc = try http_client.queryEscape(allocator, trimmed); + defer allocator.free(enc); + + const lim = @min(@max(limit, 1), 50); + + const search_url = try std.fmt.allocPrint(allocator, "https://www.goodfreephotos.com/page/search/?words={s}", .{enc}); + defer allocator.free(search_url); + + var resp = try http_client.get(client, allocator, search_url, .{ + .accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + .referer = "https://www.goodfreephotos.com/", + .max_redirects = 5, + }); + defer resp.deinit(); + if (resp.status != 200) return error.HttpStatus; + + const assets = try parseSearch(allocator, resp.body, trimmed, lim); + + return .{ + .assets = assets, + .total = null, + .provider = try allocator.dupe(u8, id), + .query = try allocator.dupe(u8, trimmed), + .allocator = allocator, + }; +} + +pub fn download( + client: *std.http.Client, + allocator: Allocator, + io: Io, + a: Asset, + output_dir: []const u8, +) !download_mod.Saved { + const url = a.image_url orelse return error.NoUrl; + const body = http_client.getBody(client, allocator, url, .{ + .referer = "https://www.goodfreephotos.com/", + .accept = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + .max_redirects = 5, + }) catch |err| switch (err) { + error.RateLimited => return error.RateLimited, + error.HttpStatus => return error.HttpStatus, + error.OutOfMemory => return error.OutOfMemory, + else => return error.Network, + }; + defer allocator.free(body); + if (body.len == 0) return error.HttpStatus; + + try download_mod.ensureDir(io, output_dir); + const ext = download_mod.guessExtension(url); + const path = try download_mod.uniquePath(allocator, io, output_dir, a.id, ext); + errdefer allocator.free(path); + const cwd = Io.Dir.cwd(); + cwd.writeFile(io, .{ .sub_path = path, .data = body }) catch return error.Io; + return .{ + .path = path, + .filename = std.fs.path.basename(path), + .bytes = body.len, + .allocator = allocator, + }; +} + +pub fn getPrompt(a: Asset) ?[]const u8 { + if (a.prompt) |p| if (p.len > 0) return p; + if (a.description.len > 0) return a.description; + return null; +} + +pub fn getUrl(a: Asset) ?[]const u8 { + return a.image_url; +} diff --git a/src/providers/registry.zig b/src/providers/registry.zig index c72e49c..86dc95f 100644 --- a/src/providers/registry.zig +++ b/src/providers/registry.zig @@ -21,6 +21,7 @@ const libreshot = @import("libreshot.zig"); const moveast = @import("moveast.zig"); const cupcake = @import("cupcake.zig"); const freenaturestock = @import("freenaturestock.zig"); +const goodfreephotos = @import("goodfreephotos.zig"); pub const ProviderInfo = struct { id: []const u8, @@ -49,6 +50,7 @@ pub fn list() []const ProviderInfo { .{ .id = moveast.id, .name = moveast.name }, .{ .id = cupcake.id, .name = cupcake.name }, .{ .id = freenaturestock.id, .name = freenaturestock.name }, + .{ .id = goodfreephotos.id, .name = goodfreephotos.name }, }; } @@ -67,5 +69,5 @@ pub fn displayName(id: []const u8) ?[]const u8 { } pub fn availableIds() []const u8 { - return "aura, unsplash, isorepublic, picjumbo, foodiesfeed, picography, gratisography, startupstockphotos, burst, jaymantri, publicdomainarchive, magdeleine, splitshire, deviantart, negativespace, skitterphoto, libreshot, moveast, cupcake, freenaturestock"; + return "aura, unsplash, isorepublic, picjumbo, foodiesfeed, picography, gratisography, startupstockphotos, burst, jaymantri, publicdomainarchive, magdeleine, splitshire, deviantart, negativespace, skitterphoto, libreshot, moveast, cupcake, freenaturestock, goodfreephotos"; }