mirror of
https://github.com/direct-img/direct-img.link.git
synced 2026-10-19 02:44:52 +00:00
Feat: Add free.direct-img.link and ?i= result picker (#1)
- free.direct-img.link serves only unrestricted (public domain/CC0) images from Openverse (license=cc0,pdm) and Wikimedia Commons (haslicense:unrestricted), with optional ?src=openverse|wikimedia - ?i=1..20 picks the i-th working image on both sites; broken links are skipped so every index is a different image - Openverse OAuth token is cached in KV and refreshed automatically - Skip Flickr "photo unavailable" placeholders (served with HTTP 200) - Free/indexed cache keys use uppercase prefixes; plain main-site keys are unchanged so existing cache entries stay valid - Update README, homepage, privacy policy and terms Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+47
-24
@@ -1,7 +1,10 @@
|
||||
import { braveImageSearch } from "./_utils/brave.js";
|
||||
import { bingImageSearchFallback } from "./_utils/bing.js";
|
||||
import { freeImageUrls, parseFreeSource, FREE_UA } from "./_utils/free.js";
|
||||
import { isFlickrPlaceholder } from "./_utils/flickr.js";
|
||||
|
||||
const TTL_SECONDS = 90 * 24 * 60 * 60;
|
||||
const MAX_INDEX = 20;
|
||||
|
||||
export async function onRequest(context) {
|
||||
const { request, env, params } = context;
|
||||
@@ -23,10 +26,18 @@ export async function onRequest(context) {
|
||||
if (!query) return jsonResponse(400, { error: "Empty query" });
|
||||
if (query.length > 200) return jsonResponse(400, { error: "Query too long (max 200 characters)" });
|
||||
|
||||
context.waitUntil(countHit(env, request, query));
|
||||
// ?i= picks which result to serve; free.direct-img.link also takes ?src= and serves only unrestricted images
|
||||
const free = url.hostname.startsWith("free.");
|
||||
const i = parseIndex(url.searchParams);
|
||||
const src = free ? parseFreeSource(url.searchParams) : null;
|
||||
if (!i || (free && !src)) return asset("bad.webp");
|
||||
|
||||
const cacheKey = query;
|
||||
const r2Key = await sha256(query);
|
||||
context.waitUntil(countHit(env, request, query, free));
|
||||
|
||||
// Main-site i=1 keeps the bare query so existing cache entries stay valid.
|
||||
// Other keys use uppercase prefixes, which can't collide with (always lowercased) queries.
|
||||
const cacheKey = free ? `FREE:${src}:${i}:${query}` : i === 1 ? query : `WEB:${i}:${query}`;
|
||||
const r2Key = `${free ? "free/" : ""}${await sha256(cacheKey)}`;
|
||||
|
||||
const cached = await env.DIRECT_IMG_CACHE.get(cacheKey, "json");
|
||||
if (cached) {
|
||||
@@ -125,7 +136,7 @@ export async function onRequest(context) {
|
||||
return asset("limit.webp");
|
||||
}
|
||||
|
||||
context.waitUntil(notify(env, { title: "New Search", message: `Query: ${query} (Search #${count} for ${ip})\n${url.origin}/${path}`, tags: "mag", priority: 2 }));
|
||||
context.waitUntil(notify(env, { title: free ? "New Free Search" : "New Search", message: `Query: ${query} (Search #${count} for ${ip})\n${url.origin}/${path}${url.search}`, tags: "mag", priority: 2 }));
|
||||
|
||||
const fail = async (t, m, tag, p) => {
|
||||
context.waitUntil(notify(env, { title: t, message: m, tags: tag, priority: p }));
|
||||
@@ -133,29 +144,25 @@ export async function onRequest(context) {
|
||||
return asset("bad.webp");
|
||||
};
|
||||
|
||||
let imageUrls = await braveImageSearch(query, env.BRAVE_API_KEY);
|
||||
|
||||
if (!imageUrls || imageUrls.length === 0) {
|
||||
context.waitUntil(notify(env, { title: "Brave Search Empty", message: `No results for: ${query}. Trying Bing Fallback.`, tags: "warning,mag", priority: 3 }));
|
||||
imageUrls = await bingImageSearchFallback(query);
|
||||
}
|
||||
|
||||
if (!imageUrls || imageUrls.length === 0) return await fail("Search Failed", `Both Brave and Bing returned no results for: ${query}`, "question", 3);
|
||||
|
||||
const imageUrls = free ? freeImageUrls(query, src, env) : webImageUrls(context, query);
|
||||
const GLOBAL_DEADLINE = Date.now() + 20000;
|
||||
let imgResult = null;
|
||||
let imgResult = null, tried = 0, found = 0;
|
||||
const failReasons = [];
|
||||
|
||||
for (const imgUrl of imageUrls) {
|
||||
// i counts only images that download, so dead links and placeholders never take up an index
|
||||
for await (const imgUrl of imageUrls) {
|
||||
tried++;
|
||||
const remaining = GLOBAL_DEADLINE - Date.now();
|
||||
if (remaining <= 500) {
|
||||
failReasons.push("Global timeout reached");
|
||||
break;
|
||||
}
|
||||
const res = await fetchImage(imgUrl, Math.min(remaining, 5000));
|
||||
const res = await fetchImage(imgUrl, Math.min(remaining, 5000), free ? FREE_UA : undefined);
|
||||
if (res.success) {
|
||||
imgResult = res;
|
||||
break;
|
||||
if (++found === i) {
|
||||
imgResult = res;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const host = new URL(imgUrl).hostname.replace(/^www\./, '');
|
||||
@@ -166,9 +173,10 @@ export async function onRequest(context) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!tried) return await fail("Search Failed", `${free ? "No free results" : "Both Brave and Bing returned no results"} for: ${cacheKey}`, "question", 3);
|
||||
if (!imgResult) {
|
||||
const reasonStr = failReasons.slice(0, 6).join(", ") + (failReasons.length > 6 ? ", ..." : "");
|
||||
return await fail("Fetch Error (502)", `All sources failed for: ${query}\nReasons: ${reasonStr}`, "boom,x", 4);
|
||||
return await fail("Fetch Error (502)", `Found ${found} of ${i} working images for: ${cacheKey}\nReasons: ${reasonStr}`, "boom,x", 4);
|
||||
}
|
||||
|
||||
const { buffer: imgBuffer, contentType: finalContentType } = imgResult;
|
||||
@@ -179,6 +187,20 @@ export async function onRequest(context) {
|
||||
return new Response(imgBuffer, { headers: imageHeaders(finalContentType, TTL_SECONDS * 1000) });
|
||||
}
|
||||
|
||||
// ?i=1..MAX_INDEX (default 1). Returns null if invalid.
|
||||
function parseIndex(searchParams) {
|
||||
const i = searchParams.get("i") || "1";
|
||||
return /^\d+$/.test(i) && +i >= 1 && +i <= MAX_INDEX ? +i : null;
|
||||
}
|
||||
|
||||
// Yields Brave results, or Bing's if Brave has none
|
||||
async function* webImageUrls(context, query) {
|
||||
const urls = await braveImageSearch(query, context.env.BRAVE_API_KEY);
|
||||
if (urls?.length) return yield* urls;
|
||||
context.waitUntil(notify(context.env, { title: "Brave Search Empty", message: `No results for: ${query}. Trying Bing Fallback.`, tags: "warning,mag", priority: 3 }));
|
||||
yield* (await bingImageSearchFallback(query)) || [];
|
||||
}
|
||||
|
||||
async function setupSurreal(env) {
|
||||
if (!env.SURREAL_URL || !env.SURREAL_USER || !env.SURREAL_PASS) return jsonResponse(500, { error: "SurrealDB env vars missing" });
|
||||
const sql = `DEFINE NAMESPACE IF NOT EXISTS direct_img;
|
||||
@@ -211,14 +233,14 @@ async function notify(env,{ title, message, tags, priority }) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function countHit(env, request, query) {
|
||||
async function countHit(env, request, query, free) {
|
||||
if (!env.GOATCOUNTER_URL || !env.GOATCOUNTER_TOKEN) return;
|
||||
const h = k => request.headers.get(k) || "";
|
||||
try {
|
||||
await fetch(`${env.GOATCOUNTER_URL}/api/v0/count`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": `Bearer ${env.GOATCOUNTER_TOKEN}` },
|
||||
body: JSON.stringify({ no_sessions: true, hits: [{ path: `/${query.replace(/ /g, "+")}`, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }),
|
||||
body: JSON.stringify({ no_sessions: true, hits: [{ path: `${free ? "/free" : ""}/${query.replace(/ /g, "+")}`, title: query, ref: h("referer"), user_agent: h("user-agent"), ip: h("cf-connecting-ip") }] }),
|
||||
signal: AbortSignal.timeout(5000)
|
||||
});
|
||||
} catch {}
|
||||
@@ -237,10 +259,10 @@ async function sha256(str) {
|
||||
return [...new Uint8Array(buf)].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function fetchImage(imageUrl, timeoutMs = 5000) {
|
||||
async function fetchImage(imageUrl, timeoutMs = 5000, ua = "Mozilla/5.0") {
|
||||
try {
|
||||
const res = await fetch(imageUrl, {
|
||||
headers: { "User-Agent": "Mozilla/5.0", "Accept": "image/avif,image/webp,image/*,*/*;q=0.8" },
|
||||
headers: { "User-Agent": ua, "Accept": "image/avif,image/webp,image/*,*/*;q=0.8" },
|
||||
redirect: "follow", signal: AbortSignal.timeout(timeoutMs), cf: { cacheTtl: 0 }
|
||||
});
|
||||
if (!res.ok) return { success: false, reason: `HTTP ${res.status}` };
|
||||
@@ -253,7 +275,8 @@ async function fetchImage(imageUrl, timeoutMs = 5000) {
|
||||
|
||||
const buffer = await res.arrayBuffer();
|
||||
if (buffer.byteLength > 10485760) return { success: false, reason: `Buffer >10MB` };
|
||||
|
||||
if (await isFlickrPlaceholder(res.url || imageUrl, buffer)) return { success: false, reason: "Flickr placeholder" };
|
||||
|
||||
return { success: true, buffer, contentType: ct };
|
||||
} catch (err) {
|
||||
return { success: false, reason: err.name === 'TimeoutError' ? 'Timeout' : err.message };
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Flickr answers deleted photos with HTTP 200 and a "photo no longer available" image.
|
||||
// SHA-256 of that placeholder at every size suffix (same bytes for every deleted photo):
|
||||
const PLACEHOLDERS = new Set([
|
||||
"26b52b156ef2459dc8963a7f565a99f47ddeac36b8005443457fee419637ce85", // none (500x375)
|
||||
"e5028de2367c10d29ea66e45f7889085682316b2c8c52198c89e72c256de70a0", // _b _c _h _k _o (768x576)
|
||||
"4a071ae0f3b051a28b0d56c9987a3d18ee5f3839bfc996cc2369a4636cefb2e2", // _z (640x480)
|
||||
"44378872c6cd32ecb0525e9c10eaade1b8a0c7ccdbc57bdc22f108ad9642606c", // _w (400x300)
|
||||
"37f48b8788c06e2092a281bf852454918d7eac946bcb21012d6fd030609bd8b1", // _n (320x240)
|
||||
"b85fb922ab3515241a1cd666bc274523c1c9784022eeed880e9fb3d4174104ae", // _m (240x180)
|
||||
"632dffa72bf3c993e08e22ba938523b5a148ce5084ca07befda225adff0ef35c", // _q (150x150)
|
||||
"ef12e410eed533981d3a0c1f447c8093c3d33ef05c1c583addc494b8a4369fd1", // _t (100x75)
|
||||
"96c0e77be54767d1b7b9e2cc47df913fc23a9cc76dd4608e441dadaeffe057cb", // _s (75x75)
|
||||
]);
|
||||
|
||||
export async function isFlickrPlaceholder(url, buffer) {
|
||||
if (!new URL(url).hostname.endsWith("staticflickr.com")) return false;
|
||||
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", buffer))].map(b => b.toString(16).padStart(2, "0")).join("");
|
||||
return PLACEHOLDERS.has(hash);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { openverseImageSearch } from "./openverse.js";
|
||||
import { wikimediaImageSearch } from "./wikimedia.js";
|
||||
|
||||
// Wikimedia's User-Agent policy requires contact info; also used when downloading free images
|
||||
export const FREE_UA = "direct-img.link/1.0 (https://direct-img.link; contact@direct-img.link)";
|
||||
|
||||
// Tried in this order when no ?src= is given
|
||||
const SOURCES = { openverse: openverseImageSearch, wikimedia: wikimediaImageSearch };
|
||||
|
||||
// ?src=openverse|wikimedia, or "auto" (every source in order) when omitted. Returns null if invalid.
|
||||
export function parseFreeSource(searchParams) {
|
||||
const src = searchParams.get("src")?.toLowerCase() || "auto";
|
||||
return src === "auto" || Object.hasOwn(SOURCES, src) ? src : null;
|
||||
}
|
||||
|
||||
// Yields each source's image URLs in turn; a source is only searched once the previous one runs out
|
||||
export async function* freeImageUrls(query, src, env) {
|
||||
for (const name of src === "auto" ? Object.keys(SOURCES) : [src]) {
|
||||
yield* (await SOURCES[name](query, env, FREE_UA)) || [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
const API = "https://api.openverse.org/v1";
|
||||
// Uppercase so it can't collide with query cache keys, which are always lowercased
|
||||
const TOKEN_KEY = "OPENVERSE_TOKEN";
|
||||
|
||||
// Tokens expire (~12h); client ID/secret are permanent, so fetch a new token whenever the cached one is gone
|
||||
async function getToken(env) {
|
||||
if (!env.OPENVERSE_CLIENT_ID || !env.OPENVERSE_CLIENT_SECRET) return null;
|
||||
const cached = await env.DIRECT_IMG_CACHE.get(TOKEN_KEY);
|
||||
if (cached) return cached;
|
||||
const res = await fetch(`${API}/auth_tokens/token/`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({ grant_type: "client_credentials", client_id: env.OPENVERSE_CLIENT_ID, client_secret: env.OPENVERSE_CLIENT_SECRET }),
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const { access_token, expires_in } = await res.json();
|
||||
await env.DIRECT_IMG_CACHE.put(TOKEN_KEY, access_token, { expirationTtl: Math.max(60, expires_in - 600) });
|
||||
return access_token;
|
||||
}
|
||||
|
||||
export async function openverseImageSearch(query, env, ua) {
|
||||
const url = `${API}/images/?${new URLSearchParams({ q: query, license: "cc0,pdm", extension: "jpg,png,gif,webp", page_size: 20 })}`;
|
||||
const search = token => fetch(url, { headers: { "User-Agent": ua, ...(token && { Authorization: `Bearer ${token}` }) } });
|
||||
try {
|
||||
const token = await getToken(env).catch(() => null);
|
||||
let res = await search(token);
|
||||
if (res.status === 401 && token) {
|
||||
await env.DIRECT_IMG_CACHE.delete(TOKEN_KEY);
|
||||
res = await search();
|
||||
}
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
return data.results?.map(r => r.url).filter(Boolean) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export async function wikimediaImageSearch(query, env, ua) {
|
||||
const params = new URLSearchParams({
|
||||
action: "query", format: "json", formatversion: 2,
|
||||
generator: "search", gsrsearch: `${query} filetype:bitmap haslicense:unrestricted`, gsrnamespace: 6, gsrlimit: 30,
|
||||
prop: "imageinfo", iiprop: "url", iiurlwidth: 1920,
|
||||
});
|
||||
try {
|
||||
const res = await fetch(`https://commons.wikimedia.org/w/api.php?${params}`, { headers: { "User-Agent": ua } });
|
||||
if (!res.ok) return null;
|
||||
const pages = (await res.json()).query?.pages || [];
|
||||
return pages
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map(p => p.imageinfo?.[0])
|
||||
.filter(Boolean)
|
||||
.map(ii => ii.thumburl || ii.url);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user