Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**).

Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion docs/stock_photo_sites.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

---
Expand Down
9 changes: 9 additions & 0 deletions src/commands/search.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
9 changes: 9 additions & 0 deletions src/interactive.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
4 changes: 3 additions & 1 deletion src/providers/registry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 },
};
}

Expand All @@ -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";
}
245 changes: 245 additions & 0 deletions src/providers/skitterphoto.zig
Original file line number Diff line number Diff line change
@@ -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;
}
Loading