diff --git a/apps/web/src/lib/url-parser.ts b/apps/web/src/lib/url-parser.ts
new file mode 100644
index 00000000..1a491510
--- /dev/null
+++ b/apps/web/src/lib/url-parser.ts
@@ -0,0 +1,43 @@
+function isValidHttpUrl(str: string): boolean {
+ try {
+ const url = new URL(str);
+ return url.protocol === "http:" || url.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+export function extractUrls(input: string): string[] {
+ const urls: string[] = [];
+
+ for (const rawLine of input.split("\n")) {
+ let line = rawLine.trim();
+ if (!line) continue;
+
+ // Strip numbered list prefixes: "1. ", "2) ", "3 "
+ line = line.replace(/^\d+[.)]?\s+/, "");
+ // Strip bullet prefixes: "- ", "* ", "+ "
+ line = line.replace(/^[-*+]\s+/, "");
+
+ // Extract from markdown links: [text](url)
+ const mdMatch = line.match(/\[.*?]\((https?:\/\/[^)]+)\)/);
+ if (mdMatch) {
+ urls.push(mdMatch[1]);
+ continue;
+ }
+
+ // Extract from HTML img tags:
+ const imgMatch = line.match(/
]+src=["'](https?:\/\/[^"']+)["']/i);
+ if (imgMatch) {
+ urls.push(imgMatch[1]);
+ continue;
+ }
+
+ line = line.trim();
+ if (isValidHttpUrl(line)) {
+ urls.push(line);
+ }
+ }
+
+ return [...new Set(urls)];
+}
diff --git a/tests/unit/web/url-parser.test.ts b/tests/unit/web/url-parser.test.ts
new file mode 100644
index 00000000..215c3861
--- /dev/null
+++ b/tests/unit/web/url-parser.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { extractUrls } from "../../../apps/web/src/lib/url-parser.js";
+
+describe("extractUrls", () => {
+ it("extracts plain URLs one per line", () => {
+ const input = "https://example.com/a.jpg\nhttps://example.com/b.png";
+ expect(extractUrls(input)).toEqual(["https://example.com/a.jpg", "https://example.com/b.png"]);
+ });
+
+ it("strips numbered list prefixes", () => {
+ const input =
+ "1. https://example.com/a.jpg\n2) https://example.com/b.png\n3 https://example.com/c.webp";
+ expect(extractUrls(input)).toEqual([
+ "https://example.com/a.jpg",
+ "https://example.com/b.png",
+ "https://example.com/c.webp",
+ ]);
+ });
+
+ it("strips bullet prefixes", () => {
+ const input =
+ "- https://example.com/a.jpg\n* https://example.com/b.png\n+ https://example.com/c.webp";
+ expect(extractUrls(input)).toEqual([
+ "https://example.com/a.jpg",
+ "https://example.com/b.png",
+ "https://example.com/c.webp",
+ ]);
+ });
+
+ it("extracts URLs from markdown links", () => {
+ const input = "[Photo 1](https://example.com/a.jpg)\n[Photo 2](https://example.com/b.png)";
+ expect(extractUrls(input)).toEqual(["https://example.com/a.jpg", "https://example.com/b.png"]);
+ });
+
+ it("extracts URLs from HTML img tags", () => {
+ const input = '
\n
';
+ expect(extractUrls(input)).toEqual(["https://example.com/a.jpg", "https://example.com/b.png"]);
+ });
+
+ it("handles mixed formats", () => {
+ const input = `1. https://example.com/a.jpg
+- [Photo](https://example.com/b.png)
+
+https://example.com/d.avif`;
+ expect(extractUrls(input)).toEqual([
+ "https://example.com/a.jpg",
+ "https://example.com/b.png",
+ "https://example.com/c.webp",
+ "https://example.com/d.avif",
+ ]);
+ });
+
+ it("deduplicates URLs", () => {
+ const input = "https://example.com/a.jpg\nhttps://example.com/a.jpg";
+ expect(extractUrls(input)).toEqual(["https://example.com/a.jpg"]);
+ });
+
+ it("filters out non-HTTP URLs", () => {
+ const input = "ftp://example.com/a.jpg\nhttps://example.com/b.png\nnot-a-url";
+ expect(extractUrls(input)).toEqual(["https://example.com/b.png"]);
+ });
+
+ it("returns empty array for empty input", () => {
+ expect(extractUrls("")).toEqual([]);
+ expect(extractUrls(" \n \n ")).toEqual([]);
+ });
+
+ it("preserves URLs with query parameters", () => {
+ const input = "https://example.com/photo?id=123&size=large";
+ expect(extractUrls(input)).toEqual(["https://example.com/photo?id=123&size=large"]);
+ });
+});