diff --git a/README.md b/README.md
index de19264..7b0a8f0 100644
--- a/README.md
+++ b/README.md
@@ -237,6 +237,7 @@ Provider and query may be given as positionals after `s` / `-s`, or with `-P` /
| `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. |
+| `libreshot` | [LibreShot](https://libreshot.com) | Free CC0 stock photos by Martin Vorel | Direct master full-resolution photo downloads. |
List providers at runtime via `ast help` (printed under **PROVIDERS**).
@@ -302,6 +303,7 @@ src/
deviantart.zig
negativespace.zig
skitterphoto.zig
+ libreshot.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 5afaf16..a54d7b5 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -195,3 +195,16 @@ This document summarizes the network endpoints, transport models, and image down
* 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.
+
+---
+
+## 17. LibreShot (`libreshot.com`)
+
+* **Search**: `GET https://libreshot.com/?s={query}`
+* **Format**: WordPress photography feed by Martin Vorel with lazy-loaded image cards.
+* **Extraction**:
+ * Title extracted and HTML unescaped from image `alt` attribute.
+ * Slug ID parsed from post permalink.
+ * Master full-resolution URL extracted by stripping geometry suffixes (`-508x339`, `-508x300`) from `wp-content/uploads/` image URLs.
+* **Download**: Direct GET on original full-resolution master photo JPEG/PNG (up to 10+ MB).
+* **License**: CC0 / Free Public Domain Stock Photos.
diff --git a/docs/stock_photo_sites.md b/docs/stock_photo_sites.md
index 1ff05cf..40c0ef7 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))
+- *(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))
- **Unimplemented / Blocked / Pending**: 11
---
diff --git a/src/commands/search.zig b/src/commands/search.zig
index d64e6f8..3573fb8 100644
--- a/src/commands/search.zig
+++ b/src/commands/search.zig
@@ -19,6 +19,7 @@ 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 libreshot = @import("../providers/libreshot.zig");
const stdio = @import("../stdio.zig");
const Allocator = std.mem.Allocator;
const Io = std.Io;
@@ -88,6 +89,9 @@ fn doSearch(
if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) {
return skitterphoto.search(client, allocator, query, limit);
}
+ if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) {
+ return libreshot.search(client, allocator, query, limit);
+ }
return error.UnknownProvider;
}
@@ -147,6 +151,9 @@ fn doDownload(
if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) {
return skitterphoto.download(client, allocator, io, a, output_dir);
}
+ if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) {
+ return libreshot.download(client, allocator, io, a, output_dir);
+ }
return error.UnknownProvider;
}
@@ -167,6 +174,7 @@ fn doPrompt(provider_id: []const u8, a: Asset) ?[]const u8 {
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);
+ if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) return libreshot.getPrompt(a);
return a.prompt orelse a.description;
}
@@ -187,6 +195,7 @@ fn doUrl(provider_id: []const u8, a: Asset) ?[]const u8 {
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);
+ if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) return libreshot.getUrl(a);
return a.image_url;
}
diff --git a/src/interactive.zig b/src/interactive.zig
index 610a015..44f9200 100644
--- a/src/interactive.zig
+++ b/src/interactive.zig
@@ -19,6 +19,7 @@ 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 libreshot = @import("providers/libreshot.zig");
const registry = @import("providers/registry.zig");
const stdio = @import("stdio.zig");
const Allocator = std.mem.Allocator;
@@ -85,6 +86,9 @@ fn searchProvider(
if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) {
return skitterphoto.search(client, allocator, query, config.default_limit);
}
+ if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) {
+ return libreshot.search(client, allocator, query, config.default_limit);
+ }
return error.UnknownProvider;
}
@@ -143,6 +147,9 @@ fn downloadAsset(
if (std.ascii.eqlIgnoreCase(provider_id, "skitterphoto")) {
return skitterphoto.download(client, allocator, io, a, config.default_output_dir);
}
+ if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) {
+ return libreshot.download(client, allocator, io, a, config.default_output_dir);
+ }
return error.UnknownProvider;
}
@@ -163,6 +170,7 @@ fn promptOf(provider_id: []const u8, a: Asset) ?[]const u8 {
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);
+ if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) return libreshot.getPrompt(a);
return a.prompt;
}
@@ -183,6 +191,7 @@ fn urlOf(provider_id: []const u8, a: Asset) ?[]const u8 {
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);
+ if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) return libreshot.getUrl(a);
return a.image_url;
}
diff --git a/src/providers/libreshot.zig b/src/providers/libreshot.zig
new file mode 100644
index 0000000..4977313
--- /dev/null
+++ b/src/providers/libreshot.zig
@@ -0,0 +1,381 @@
+//! LibreShot provider — Martin Vorel's free CC0 stock photography.
+//! Search: GET https://libreshot.com/?s={query}
+//! Download: Direct master-resolution JPEG from LibreShot / WordPress media 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 = "libreshot";
+pub const name = "LibreShot";
+
+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 stripGeometrySuffix(allocator: Allocator, url: []const u8) ![]u8 {
+ const last_dot = std.mem.lastIndexOfScalar(u8, url, '.') orelse return try allocator.dupe(u8, url);
+ const ext = url[last_dot..];
+ const before_ext = url[0..last_dot];
+
+ const last_dash = std.mem.lastIndexOfScalar(u8, before_ext, '-') orelse return try allocator.dupe(u8, url);
+ const dim_part = before_ext[last_dash + 1 ..];
+
+ if (std.mem.indexOfScalar(u8, dim_part, 'x')) |x_pos| {
+ const w_str = dim_part[0..x_pos];
+ const h_str = dim_part[x_pos + 1 ..];
+ if (w_str.len > 0 and h_str.len > 0) {
+ var all_digits = true;
+ for (w_str) |c| {
+ if (!std.ascii.isDigit(c)) {
+ all_digits = false;
+ break;
+ }
+ }
+ for (h_str) |c| {
+ if (!std.ascii.isDigit(c)) {
+ all_digits = false;
+ break;
+ }
+ }
+ if (all_digits) {
+ return try std.fmt.allocPrint(allocator, "{s}{s}", .{ before_ext[0..last_dash], ext });
+ }
+ }
+ }
+
+ return try allocator.dupe(u8, url);
+}
+
+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 img_marker = "
') orelse continue;
+ const img_tag = html[found .. img_end + 1];
+
+ // Extract real image URL: check data-src first, then src
+ var src_val: []const u8 = "";
+ if (std.mem.indexOf(u8, img_tag, "data-src=")) |dsi| {
+ var ds_start = dsi + "data-src=".len;
+ var is_quoted = false;
+ var quote_char: u8 = 0;
+ if (ds_start < img_tag.len and (img_tag[ds_start] == '"' or img_tag[ds_start] == '\'')) {
+ is_quoted = true;
+ quote_char = img_tag[ds_start];
+ ds_start += 1;
+ }
+ var ds_end = ds_start;
+ while (ds_end < img_tag.len) : (ds_end += 1) {
+ if (is_quoted) {
+ if (img_tag[ds_end] == quote_char) break;
+ } else {
+ if (img_tag[ds_end] == ' ' or img_tag[ds_end] == '>' or img_tag[ds_end] == '\t' or img_tag[ds_end] == '\n') break;
+ }
+ }
+ if (ds_end > ds_start) {
+ src_val = img_tag[ds_start..ds_end];
+ }
+ } else if (std.mem.indexOf(u8, img_tag, "src=")) |si| {
+ var s_start = si + "src=".len;
+ var is_quoted = false;
+ var quote_char: u8 = 0;
+ if (s_start < img_tag.len and (img_tag[s_start] == '"' or img_tag[s_start] == '\'')) {
+ is_quoted = true;
+ quote_char = img_tag[s_start];
+ s_start += 1;
+ }
+ var s_end = s_start;
+ while (s_end < img_tag.len) : (s_end += 1) {
+ if (is_quoted) {
+ if (img_tag[s_end] == quote_char) break;
+ } else {
+ if (img_tag[s_end] == ' ' or img_tag[s_end] == '>' or img_tag[s_end] == '\t' or img_tag[s_end] == '\n') break;
+ }
+ }
+ if (s_end > s_start) {
+ src_val = img_tag[s_start..s_end];
+ }
+ }
+
+ if (src_val.len == 0 or std.mem.indexOf(u8, src_val, "wp-content/uploads/") == null) continue;
+ if (std.mem.indexOf(u8, src_val, "logo") != null or std.mem.indexOf(u8, src_val, "Banner") != null) continue;
+
+ // Extract alt attribute
+ var alt_val: []const u8 = "";
+ if (std.mem.indexOf(u8, img_tag, "alt=")) |ai| {
+ var as = ai + "alt=".len;
+ var is_quoted = false;
+ var quote_char: u8 = 0;
+ if (as < img_tag.len and (img_tag[as] == '"' or img_tag[as] == '\'')) {
+ is_quoted = true;
+ quote_char = img_tag[as];
+ as += 1;
+ }
+ var ae = as;
+ while (ae < img_tag.len) : (ae += 1) {
+ if (is_quoted) {
+ if (img_tag[ae] == quote_char) break;
+ } else {
+ if (img_tag[ae] == ' ' or img_tag[ae] == '>' or img_tag[ae] == '\t' or img_tag[ae] == '\n') break;
+ }
+ }
+ if (ae > as) {
+ alt_val = img_tag[as..ae];
+ }
+ }
+
+ // Find enclosing
+ const a_search_start = if (found > 500) found - 500 else 0;
+ const a_chunk = html[a_search_start..found];
+ const href_val: []const u8 = blk: {
+ if (std.mem.lastIndexOf(u8, a_chunk, "' or a_chunk[he] == '\t' or a_chunk[he] == '\n') break;
+ }
+ }
+ if (he > hs) break :blk a_chunk[hs..he];
+ }
+ break :blk "";
+ };
+
+ // Determine ID from page URL or filename
+ var raw_slug: []const u8 = "";
+ if (href_val.len > 0 and std.mem.startsWith(u8, href_val, "https://libreshot.com/")) {
+ var trimmed = href_val["https://libreshot.com/".len..];
+ trimmed = std.mem.trim(u8, trimmed, "/");
+ if (trimmed.len > 0) raw_slug = trimmed;
+ }
+ if (raw_slug.len == 0) {
+ const last_slash = std.mem.lastIndexOfScalar(u8, src_val, '/') orelse continue;
+ raw_slug = src_val[last_slash + 1 ..];
+ }
+
+ // Strip file extension and dimension suffixes from ID
+ var clean_id = raw_slug;
+ if (std.mem.lastIndexOfScalar(u8, clean_id, '.')) |dot_idx| {
+ clean_id = clean_id[0..dot_idx];
+ }
+ if (std.mem.lastIndexOfScalar(u8, clean_id, '-')) |dash_idx| {
+ const dim_part = clean_id[dash_idx + 1 ..];
+ if (std.mem.indexOfScalar(u8, dim_part, 'x')) |x_pos| {
+ const w_str = dim_part[0..x_pos];
+ const h_str = dim_part[x_pos + 1 ..];
+ var all_digits = (w_str.len > 0 and h_str.len > 0);
+ for (w_str) |c| if (!std.ascii.isDigit(c)) {
+ all_digits = false;
+ break;
+ };
+ for (h_str) |c| if (!std.ascii.isDigit(c)) {
+ all_digits = false;
+ break;
+ };
+ if (all_digits) {
+ clean_id = clean_id[0..dash_idx];
+ }
+ }
+ }
+
+ const pid = try allocator.dupe(u8, clean_id);
+ errdefer allocator.free(pid);
+
+ if (seen.contains(pid)) {
+ allocator.free(pid);
+ continue;
+ }
+ try seen.put(allocator, try allocator.dupe(u8, pid), {});
+
+ const title_unesc = try htmlUnescape(allocator, if (alt_val.len > 0) alt_val else clean_id);
+ defer allocator.free(title_unesc);
+
+ const desc = if (title_unesc.len > 0) title_unesc else pid;
+ 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 stripGeometrySuffix(allocator, src_val);
+ errdefer allocator.free(master_url);
+
+ const thumb_owned = try allocator.dupe(u8, src_val);
+ errdefer allocator.free(thumb_owned);
+ const prov_owned = try allocator.dupe(u8, id);
+ errdefer allocator.free(prov_owned);
+ const auth_owned = try allocator.dupe(u8, "Martin Vorel");
+ errdefer allocator.free(auth_owned);
+
+ const page_url = if (href_val.len > 0) href_val else master_url;
+ var meta_aw: Io.Writer.Allocating = .init(allocator);
+ defer meta_aw.deinit();
+ try meta_aw.writer.print("{{\"source\":\"libreshot\",\"page\":\"{s}\"}}", .{page_url});
+ 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_owned,
+ .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 encoded_query = try http_client.queryEscape(allocator, trimmed);
+ defer allocator.free(encoded_query);
+
+ const search_url = try std.fmt.allocPrint(allocator, "https://libreshot.com/?s={s}", .{encoded_query});
+ 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://libreshot.com/",
+ .max_redirects = 5,
+ });
+ defer resp.deinit();
+ if (resp.status != 200) return error.HttpStatus;
+
+ 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, 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://libreshot.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 4c7c25b..cda1a1a 100644
--- a/src/providers/registry.zig
+++ b/src/providers/registry.zig
@@ -17,6 +17,7 @@ const splitshire = @import("splitshire.zig");
const deviantart = @import("deviantart.zig");
const negativespace = @import("negativespace.zig");
const skitterphoto = @import("skitterphoto.zig");
+const libreshot = @import("libreshot.zig");
pub const ProviderInfo = struct {
id: []const u8,
@@ -41,6 +42,7 @@ pub fn list() []const ProviderInfo {
.{ .id = deviantart.id, .name = deviantart.name },
.{ .id = negativespace.id, .name = negativespace.name },
.{ .id = skitterphoto.id, .name = skitterphoto.name },
+ .{ .id = libreshot.id, .name = libreshot.name },
};
}
@@ -59,5 +61,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";
+ return "aura, unsplash, isorepublic, picjumbo, foodiesfeed, picography, gratisography, startupstockphotos, burst, jaymantri, publicdomainarchive, magdeleine, splitshire, deviantart, negativespace, skitterphoto, libreshot";
}