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 @@ -239,6 +239,7 @@ Provider and query may be given as positionals after `s` / `-s`, or with `-P` /
| `skitterphoto` | [Skitterphoto](https://skitterphoto.com) | 100% free CC0 public domain photography | Direct high-resolution photo downloads. |
| `libreshot` | [LibreShot](https://libreshot.com) | Free CC0 stock photos by Martin Vorel | Direct master full-resolution photo downloads. |
| `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. |

List providers at runtime via `ast help` (printed under **PROVIDERS**).

Expand Down Expand Up @@ -306,6 +307,7 @@ src/
skitterphoto.zig
libreshot.zig
moveast.zig
cupcake.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 @@ -221,3 +221,15 @@ This document summarizes the network endpoints, transport models, and image down
* 1280px / 500px CDN image endpoints.
* **Download**: Direct GET on high-resolution Tumblr CDN JPEG assets.
* **License**: CC0 / 100% Free Public Domain.

---

## 19. Cupcake (`cupcake.nilssonlee.se`)

* **Search**: `GET https://cupcake.nilssonlee.se/`
* **Format**: Photography feed by Swedish photographer Jonas Nilsson Lee with rich `alt` descriptions.
* **Extraction**:
* Rich visual descriptions extracted and HTML unescaped from `alt` attributes.
* ImageKit CDN master URL extracted by stripping query size parameters (`?tr=...`).
* **Download**: Direct GET on master full-resolution JPEG/WebP asset via CloudFront/ImageKit CDN.
* **License**: CC0 / 100% Free Public Domain (Do whatever you want).
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)), `skitterphoto` ([Skitterphoto.com](https://skitterphoto.com)), `libreshot` ([LibreShot.com](https://libreshot.com)), `moveast` ([Moveast.me](https://moveast.me))
- *(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))
- **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 @@ -21,6 +21,7 @@ const negativespace = @import("../providers/negativespace.zig");
const skitterphoto = @import("../providers/skitterphoto.zig");
const libreshot = @import("../providers/libreshot.zig");
const moveast = @import("../providers/moveast.zig");
const cupcake = @import("../providers/cupcake.zig");
const stdio = @import("../stdio.zig");
const Allocator = std.mem.Allocator;
const Io = std.Io;
Expand Down Expand Up @@ -96,6 +97,9 @@ fn doSearch(
if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) {
return moveast.search(client, allocator, query, limit);
}
if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) {
return cupcake.search(client, allocator, query, limit);
}
return error.UnknownProvider;
}

Expand Down Expand Up @@ -161,6 +165,9 @@ fn doDownload(
if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) {
return moveast.download(client, allocator, io, a, output_dir);
}
if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) {
return cupcake.download(client, allocator, io, a, output_dir);
}
return error.UnknownProvider;
}

Expand All @@ -183,6 +190,7 @@ fn doPrompt(provider_id: []const u8, a: Asset) ?[]const u8 {
if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) return skitterphoto.getPrompt(a);
if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) return libreshot.getPrompt(a);
if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getPrompt(a);
if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getPrompt(a);
return a.prompt orelse a.description;
}

Expand All @@ -205,6 +213,7 @@ fn doUrl(provider_id: []const u8, a: Asset) ?[]const u8 {
if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) return skitterphoto.getUrl(a);
if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) return libreshot.getUrl(a);
if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getUrl(a);
if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.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 @@ -21,6 +21,7 @@ const negativespace = @import("providers/negativespace.zig");
const skitterphoto = @import("providers/skitterphoto.zig");
const libreshot = @import("providers/libreshot.zig");
const moveast = @import("providers/moveast.zig");
const cupcake = @import("providers/cupcake.zig");
const registry = @import("providers/registry.zig");
const stdio = @import("stdio.zig");
const Allocator = std.mem.Allocator;
Expand Down Expand Up @@ -93,6 +94,9 @@ fn searchProvider(
if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) {
return moveast.search(client, allocator, query, config.default_limit);
}
if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) {
return cupcake.search(client, allocator, query, config.default_limit);
}
return error.UnknownProvider;
}

Expand Down Expand Up @@ -157,6 +161,9 @@ fn downloadAsset(
if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) {
return moveast.download(client, allocator, io, a, config.default_output_dir);
}
if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) {
return cupcake.download(client, allocator, io, a, config.default_output_dir);
}
return error.UnknownProvider;
}

Expand All @@ -179,6 +186,7 @@ fn promptOf(provider_id: []const u8, a: Asset) ?[]const u8 {
if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) return skitterphoto.getPrompt(a);
if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) return libreshot.getPrompt(a);
if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getPrompt(a);
if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getPrompt(a);
return a.prompt;
}

Expand All @@ -201,6 +209,7 @@ fn urlOf(provider_id: []const u8, a: Asset) ?[]const u8 {
if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) return skitterphoto.getUrl(a);
if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) return libreshot.getUrl(a);
if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getUrl(a);
if (std.ascii.eqlIgnoreCase(provider_id, "cupcake")) return cupcake.getUrl(a);
return a.image_url;
}

Expand Down
266 changes: 266 additions & 0 deletions src/providers/cupcake.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
//! Cupcake provider — Jonas Nilsson Lee's free CC0 stock photography.
//! Source: https://cupcake.nilssonlee.se/
//! Download: Direct master image from ImageKit CDN without size transforms.

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 = "cupcake";
pub const name = "Cupcake";

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..], "&amp;")) {
try list.append(allocator, '&');
i += 5;
continue;
}
if (std.mem.startsWith(u8, s[i..], "&quot;")) {
try list.append(allocator, '"');
i += 6;
continue;
}
if (std.mem.startsWith(u8, s[i..], "&#8211;")) {
try list.append(allocator, '-');
i += 7;
continue;
}
if (std.mem.startsWith(u8, s[i..], "&#8212;")) {
try list.append(allocator, '-');
i += 7;
continue;
}
if (std.mem.startsWith(u8, s[i..], "&#038;")) {
try list.append(allocator, '&');
i += 6;
continue;
}
if (std.mem.startsWith(u8, s[i..], "&#39;") or std.mem.startsWith(u8, s[i..], "&apos;")) {
try list.append(allocator, '\'');
i += if (std.mem.startsWith(u8, s[i..], "&#39;")) 5 else 6;
continue;
}
}
try list.append(allocator, s[i]);
i += 1;
}
return list.toOwnedSlice(allocator);
}

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, "cupcake") or
std.ascii.eqlIgnoreCase(trimmed_query, "photos") or
trimmed_query.len == 0;

const img_marker = "<img";
var pos: usize = 0;
while (assets.items.len < limit) {
const found = std.mem.indexOfPos(u8, html, pos, img_marker) orelse break;
pos = found + img_marker.len;

const img_end = std.mem.indexOfScalarPos(u8, html, found, '>') orelse continue;
const img_tag = html[found .. img_end + 1];

// Extract src
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, "imagekit.io") == null) continue;

// Extract alt
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];
}
}

// Clean alt description
const alt_clean = try htmlUnescape(allocator, alt_val);
defer allocator.free(alt_clean);

// Check query match against alt
var matched = match_all;
if (!matched) {
if (containsIgnoreCase(alt_clean, trimmed_query) or
containsIgnoreCase(src_val, trimmed_query))
{
matched = true;
}
}
if (!matched) continue;

// Master URL: strip query parameters
const q_pos = std.mem.indexOfScalar(u8, src_val, '?');
const master_raw = if (q_pos) |qp| src_val[0..qp] else src_val;

// Determine ID from file basename
const last_slash = std.mem.lastIndexOfScalar(u8, master_raw, '/') orelse continue;
var base_name = master_raw[last_slash + 1 ..];
if (std.mem.lastIndexOfScalar(u8, base_name, '.')) |dot_idx| {
base_name = base_name[0..dot_idx];
}

if (seen.contains(base_name)) continue;
try seen.put(allocator, try allocator.dupe(u8, base_name), {});

const pid = try allocator.dupe(u8, base_name);
errdefer allocator.free(pid);

const desc = if (alt_clean.len > 0) alt_clean else base_name;
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 master_url = try allocator.dupe(u8, master_raw);
errdefer allocator.free(master_url);

const thumb_url = try allocator.dupe(u8, src_val);
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, "Jonas Nilsson Lee");
errdefer allocator.free(auth_owned);

var meta_aw: Io.Writer.Allocating = .init(allocator);
defer meta_aw.deinit();
try meta_aw.writer.print("{{\"source\":\"cupcake\",\"page\":\"https://cupcake.nilssonlee.se/\"}}", .{});
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 lim = @min(@max(limit, 1), 50);

const search_url = "https://cupcake.nilssonlee.se/";
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://cupcake.nilssonlee.se/",
.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://cupcake.nilssonlee.se/",
.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
Loading