diff --git a/README.md b/README.md index 7b0a8f0..ee4c7d8 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ Provider and query may be given as positionals after `s` / `-s`, or with `-P` / | `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. | +| `moveast` | [Moveast](https://moveast.me) | Free CC0 travel and documentary photography by João Pacheco | Direct high-resolution photo downloads. | List providers at runtime via `ast help` (printed under **PROVIDERS**). @@ -304,6 +305,7 @@ src/ negativespace.zig skitterphoto.zig libreshot.zig + moveast.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 a54d7b5..e58d08c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -208,3 +208,16 @@ This document summarizes the network endpoints, transport models, and image down * 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. + +--- + +## 18. Moveast (`moveast.me`) + +* **Search**: `GET https://moveast.me/api/read/json?tagged={tag}&num={limit}` (with fallback to main feed). +* **Format**: Tumblr JSON feed by Portuguese designer/traveler João Pacheco. +* **Extraction**: + * Clean HTML stripped from photo captions. + * Tag buffer and slug fallback for descriptive search prompts. + * 1280px / 500px CDN image endpoints. +* **Download**: Direct GET on high-resolution Tumblr CDN JPEG assets. +* **License**: CC0 / 100% Free Public Domain. diff --git a/docs/stock_photo_sites.md b/docs/stock_photo_sites.md index 40c0ef7..47ac60f 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)), `libreshot` ([LibreShot.com](https://libreshot.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)), `moveast` ([Moveast.me](https://moveast.me)) - **Unimplemented / Blocked / Pending**: 11 --- diff --git a/src/commands/search.zig b/src/commands/search.zig index 3573fb8..45c14c0 100644 --- a/src/commands/search.zig +++ b/src/commands/search.zig @@ -20,6 +20,7 @@ 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 moveast = @import("../providers/moveast.zig"); const stdio = @import("../stdio.zig"); const Allocator = std.mem.Allocator; const Io = std.Io; @@ -92,6 +93,9 @@ fn doSearch( if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) { return libreshot.search(client, allocator, query, limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) { + return moveast.search(client, allocator, query, limit); + } return error.UnknownProvider; } @@ -154,6 +158,9 @@ fn doDownload( if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) { return libreshot.download(client, allocator, io, a, output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) { + return moveast.download(client, allocator, io, a, output_dir); + } return error.UnknownProvider; } @@ -175,6 +182,7 @@ fn doPrompt(provider_id: []const u8, a: Asset) ?[]const u8 { 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); + if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getPrompt(a); return a.prompt orelse a.description; } @@ -196,6 +204,7 @@ fn doUrl(provider_id: []const u8, a: Asset) ?[]const u8 { 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); + if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getUrl(a); return a.image_url; } diff --git a/src/interactive.zig b/src/interactive.zig index 44f9200..08b3628 100644 --- a/src/interactive.zig +++ b/src/interactive.zig @@ -20,6 +20,7 @@ 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 moveast = @import("providers/moveast.zig"); const registry = @import("providers/registry.zig"); const stdio = @import("stdio.zig"); const Allocator = std.mem.Allocator; @@ -89,6 +90,9 @@ fn searchProvider( if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) { return libreshot.search(client, allocator, query, config.default_limit); } + if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) { + return moveast.search(client, allocator, query, config.default_limit); + } return error.UnknownProvider; } @@ -150,6 +154,9 @@ fn downloadAsset( if (std.ascii.eqlIgnoreCase(provider_id, "libreshot")) { return libreshot.download(client, allocator, io, a, config.default_output_dir); } + if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) { + return moveast.download(client, allocator, io, a, config.default_output_dir); + } return error.UnknownProvider; } @@ -171,6 +178,7 @@ fn promptOf(provider_id: []const u8, a: Asset) ?[]const u8 { 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); + if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getPrompt(a); return a.prompt; } @@ -192,6 +200,7 @@ fn urlOf(provider_id: []const u8, a: Asset) ?[]const u8 { 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); + if (std.ascii.eqlIgnoreCase(provider_id, "moveast")) return moveast.getUrl(a); return a.image_url; } diff --git a/src/providers/moveast.zig b/src/providers/moveast.zig new file mode 100644 index 0000000..cfc8b8a --- /dev/null +++ b/src/providers/moveast.zig @@ -0,0 +1,275 @@ +//! Moveast provider — free CC0 travel and journey stock photography by João Pacheco. +//! Search: GET https://moveast.me/api/read/json?num=50&type=photo +//! Download: Direct high-resolution JPEG from Tumblr 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 = "moveast"; +pub const name = "Moveast"; + +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 stripHtmlTags(allocator: Allocator, html: []const u8) ![]u8 { + var list: std.ArrayList(u8) = .empty; + defer list.deinit(allocator); + + var in_tag = false; + for (html) |c| { + if (c == '<') { + in_tag = true; + } else if (c == '>') { + in_tag = false; + } else if (!in_tag) { + if (c == '\n' or c == '\r' or c == '\t') { + if (list.items.len > 0 and list.items[list.items.len - 1] != ' ') { + try list.append(allocator, ' '); + } + } else { + try list.append(allocator, c); + } + } + } + const trimmed = std.mem.trim(u8, list.items, " \t\r\n"); + return try allocator.dupe(u8, trimmed); +} + +fn parseSearch(allocator: Allocator, json_text: []const u8, query: []const u8, limit: u32) ![]Asset { + var raw = json_text; + const prefix = "var tumblr_api_read = "; + if (std.mem.startsWith(u8, raw, prefix)) { + raw = raw[prefix.len..]; + } + raw = std.mem.trimEnd(u8, raw, "; \n\r\t"); + + var parsed = try std.json.parseFromSlice(std.json.Value, allocator, raw, .{}); + defer parsed.deinit(); + + const root = parsed.value; + if (root != .object) return &[_]Asset{}; + + const posts_val = root.object.get("posts") orelse return &[_]Asset{}; + if (posts_val != .array) return &[_]Asset{}; + + var assets: std.ArrayList(Asset) = .empty; + errdefer { + for (assets.items) |*a| a.deinit(allocator); + assets.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, "moveast") or + std.ascii.eqlIgnoreCase(trimmed_query, "photos") or + trimmed_query.len == 0; + + for (posts_val.array.items) |p| { + if (assets.items.len >= limit) break; + if (p != .object) continue; + + const post_id_val = p.object.get("id") orelse continue; + const post_id = switch (post_id_val) { + .string => |s| s, + .integer => |i| blk: { + var buf: [32]u8 = undefined; + const s = std.fmt.bufPrint(&buf, "{d}", .{i}) catch continue; + break :blk s; + }, + else => continue, + }; + + const raw_caption = if (p.object.get("photo-caption")) |c| (if (c == .string) c.string else "") else ""; + const slug = if (p.object.get("slug")) |s| (if (s == .string) s.string else "") else ""; + const photo_1280 = if (p.object.get("photo-url-1280")) |u| (if (u == .string) u.string else null) else null; + const photo_500 = if (p.object.get("photo-url-500")) |u| (if (u == .string) u.string else null) else null; + + var tags_buf: std.ArrayList(u8) = .empty; + defer tags_buf.deinit(allocator); + if (p.object.get("tags")) |tags_val| { + if (tags_val == .array) { + for (tags_val.array.items) |tag_item| { + if (tag_item == .string) { + if (tags_buf.items.len > 0) try tags_buf.appendSlice(allocator, ", "); + try tags_buf.appendSlice(allocator, tag_item.string); + } + } + } + } + + const clean_caption = try stripHtmlTags(allocator, raw_caption); + defer allocator.free(clean_caption); + + // Check if query matches + var matched = match_all; + if (!matched) { + if (containsIgnoreCase(tags_buf.items, trimmed_query) or + containsIgnoreCase(clean_caption, trimmed_query) or + containsIgnoreCase(slug, trimmed_query)) + { + matched = true; + } + } + if (!matched) continue; + + const best_img = photo_1280 orelse photo_500 orelse continue; + const thumb_img = photo_500 orelse photo_1280 orelse best_img; + + // Description / prompt + const desc = if (clean_caption.len > 0) + clean_caption + else if (slug.len > 0 and !std.mem.eql(u8, slug, "download")) + slug + else if (tags_buf.items.len > 0) + tags_buf.items + else + "Moveast Stock Photo"; + + const pid_owned = try allocator.dupe(u8, post_id); + errdefer allocator.free(pid_owned); + 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 img_owned = try allocator.dupe(u8, best_img); + errdefer allocator.free(img_owned); + const thumb_owned = try allocator.dupe(u8, thumb_img); + errdefer allocator.free(thumb_owned); + const provider_owned = try allocator.dupe(u8, id); + errdefer allocator.free(provider_owned); + + var meta_aw: Io.Writer.Allocating = .init(allocator); + defer meta_aw.deinit(); + try meta_aw.writer.print("{{\"source\":\"moveast\",\"page\":\"https://moveast.me/post/{s}\"}}", .{post_id}); + const meta = try meta_aw.toOwnedSlice(); + + try assets.append(allocator, .{ + .id = pid_owned, + .description = desc_owned, + .prompt = prompt_owned, + .image_url = img_owned, + .thumbnail_url = thumb_owned, + .provider = provider_owned, + .author = try allocator.dupe(u8, "João Pacheco"), + .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); + + // Try searching by tag first + const tagged_url = try std.fmt.allocPrint(allocator, "https://moveast.me/api/read/json?tagged={s}&num=50&type=photo", .{enc}); + defer allocator.free(tagged_url); + + var resp = try http_client.get(client, allocator, tagged_url, .{ + .accept = "application/json,text/javascript,*/*;q=0.8", + .referer = "https://moveast.me/", + .max_redirects = 5, + }); + defer resp.deinit(); + + var assets: []Asset = if (resp.status == 200) + try parseSearch(allocator, resp.body, trimmed, lim) + else + try allocator.alloc(Asset, 0); + + // Fallback: if tag returned 0 results, fetch global feed and match client-side + if (assets.len == 0) { + allocator.free(assets); + const global_url = "https://moveast.me/api/read/json?num=50&type=photo"; + var global_resp = try http_client.get(client, allocator, global_url, .{ + .accept = "application/json,text/javascript,*/*;q=0.8", + .referer = "https://moveast.me/", + .max_redirects = 5, + }); + defer global_resp.deinit(); + if (global_resp.status != 200) return error.HttpStatus; + assets = try parseSearch(allocator, global_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://moveast.me/", + .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 cda1a1a..ff569f4 100644 --- a/src/providers/registry.zig +++ b/src/providers/registry.zig @@ -18,6 +18,7 @@ const deviantart = @import("deviantart.zig"); const negativespace = @import("negativespace.zig"); const skitterphoto = @import("skitterphoto.zig"); const libreshot = @import("libreshot.zig"); +const moveast = @import("moveast.zig"); pub const ProviderInfo = struct { id: []const u8, @@ -43,6 +44,7 @@ pub fn list() []const ProviderInfo { .{ .id = negativespace.id, .name = negativespace.name }, .{ .id = skitterphoto.id, .name = skitterphoto.name }, .{ .id = libreshot.id, .name = libreshot.name }, + .{ .id = moveast.id, .name = moveast.name }, }; } @@ -61,5 +63,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"; + return "aura, unsplash, isorepublic, picjumbo, foodiesfeed, picography, gratisography, startupstockphotos, burst, jaymantri, publicdomainarchive, magdeleine, splitshire, deviantart, negativespace, skitterphoto, libreshot, moveast"; }