diff --git a/README.md b/README.md index c385553..de19264 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,7 @@ Provider and query may be given as positionals after `s` / `-s`, or with `-P` / | `splitshire` | [SplitShire](https://splitshire.com) | Free stock photos and artwork by Daniel Nanescu & community | Direct high-resolution photo downloads. | | `deviantart` | [DeviantArt](https://deviantart.com) | Creative reference photos, stock imagery, and digital art | Direct image downloads via Wixmp CDN. | | `negativespace` | [NegativeSpace](https://negativespace.co) | Free high-resolution CC0 stock photography | Direct master full-resolution photo downloads. | +| `skitterphoto` | [Skitterphoto](https://skitterphoto.com) | 100% free CC0 public domain photography | Direct high-resolution photo downloads. | List providers at runtime via `ast help` (printed under **PROVIDERS**). @@ -300,6 +301,7 @@ src/ splitshire.zig deviantart.zig negativespace.zig + skitterphoto.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 54c730b..5afaf16 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -183,3 +183,15 @@ This document summarizes the network endpoints, transport models, and image down * Master full-resolution URL extracted by stripping geometry suffixes (`-1062x708`) from `wp-content/uploads/` image URLs. * **Download**: Direct GET on original full-resolution master photo JPEG/PNG (up to 20+ MB). * **License**: CC0 / 100% Free High-Resolution Stock Photos. + +--- + +## 16. Skitterphoto (`skitterphoto.com`) + +* **Search**: `GET https://skitterphoto.com/photos/tags/{tag}` +* **Format**: Clean SSR photography feed with photo IDs, slugs, and CDN thumbnails. +* **Extraction**: + * Title formatted and capitalized from URL slug. + * High-resolution photo asset path extracted directly (`/photos/skitterphoto-{id}-default.jpg`). +* **Download**: Direct GET on high-resolution image URL. +* **License**: CC0 / 100% Free Public Domain. diff --git a/docs/stock_photo_sites.md b/docs/stock_photo_sites.md index b296042..1ff05cf 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)) +- *(Additional Providers Implemented in `ast`)*: `aura` ([Aura.build](https://www.aura.build)), `negativespace` ([NegativeSpace.co](https://negativespace.co)), `skitterphoto` ([Skitterphoto.com](https://skitterphoto.com)) - **Unimplemented / Blocked / Pending**: 11 --- diff --git a/src/commands/search.zig b/src/commands/search.zig index 0d31479..d64e6f8 100644 --- a/src/commands/search.zig +++ b/src/commands/search.zig @@ -18,6 +18,7 @@ const magdeleine = @import("../providers/magdeleine.zig"); const splitshire = @import("../providers/splitshire.zig"); const deviantart = @import("../providers/deviantart.zig"); const negativespace = @import("../providers/negativespace.zig"); +const skitterphoto = @import("../providers/skitterphoto.zig"); const stdio = @import("../stdio.zig"); const Allocator = std.mem.Allocator; const Io = std.Io; @@ -84,6 +85,9 @@ fn doSearch( if (std.ascii.eqlIgnoreCase(provider_id, "negativespace")) { return negativespace.search(client, allocator, query, limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) { + return skitterphoto.search(client, allocator, query, limit); + } return error.UnknownProvider; } @@ -140,6 +144,9 @@ fn doDownload( if (std.ascii.eqlIgnoreCase(provider_id, "negativespace")) { return negativespace.download(client, allocator, io, a, output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) { + return skitterphoto.download(client, allocator, io, a, output_dir); + } return error.UnknownProvider; } @@ -159,6 +166,7 @@ fn doPrompt(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "splitshire")) return splitshire.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "deviantart")) return deviantart.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "negativespace")) return negativespace.getPrompt(a); + if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) return skitterphoto.getPrompt(a); return a.prompt orelse a.description; } @@ -178,6 +186,7 @@ fn doUrl(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "splitshire")) return splitshire.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "deviantart")) return deviantart.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "negativespace")) return negativespace.getUrl(a); + if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) return skitterphoto.getUrl(a); return a.image_url; } diff --git a/src/interactive.zig b/src/interactive.zig index 822b3c4..610a015 100644 --- a/src/interactive.zig +++ b/src/interactive.zig @@ -18,6 +18,7 @@ const magdeleine = @import("providers/magdeleine.zig"); const splitshire = @import("providers/splitshire.zig"); const deviantart = @import("providers/deviantart.zig"); const negativespace = @import("providers/negativespace.zig"); +const skitterphoto = @import("providers/skitterphoto.zig"); const registry = @import("providers/registry.zig"); const stdio = @import("stdio.zig"); const Allocator = std.mem.Allocator; @@ -81,6 +82,9 @@ fn searchProvider( if (std.ascii.eqlIgnoreCase(provider_id, "negativespace")) { return negativespace.search(client, allocator, query, config.default_limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) { + return skitterphoto.search(client, allocator, query, config.default_limit); + } return error.UnknownProvider; } @@ -136,6 +140,9 @@ fn downloadAsset( if (std.ascii.eqlIgnoreCase(provider_id, "negativespace")) { return negativespace.download(client, allocator, io, a, config.default_output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) { + return skitterphoto.download(client, allocator, io, a, config.default_output_dir); + } return error.UnknownProvider; } @@ -155,6 +162,7 @@ fn promptOf(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "splitshire")) return splitshire.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "deviantart")) return deviantart.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "negativespace")) return negativespace.getPrompt(a); + if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) return skitterphoto.getPrompt(a); return a.prompt; } @@ -174,6 +182,7 @@ fn urlOf(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "splitshire")) return splitshire.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "deviantart")) return deviantart.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "negativespace")) return negativespace.getUrl(a); + if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) return skitterphoto.getUrl(a); return a.image_url; } diff --git a/src/providers/registry.zig b/src/providers/registry.zig index 6d8abee..4c7c25b 100644 --- a/src/providers/registry.zig +++ b/src/providers/registry.zig @@ -16,6 +16,7 @@ const magdeleine = @import("magdeleine.zig"); const splitshire = @import("splitshire.zig"); const deviantart = @import("deviantart.zig"); const negativespace = @import("negativespace.zig"); +const skitterphoto = @import("skitterphoto.zig"); pub const ProviderInfo = struct { id: []const u8, @@ -39,6 +40,7 @@ pub fn list() []const ProviderInfo { .{ .id = splitshire.id, .name = splitshire.name }, .{ .id = deviantart.id, .name = deviantart.name }, .{ .id = negativespace.id, .name = negativespace.name }, + .{ .id = skitterphoto.id, .name = skitterphoto.name }, }; } @@ -57,5 +59,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"; + return "aura, unsplash, isorepublic, picjumbo, foodiesfeed, picography, gratisography, startupstockphotos, burst, jaymantri, publicdomainarchive, magdeleine, splitshire, deviantart, negativespace, skitterphoto"; } diff --git a/src/providers/skitterphoto.zig b/src/providers/skitterphoto.zig new file mode 100644 index 0000000..3255453 --- /dev/null +++ b/src/providers/skitterphoto.zig @@ -0,0 +1,245 @@ +//! Skitterphoto provider — 100% free CC0 public domain photos. +//! Search: GET https://skitterphoto.com/photos/tags/{tag} +//! Download: Direct high-resolution JPEG from Skitterphoto CDN. + +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 = "skitterphoto"; +pub const name = "Skitterphoto"; + +fn formatTag(allocator: Allocator, query: []const u8) ![]u8 { + const trimmed = std.mem.trim(u8, query, " \t\r\n"); + var list: std.ArrayList(u8) = .empty; + errdefer list.deinit(allocator); + + for (trimmed) |c| { + if (std.ascii.isAlphanumeric(c)) { + try list.append(allocator, std.ascii.toLower(c)); + } else if (c == ' ' or c == '-' or c == '_') { + if (list.items.len > 0 and list.items[list.items.len - 1] != '-') { + try list.append(allocator, '-'); + } + } + } + if (list.items.len == 0) { + try list.appendSlice(allocator, "nature"); + } + return list.toOwnedSlice(allocator); +} + +fn formatTitleFromSlug(allocator: Allocator, slug: []const u8) ![]u8 { + var list: std.ArrayList(u8) = .empty; + errdefer list.deinit(allocator); + + var cap_next = true; + for (slug) |c| { + if (c == '-' or c == '_' or c == '/') { + if (list.items.len > 0 and list.items[list.items.len - 1] != ' ') { + try list.append(allocator, ' '); + } + cap_next = true; + } else if (cap_next and std.ascii.isAlphabetic(c)) { + try list.append(allocator, std.ascii.toUpper(c)); + cap_next = false; + } else { + try list.append(allocator, c); + cap_next = false; + } + } + return list.toOwnedSlice(allocator); +} + +fn parseSearch(allocator: Allocator, html: []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 marker = "https://skitterphoto.com/photos/"; + var pos: usize = 0; + while (assets.items.len < limit) { + const found = std.mem.indexOfPos(u8, html, pos, marker) orelse break; + pos = found + marker.len; + + const rest = html[pos..]; + var id_len: usize = 0; + while (id_len < rest.len and std.ascii.isDigit(rest[id_len])) : (id_len += 1) {} + if (id_len == 0) continue; + + const photo_id = rest[0..id_len]; + if (seen.contains(photo_id)) continue; + + var slug: []const u8 = ""; + if (id_len < rest.len and rest[id_len] == '/') { + const slug_start = id_len + 1; + var slug_end = slug_start; + while (slug_end < rest.len and rest[slug_end] != '"' and rest[slug_end] != '\'' and rest[slug_end] != ' ' and rest[slug_end] != '>') : (slug_end += 1) {} + if (slug_end > slug_start) { + slug = rest[slug_start..slug_end]; + } + } + + try seen.put(allocator, try allocator.dupe(u8, photo_id), {}); + + const pid = try allocator.dupe(u8, photo_id); + errdefer allocator.free(pid); + + const desc = if (slug.len > 0) + try formatTitleFromSlug(allocator, slug) + else + try std.fmt.allocPrint(allocator, "Skitterphoto Photo {s}", .{photo_id}); + errdefer allocator.free(desc); + + const prompt_owned = try allocator.dupe(u8, desc); + errdefer allocator.free(prompt_owned); + + const img_url = try std.fmt.allocPrint(allocator, "https://skitterphoto.com/photos/skitterphoto-{s}-default.jpg", .{photo_id}); + errdefer allocator.free(img_url); + + const thumb_url = try std.fmt.allocPrint(allocator, "https://skitterphoto.com/photos/skitterphoto-{s}-thumbnail.jpg", .{photo_id}); + errdefer allocator.free(thumb_url); + + const prov_owned = try allocator.dupe(u8, id); + errdefer allocator.free(prov_owned); + + const auth_owned = try allocator.dupe(u8, "Skitterphoto"); + errdefer allocator.free(auth_owned); + + const page_url = if (slug.len > 0) + try std.fmt.allocPrint(allocator, "https://skitterphoto.com/photos/{s}/{s}", .{ photo_id, slug }) + else + try std.fmt.allocPrint(allocator, "https://skitterphoto.com/photos/{s}", .{photo_id}); + defer allocator.free(page_url); + + var meta_aw: Io.Writer.Allocating = .init(allocator); + defer meta_aw.deinit(); + try meta_aw.writer.print("{{\"source\":\"skitterphoto\",\"page\":\"{s}\"}}", .{page_url}); + const meta = try meta_aw.toOwnedSlice(); + + try assets.append(allocator, .{ + .id = pid, + .description = desc, + .prompt = prompt_owned, + .image_url = img_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 tag = try formatTag(allocator, query); + defer allocator.free(tag); + + const search_url = try std.fmt.allocPrint(allocator, "https://skitterphoto.com/photos/tags/{s}", .{tag}); + 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://skitterphoto.com/", + .max_redirects = 5, + }); + defer resp.deinit(); + if (resp.status != 200) { + // Fallback to latest photos feed if tag not found + var fallback_resp = try http_client.get(client, allocator, "https://skitterphoto.com/photos", .{ + .accept = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + .referer = "https://skitterphoto.com/", + .max_redirects = 5, + }); + defer fallback_resp.deinit(); + if (fallback_resp.status != 200) return error.HttpStatus; + + const lim = @min(@max(limit, 1), 50); + const assets = try parseSearch(allocator, fallback_resp.body, lim); + return .{ + .assets = assets, + .total = null, + .provider = try allocator.dupe(u8, id), + .query = try allocator.dupe(u8, query), + .allocator = allocator, + }; + } + + const lim = @min(@max(limit, 1), 50); + const assets = try parseSearch(allocator, resp.body, lim); + + return .{ + .assets = assets, + .total = null, + .provider = try allocator.dupe(u8, id), + .query = try allocator.dupe(u8, query), + .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://skitterphoto.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; +}