From 3eb50561b03953c1c78844aa3e1228a632485bf0 Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Tue, 1 Sep 2026 20:53:18 +0530 Subject: [PATCH 1/3] feat(providers): add LibreShot stock photo provider --- src/providers/libreshot.zig | 381 ++++++++++++++++++++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 src/providers/libreshot.zig 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; +} From e8d5640742798a47edaa9c6523ea1eaafa04fef0 Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Tue, 1 Sep 2026 20:53:20 +0530 Subject: [PATCH 2/3] feat(registry): register libreshot provider and wire search and interactive CLI --- src/commands/search.zig | 9 +++++++++ src/interactive.zig | 9 +++++++++ src/providers/registry.zig | 4 +++- 3 files changed, 21 insertions(+), 1 deletion(-) 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/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"; } From b3c2f4ea21095b795c02ee6f637936403a7021a2 Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Tue, 1 Sep 2026 20:53:23 +0530 Subject: [PATCH 3/3] docs: update provider reference and tracker for LibreShot --- README.md | 2 ++ docs/providers.md | 13 +++++++++++++ docs/stock_photo_sites.md | 2 +- 3 files changed, 16 insertions(+), 1 deletion(-) 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 ---