From bdd2485d36595b7f8ad619d8b7d115a3c0c86f32 Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Wed, 2 Sep 2026 22:51:04 +0530 Subject: [PATCH 1/3] feat(providers): add Vecteezy stock photo provider --- src/providers/vecteezy.zig | 330 +++++++++++++++++++++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 src/providers/vecteezy.zig diff --git a/src/providers/vecteezy.zig b/src/providers/vecteezy.zig new file mode 100644 index 0000000..4cbd44b --- /dev/null +++ b/src/providers/vecteezy.zig @@ -0,0 +1,330 @@ +//! Vecteezy provider — free stock photos, vectors, and design assets. +//! Search: GET https://www.vecteezy.com/search?qterm={query}&content_type=photo&license_class=free +//! Download: Direct master full-resolution media from CloudFront / Vecteezy 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 = "vecteezy"; +pub const name = "Vecteezy"; + +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..], "&")) { + 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 extractResourceId(allocator: Allocator, url: []const u8) ![]u8 { + // URL looks like: https://static.vecteezy.com/system/resources/previews/004/572/561/non_2x/sunset-sky... + const marker = "/previews/"; + if (std.mem.indexOf(u8, url, marker)) |idx| { + const sub = url[idx + marker.len ..]; + if (std.mem.indexOf(u8, sub, "/non_2x/")) |end_idx| { + const raw_id_part = sub[0..end_idx]; + var digits: std.ArrayList(u8) = .empty; + defer digits.deinit(allocator); + for (raw_id_part) |c| { + if (std.ascii.isDigit(c)) { + try digits.append(allocator, c); + } + } + if (digits.items.len > 0) { + return try std.fmt.allocPrint(allocator, "vecteezy-{s}", .{digits.items}); + } + } + } + // Fallback: use filename stem + const last_slash = std.mem.lastIndexOfScalar(u8, url, '/') orelse return allocator.dupe(u8, "vecteezy-photo"); + var fn_stem = url[last_slash + 1 ..]; + if (std.mem.lastIndexOfScalar(u8, fn_stem, '.')) |dot| { + fn_stem = fn_stem[0..dot]; + } + return try std.fmt.allocPrint(allocator, "vecteezy-{s}", .{fn_stem}); +} + +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 script_marker = "') orelse continue; + const json_body = full_script[tag_close + 1 .. full_script.len - script_end_tag.len]; + + var parsed = std.json.parseFromSlice(std.json.Value, allocator, json_body, .{}) catch continue; + defer parsed.deinit(); + + const root = parsed.value; + if (root != .object) continue; + + const name_val = root.object.get("name"); + const name_str = if (name_val) |nv| switch (nv) { + .string => |s| s, + else => "", + } else ""; + + const thumb_val = root.object.get("thumbnailUrl"); + const thumb_str = if (thumb_val) |tv| switch (tv) { + .string => |s| s, + else => "", + } else ""; + + const content_val = root.object.get("contentUrl"); + const content_str = if (content_val) |cv| switch (cv) { + .string => |s| s, + else => "", + } else ""; + + if (content_str.len == 0 and thumb_str.len == 0) continue; + + const active_url = if (content_str.len > 0) content_str else thumb_str; + const res_id = try extractResourceId(allocator, active_url); + errdefer allocator.free(res_id); + + if (seen.contains(res_id)) { + allocator.free(res_id); + continue; + } + try seen.put(allocator, try allocator.dupe(u8, res_id), {}); + + // Build master URL by substituting /non_2x/ with /original/ + const master_url = if (std.mem.indexOf(u8, content_str, "/non_2x/")) |n_idx| blk: { + const before = content_str[0..n_idx]; + const after = content_str[n_idx + "/non_2x/".len ..]; + break :blk try std.fmt.allocPrint(allocator, "{s}/original/{s}", .{ before, after }); + } else try allocator.dupe(u8, active_url); + errdefer allocator.free(master_url); + + const thumb_owned = if (thumb_str.len > 0) + try allocator.dupe(u8, thumb_str) + else + try allocator.dupe(u8, master_url); + errdefer allocator.free(thumb_owned); + + const clean_desc = try htmlUnescape(allocator, if (name_str.len > 0) name_str else res_id); + errdefer allocator.free(clean_desc); + + const prompt_owned = try allocator.dupe(u8, clean_desc); + errdefer allocator.free(prompt_owned); + + const prov_owned = try allocator.dupe(u8, id); + errdefer allocator.free(prov_owned); + + const auth_owned = try allocator.dupe(u8, "Vecteezy Contributor"); + errdefer allocator.free(auth_owned); + + var meta_aw: Io.Writer.Allocating = .init(allocator); + defer meta_aw.deinit(); + try meta_aw.writer.print("{{\"source\":\"vecteezy\",\"page\":\"https://www.vecteezy.com/\"}}", .{}); + const meta = try meta_aw.toOwnedSlice(); + + _ = trimmed_query; + + try assets.append(allocator, .{ + .id = res_id, + .description = clean_desc, + .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 enc = try http_client.queryEscape(allocator, trimmed); + defer allocator.free(enc); + + const lim = @min(@max(limit, 1), 50); + + const search_url = try std.fmt.allocPrint( + allocator, + "https://www.vecteezy.com/search?qterm={s}&content_type=photo&license_class=free", + .{enc}, + ); + 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://www.vecteezy.com/", + .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; + + // Try primary master original URL first + var body_opt: ?[]u8 = http_client.getBody(client, allocator, url, .{ + .referer = "https://www.vecteezy.com/", + .accept = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + .max_redirects = 5, + }) catch null; + + // If master /original/ was not found or forbidden, fallback to /large_2x/ or thumbnail URL + if (body_opt == null) { + if (std.mem.indexOf(u8, url, "/original/")) |o_idx| { + const fallback_url = try std.fmt.allocPrint( + allocator, + "{s}/non_2x/{s}", + .{ url[0..o_idx], url[o_idx + "/original/".len ..] }, + ); + defer allocator.free(fallback_url); + body_opt = http_client.getBody(client, allocator, fallback_url, .{ + .referer = "https://www.vecteezy.com/", + .accept = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + .max_redirects = 5, + }) catch null; + } + } + + // Final fallback to thumbnail + if (body_opt == null and a.thumbnail_url != null) { + body_opt = http_client.getBody(client, allocator, a.thumbnail_url.?, .{ + .referer = "https://www.vecteezy.com/", + .accept = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", + .max_redirects = 5, + }) catch null; + } + + const body = body_opt orelse return error.HttpStatus; + 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 c88c1c884d07021e55abe9a3bc125a1192dd08f5 Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Wed, 2 Sep 2026 22:51:08 +0530 Subject: [PATCH 2/3] feat(registry): register vecteezy 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 9d8e783..fe97ec7 100644 --- a/src/commands/search.zig +++ b/src/commands/search.zig @@ -25,6 +25,7 @@ const cupcake = @import("../providers/cupcake.zig"); const freenaturestock = @import("../providers/freenaturestock.zig"); const goodfreephotos = @import("../providers/goodfreephotos.zig"); const wikimedia = @import("../providers/wikimedia.zig"); +const vecteezy = @import("../providers/vecteezy.zig"); const stdio = @import("../stdio.zig"); const Allocator = std.mem.Allocator; const Io = std.Io; @@ -112,6 +113,9 @@ fn doSearch( if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) { return wikimedia.search(client, allocator, query, limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "vecteezy")) { + return vecteezy.search(client, allocator, query, limit); + } return error.UnknownProvider; } @@ -189,6 +193,9 @@ fn doDownload( if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) { return wikimedia.download(client, allocator, io, a, output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "vecteezy")) { + return vecteezy.download(client, allocator, io, a, output_dir); + } return error.UnknownProvider; } @@ -215,6 +222,7 @@ fn doPrompt(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) return wikimedia.getPrompt(a); + if (std.ascii.eqlIgnoreCase(provider_id, "vecteezy")) return vecteezy.getPrompt(a); return a.prompt orelse a.description; } @@ -241,6 +249,7 @@ fn doUrl(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) return wikimedia.getUrl(a); + if (std.ascii.eqlIgnoreCase(provider_id, "vecteezy")) return vecteezy.getUrl(a); return a.image_url; } diff --git a/src/interactive.zig b/src/interactive.zig index 76308bf..8bb39b1 100644 --- a/src/interactive.zig +++ b/src/interactive.zig @@ -25,6 +25,7 @@ const cupcake = @import("providers/cupcake.zig"); const freenaturestock = @import("providers/freenaturestock.zig"); const goodfreephotos = @import("providers/goodfreephotos.zig"); const wikimedia = @import("providers/wikimedia.zig"); +const vecteezy = @import("providers/vecteezy.zig"); const registry = @import("providers/registry.zig"); const stdio = @import("stdio.zig"); const Allocator = std.mem.Allocator; @@ -109,6 +110,9 @@ fn searchProvider( if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) { return wikimedia.search(client, allocator, query, config.default_limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "vecteezy")) { + return vecteezy.search(client, allocator, query, config.default_limit); + } return error.UnknownProvider; } @@ -185,6 +189,9 @@ fn downloadAsset( if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) { return wikimedia.download(client, allocator, io, a, config.default_output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "vecteezy")) { + return vecteezy.download(client, allocator, io, a, config.default_output_dir); + } return error.UnknownProvider; } @@ -211,6 +218,7 @@ fn promptOf(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getPrompt(a); if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) return wikimedia.getPrompt(a); + if (std.ascii.eqlIgnoreCase(provider_id, "vecteezy")) return vecteezy.getPrompt(a); return a.prompt; } @@ -237,6 +245,7 @@ fn urlOf(provider_id: []const u8, a: Asset) ?[]const u8 { if (std.ascii.eqlIgnoreCase(provider_id, "freenaturestock")) return freenaturestock.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "goodfreephotos")) return goodfreephotos.getUrl(a); if (std.ascii.eqlIgnoreCase(provider_id, "wikimedia")) return wikimedia.getUrl(a); + if (std.ascii.eqlIgnoreCase(provider_id, "vecteezy")) return vecteezy.getUrl(a); return a.image_url; } diff --git a/src/providers/registry.zig b/src/providers/registry.zig index f0d5cea..1435749 100644 --- a/src/providers/registry.zig +++ b/src/providers/registry.zig @@ -23,6 +23,7 @@ const cupcake = @import("cupcake.zig"); const freenaturestock = @import("freenaturestock.zig"); const goodfreephotos = @import("goodfreephotos.zig"); const wikimedia = @import("wikimedia.zig"); +const vecteezy = @import("vecteezy.zig"); pub const ProviderInfo = struct { id: []const u8, @@ -53,6 +54,7 @@ pub fn list() []const ProviderInfo { .{ .id = freenaturestock.id, .name = freenaturestock.name }, .{ .id = goodfreephotos.id, .name = goodfreephotos.name }, .{ .id = wikimedia.id, .name = wikimedia.name }, + .{ .id = vecteezy.id, .name = vecteezy.name }, }; } @@ -71,5 +73,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, libreshot, moveast, cupcake, freenaturestock, goodfreephotos, wikimedia"; + return "aura, unsplash, isorepublic, picjumbo, foodiesfeed, picography, gratisography, startupstockphotos, burst, jaymantri, publicdomainarchive, magdeleine, splitshire, deviantart, negativespace, skitterphoto, libreshot, moveast, cupcake, freenaturestock, goodfreephotos, wikimedia, vecteezy"; } From 335e52449238bca66b73f16aa33175613797e4b2 Mon Sep 17 00:00:00 2001 From: gitadityakumar Date: Wed, 2 Sep 2026 22:51:11 +0530 Subject: [PATCH 3/3] docs: update provider reference and tracker for Vecteezy --- README.md | 2 ++ docs/providers.md | 13 +++++++++++++ docs/stock_photo_sites.md | 11 ++++++----- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 174ebe1..c17ce66 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,7 @@ Provider and query may be given as positionals after `s` / `-s`, or with `-P` / | `freenaturestock` | [Free Nature Stock](https://freenaturestock.com) | Free CC0 nature & wildlife stock photography by Adrian Pelletier | Direct master full-resolution photo downloads (up to 20+ MB). | | `goodfreephotos` | [Good Free Photos](https://goodfreephotos.com) | Free CC0 public domain photos, state parks, and landmarks by Yinan Chen | Direct master full-resolution photo downloads. | | `wikimedia` | [Wikimedia Commons](https://commons.wikimedia.org) | Freely licensed and public domain educational & stock media repository | Direct master full-resolution image downloads. | +| `vecteezy` | [Vecteezy](https://vecteezy.com) | Free high-resolution stock photography and design assets | Direct master full-resolution photo downloads (up to 15+ MB). | List providers at runtime via `ast help` (printed under **PROVIDERS**). @@ -314,6 +315,7 @@ src/ freenaturestock.zig goodfreephotos.zig wikimedia.zig + vecteezy.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 2747c54..6c03118 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -275,3 +275,16 @@ This document summarizes the network endpoints, transport models, and image down * Dimensions extracted from `imageinfo.width` and `imageinfo.height`. * **Download**: Direct GET on full master resolution media via `upload.wikimedia.org`. * **License**: Public Domain, CC0, and Creative Commons. + +--- + +## 23. Vecteezy (`vecteezy.com`) + +* **Search**: `GET https://www.vecteezy.com/search?qterm={query}&content_type=photo&license_class=free` +* **Format**: Structured search results containing embedded JSON-LD schema (`application/ld+json`). +* **Extraction**: + * Clean image IDs extracted from resource digits (`vecteezy-{resource_id}`). + * Descriptive prompt titles extracted and HTML unescaped from schema `name`. + * Master full-resolution URLs constructed by replacing `/non_2x/` previews with `/original/` master assets on Vecteezy CDN. +* **Download**: Direct GET on master original camera photo JPEG files (up to 15+ MB) with automated preview fallback. +* **License**: Vecteezy Free License. diff --git a/docs/stock_photo_sites.md b/docs/stock_photo_sites.md index 279bf6c..b0c3a19 100644 --- a/docs/stock_photo_sites.md +++ b/docs/stock_photo_sites.md @@ -7,9 +7,9 @@ This document tracks all 24 stock photo websites listed in the [GrayGrids 21+ Be ## Summary Status - **Total Sites Listed**: 24 -- **Implemented & Working**: 13 (`unsplash`, `isorepublic`, `picjumbo`, `foodiesfeed`, `picography`, `gratisography`, `startupstockphotos`, `burst`, `jaymantri`, `publicdomainarchive`, `magdeleine`, `splitshire`, `deviantart`) +- **Implemented & Working**: 14 (`unsplash`, `isorepublic`, `picjumbo`, `foodiesfeed`, `picography`, `gratisography`, `startupstockphotos`, `burst`, `jaymantri`, `publicdomainarchive`, `magdeleine`, `splitshire`, `deviantart`, `vecteezy`) - *(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)), `freenaturestock` ([FreeNatureStock](https://freenaturestock.com)), `goodfreephotos` ([Good Free Photos](https://goodfreephotos.com)), `wikimedia` ([Wikimedia Commons](https://commons.wikimedia.org)) -- **Unimplemented / Blocked / Pending**: 11 +- **Unimplemented / Blocked / Pending**: 10 --- @@ -38,9 +38,10 @@ This document tracks all 24 stock photo websites listed in the [GrayGrids 21+ Be - **Status**: Implemented & verified. - **Notes**: Search and full master-resolution CC0 photo downloading supported. -- [ ] **Vecteezy** ([vecteezy.com](https://www.vecteezy.com/)) - - **Status**: Unimplemented. - - **Notes**: Mixed free/pro licensing with attribution and auth requirements. +- [x] **Vecteezy** ([vecteezy.com](https://www.vecteezy.com/)) + - **CLI Provider**: `vecteezy` + - **Status**: Implemented & verified. + - **Notes**: Free photo search and direct full-resolution master photo downloading (up to 15+ MB) via CloudFront CDN supported. - [ ] **Freepik** ([freepik.com](https://www.freepik.com/)) - **Status**: Unimplemented.