Skip to content

Commit 2b1025e

Browse files
authored
fix(chrome-extension): resolve Hugging Face file URLs (#1947)
* fix(chrome-extension): resolve Hugging Face file URLs The Hub links one file from seven routes (blob, raw, blame, edit, delete, commits and the download button) and every one ends in the file's own extension, so a repository page offered near-duplicate hits named after the surrounding UI rather than the one direct URL a map source can read. * Address review feedback - Read the Hugging Face route from its structural position (index 2, or 3 under /datasets and /spaces) instead of scanning for the first matching segment, so an owner or repository named after a route cannot stand in for one; namespaceless legacy repos still resolve at index 2. - Cover the collision cases in tests: a repository named `blob`, an owner named `raw`, and a legacy `/datasets/<name>/blob/...` path. - Hoist the repeated Hugging Face host test in addDataset into one local. * Address review feedback - Pick the Hugging Face route from the positions the path grammar allows ([3, 2] under /datasets and /spaces, otherwise [2, 1]), preferring the deeper one and requiring room for a revision and a path. This adds namespaceless legacy repos (huggingface.co/gpt2/blob/main/...) and stops a revision named after a route from rejecting an otherwise valid file. - Drop the fragment when canonicalizing, so a blob line anchor does not split one file into two entries.
1 parent 5f28f0c commit 2b1025e

2 files changed

Lines changed: 149 additions & 1 deletion

File tree

extensions/geolibre-chrome/scanner.mjs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,38 @@ export function scanDocumentForDatasets() {
3030
}
3131
};
3232

33+
const huggingFaceHost = (url) => /^(?:huggingface\.co|hf\.co)$/i.test(url.hostname);
34+
35+
// The Hub links one file from seven routes -- blob, raw, blame, edit, delete,
36+
// commits and the ?download=true button -- and every one of them ends in the
37+
// file's own extension, so a repository page yields near-duplicate hits where
38+
// only `resolve` (which 302s to the CDN) serves bytes a map source can read.
39+
const huggingFaceFileUrl = (url) => {
40+
if (!huggingFaceHost(url)) return null;
41+
const parts = url.pathname.split("/").filter(Boolean);
42+
const isRoute = (part) => /^(?:blob|raw|blame|edit|delete|commits|resolve)$/.test(part ?? "");
43+
// /<owner>/<repo>/<route>/<revision>/<path> for models, one segment deeper
44+
// under /datasets and /spaces, and one shallower for the namespaceless
45+
// legacy repos both shapes still carry. Read the route from the positions
46+
// the grammar allows rather than scanning for the first keyword, so an
47+
// owner, repository or revision named after a route cannot stand in for
48+
// one, and prefer the deeper position since namespaced repos are the norm.
49+
const route = (/^(?:datasets|spaces)$/.test(parts[0]) ? [3, 2] : [2, 1]).find(
50+
(index) => isRoute(parts[index]) && parts.length >= index + 3,
51+
);
52+
if (route === undefined) return null;
53+
parts[route] = "resolve";
54+
const canonical = new URL(url.href);
55+
canonical.pathname = `/${parts.join("/")}`;
56+
canonical.searchParams.delete("download");
57+
// A line anchor off a blob page would otherwise split one file into two
58+
// entries that the CDN serves identically.
59+
canonical.hash = "";
60+
return canonical;
61+
};
62+
3363
const canonicalUrl = (url) => {
64+
if (huggingFaceHost(url)) return huggingFaceFileUrl(url) ?? url;
3465
if (url.hostname !== "source.coop") return url;
3566
const parts = url.pathname.split("/").filter(Boolean);
3667
if (parts.length < 3) return url;
@@ -93,10 +124,18 @@ export function scanDocumentForDatasets() {
93124
return;
94125
}
95126

127+
// Every Hub route other than a file route is a UI page -- tree, viewer, the
128+
// "Auto-converted to Parquet" branch -- so a hint-based match there would
129+
// offer HTML as data.
130+
const onHub = huggingFaceHost(url);
131+
if (onHub && !huggingFaceFileUrl(url)) return;
132+
96133
const kind = classify(url, hint);
97134
if (!kind) return;
98135
const existing = datasets.get(url.href);
99-
const name = label.trim() || cleanName(url);
136+
// Hub links carry UI chrome as their text ("Download", "History", "308 kB
137+
// xet"), so the file name has to come from the path.
138+
const name = (onHub ? "" : label.trim()) || cleanName(url);
100139
const candidate = {
101140
url: url.href,
102141
name,

tests/chrome-extension.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,115 @@ describe("GeoLibre Chrome extension scanner", () => {
9696
assert.equal(found.styleUrl, "https://data.source.coop/giswqs/opengeos/roads.style.json");
9797
});
9898

99+
it("collapses every Hugging Face file route onto the direct resolve URL", () => {
100+
const repo = "https://huggingface.co/datasets/giswqs/PACE-Water-Quality";
101+
const file = "main/cogs/PACE_OCI-20260103-chla.tif";
102+
const found = scan(
103+
`
104+
<a href="${repo}/blob/${file}">PACE_OCI-20260103-chla.tif</a>
105+
<a href="${repo}/raw/${file}">Raw pointer file</a>
106+
<a href="${repo}/blame/${file}">Blame</a>
107+
<a href="${repo}/edit/${file}">Contribute</a>
108+
<a href="${repo}/delete/${file}">Delete</a>
109+
<a href="${repo}/commits/${file}">History</a>
110+
<a href="${repo}/resolve/${file}?download=true">Download</a>
111+
`,
112+
`${repo}/blob/${file}`,
113+
);
114+
assert.deepEqual(found, [
115+
{
116+
url: `${repo}/resolve/${file}`,
117+
name: "PACE_OCI-20260103-chla.tif",
118+
format: "GeoTIFF",
119+
kind: "raster",
120+
styleUrl: null,
121+
},
122+
]);
123+
});
124+
125+
it("canonicalizes Hugging Face model and Space files but leaves other Hub links alone", () => {
126+
const found = scan(
127+
`
128+
<a href="https://hf.co/giswqs/model/blob/main/grid.geojson">grid</a>
129+
<a href="https://huggingface.co/spaces/giswqs/demo/blob/main/roads.pmtiles">roads</a>
130+
<a href="https://huggingface.co/datasets/giswqs/PACE-Water-Quality/tree/main/cogs">cogs</a>
131+
<a href="https://huggingface.co/datasets/giswqs/PACE-Water-Quality/tree/refs%2Fconvert%2Fparquet/default">Auto-converted to Parquet</a>
132+
`,
133+
"https://huggingface.co/giswqs",
134+
);
135+
assert.deepEqual(
136+
found.map((dataset) => dataset.url),
137+
[
138+
"https://hf.co/giswqs/model/resolve/main/grid.geojson",
139+
"https://huggingface.co/spaces/giswqs/demo/resolve/main/roads.pmtiles",
140+
],
141+
);
142+
});
143+
144+
it("reads the Hugging Face route from its position, not the first matching segment", () => {
145+
const found = scan(
146+
`
147+
<a href="https://huggingface.co/datasets/giswqs/blob/resolve/main/roads.geojson">repo named blob</a>
148+
<a href="https://huggingface.co/raw/model/blob/main/dem.tif">owner named raw</a>
149+
<a href="https://huggingface.co/datasets/glue/blob/main/grid.pmtiles">legacy repo</a>
150+
`,
151+
"https://huggingface.co/giswqs",
152+
);
153+
assert.deepEqual(
154+
found.map((dataset) => dataset.url),
155+
[
156+
"https://huggingface.co/raw/model/resolve/main/dem.tif",
157+
"https://huggingface.co/datasets/glue/resolve/main/grid.pmtiles",
158+
"https://huggingface.co/datasets/giswqs/blob/resolve/main/roads.geojson",
159+
],
160+
);
161+
});
162+
163+
it("falls back to a shallower Hugging Face route when the deeper one cannot parse", () => {
164+
const found = scan(
165+
`
166+
<a href="https://huggingface.co/gpt2/blob/main/grid.geojson">namespaceless model</a>
167+
<a href="https://huggingface.co/datasets/glue/blob/resolve/legacy.tif">revision named resolve</a>
168+
`,
169+
"https://huggingface.co/gpt2",
170+
);
171+
assert.deepEqual(
172+
found.map((dataset) => dataset.url),
173+
[
174+
"https://huggingface.co/gpt2/resolve/main/grid.geojson",
175+
"https://huggingface.co/datasets/glue/resolve/resolve/legacy.tif",
176+
],
177+
);
178+
});
179+
180+
it("drops a Hugging Face line anchor so it does not split one file in two", () => {
181+
const repo = "https://huggingface.co/datasets/giswqs/opengeos";
182+
const found = scan(
183+
`
184+
<a href="${repo}/blob/main/roads.geojson#L10">roads</a>
185+
<a href="${repo}/resolve/main/roads.geojson?download=true">Download</a>
186+
`,
187+
`${repo}/tree/main`,
188+
);
189+
assert.deepEqual(
190+
found.map((dataset) => dataset.url),
191+
[`${repo}/resolve/main/roads.geojson`],
192+
);
193+
});
194+
195+
it("pairs a Hugging Face style file with its dataset across routes", () => {
196+
const repo = "https://huggingface.co/datasets/giswqs/opengeos";
197+
const [found] = scan(
198+
`
199+
<a href="${repo}/resolve/main/roads.geojson?download=true">Download</a>
200+
<a href="${repo}/blob/main/roads.style.json">roads.style.json</a>
201+
`,
202+
`${repo}/tree/main`,
203+
);
204+
assert.equal(found.url, `${repo}/resolve/main/roads.geojson`);
205+
assert.equal(found.styleUrl, `${repo}/resolve/main/roads.style.json`);
206+
});
207+
99208
it("preserves a discovered style when stronger metadata replaces a dataset", () => {
100209
const target = new URL("https://web.geolibre.app/");
101210
target.searchParams.append("data", "https://data.example.com/roads.json");

0 commit comments

Comments
 (0)