Skip to content

Commit dbf2558

Browse files
committed
feat: simulate exact userscript URL matches
1 parent 29ac3c8 commit dbf2558

5 files changed

Lines changed: 143 additions & 18 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ All notable changes to ScriptHunt will be documented in this file.
2323
- Added installed/favorite import previews with valid, invalid, duplicate, conflict, and manager-format counts; merge, replace, skip-conflict, and cancel actions; and versioned pre-import snapshots with one-click rollback.
2424
- Replaced unconstrained custom-source field guessing with declarative schema-v1 manifests covering stable identity, capabilities, privacy route, response/mapping paths, and strict byte/item/time limits; legacy templates migrate to a bounded compatibility mapping.
2525
- Made the executable source registry authoritative for README source rows and diagnostics method/route/capability claims, with local drift tests and complete package release metadata.
26+
- Added exact-URL applicability simulation for userscript scheme, host, port, path, include globs, and exclusion rules while retaining domain-only host hints.
2627

2728
## [v0.5.1]
2829

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ npm run qa
5959
| **Favorites** | Save scripts locally with versioned JSON export/import, conflict preview, recovery snapshots, and undo |
6060
| **Saved Searches** | Save query/filter/source combinations locally and manually refresh them to badge new or updated results |
6161
| **Installed Import/Export** | Preview manager/app backups before merge, replace, or conflict-skip; every write creates a versioned local snapshot with one-click rollback |
62-
| **Advanced Query Syntax** | `site:`, `author:`, `updated:`, `grant:` operators with domain-aware by-site.json search and metadata-backed grant filtering |
62+
| **Advanced Query Syntax** | `site:`, `author:`, `updated:`, `grant:` operators with domain-aware by-site.json search, exact-URL applicability simulation, and metadata-backed grant filtering |
6363
| **Advanced Filters** | Visible controls for source, license, installs, updated date, catalog language, @grant, risk, and applies-to domain |
64-
| **Applies-To Evidence** | Site-filtered results show source site matches alongside parsed `@match`, `@include`, and `@exclude` metadata evidence |
64+
| **Applies-To Evidence** | Domain hints show host-level evidence, while exact URL filters simulate scheme, host, port, path, `@match`, `@include`, `@exclude`, and `@exclude-match` behavior |
6565
| **Filter Results** | Instantly narrow loaded results by name/description/author without re-querying sources |
6666
| **Source Health** | Persists failing source cooldowns with retry controls and versioned per-source provenance for partiality, route, latency, HTTP status, and cache use |
6767
| **Offline Recent Searches** | Stores recent successful searches locally, labels stale cached results, and exposes revalidation when online |

index.html

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -390,7 +390,7 @@
390390
<div class="filter-row">
391391
<div class="site-filter-wrap">
392392
<svg class="site-filter-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
393-
<input type="text" class="site-filter" id="siteFilter" placeholder="Filter by site (youtube.com)" autocomplete="off" spellcheck="false" aria-label="Filter by website domain">
393+
<input type="text" class="site-filter" id="siteFilter" placeholder="Domain or exact URL" autocomplete="off" spellcheck="false" aria-label="Filter by website domain or exact URL">
394394
</div>
395395
<button class="tool-btn" id="btnBookmarklet" title="Bookmarklet tool" popovertarget="bookmarkletSection">Bookmarklet</button>
396396
<button class="tool-btn" id="btnSavedSearches" title="Saved searches" popovertarget="savedSearchSection">Saved</button>
@@ -2682,7 +2682,7 @@ <h3>Search userscripts everywhere</h3>
26822682
var q = raw.trim(), site = '', author = '', updatedDays = 0, grantFilter = '';
26832683
var m;
26842684
m = q.match(/\bsite:(\S+)/i);
2685-
if (m) { site = m[1].replace(/^https?:\/\//, '').replace(/\/.*/, ''); q = q.replace(m[0], '').trim(); }
2685+
if (m) { site = m[1]; q = q.replace(m[0], '').trim(); }
26862686
m = q.match(/\bauthor:(\S+)/i);
26872687
if (m) { author = m[1]; q = q.replace(m[0], '').trim(); }
26882688
m = q.match(/\bupdated:(\d+)d?\b/i);
@@ -2703,6 +2703,19 @@ <h3>Search userscripts everywhere</h3>
27032703
}
27042704
}
27052705

2706+
function normalizeSiteTarget(site) {
2707+
var raw = String(site || '').trim();
2708+
if (!raw) return null;
2709+
if (/^(https?|file|ftp):\/\//i.test(raw)) {
2710+
try {
2711+
var url = new URL(raw);
2712+
return { mode: 'exact-url', raw: raw, url: url, host: url.hostname.replace(/^www\./, '').toLowerCase(), label: url.toString() };
2713+
} catch(e) {}
2714+
}
2715+
var host = normalizeSiteHost(raw);
2716+
return host ? { mode: 'host-hint', raw: raw, url: null, host: host, label: host } : null;
2717+
}
2718+
27062719
function hostMatchesPattern(host, patternHost) {
27072720
host = normalizeSiteHost(host);
27082721
patternHost = (patternHost || '').toLowerCase().replace(/^www\./, '');
@@ -2723,7 +2736,7 @@ <h3>Search userscripts everywhere</h3>
27232736
if (!pattern) return false;
27242737
if (pattern === '<all_urls>') return true;
27252738
var m = String(pattern).match(/^(?:\*|https?|file):\/\/([^/]+)\//i);
2726-
if (m) return hostMatchesPattern(host, m[1]);
2739+
if (m) return hostMatchesPattern(host, m[1].replace(/:\d+$/, ''));
27272740
return false;
27282741
}
27292742

@@ -2734,27 +2747,77 @@ <h3>Search userscripts everywhere</h3>
27342747
return candidates.some(function(c) { return rx.test(c); });
27352748
}
27362749

2750+
function matchDirectiveAgainstUrl(pattern, url) {
2751+
if (pattern === '<all_urls>') {
2752+
return /^(https?|file|ftp):$/.test(url.protocol) ? { matched: true, reason: '' } : { matched: false, reason: 'scheme is outside <all_urls>' };
2753+
}
2754+
var parsed = String(pattern || '').match(/^(\*|https?|file|ftp):\/\/([^/]*)(\/.*)$/i);
2755+
if (!parsed) return { matched: false, reason: 'invalid @match pattern' };
2756+
var scheme = parsed[1].toLowerCase();
2757+
if (scheme === '*') {
2758+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return { matched: false, reason: 'scheme mismatch' };
2759+
} else if (url.protocol !== scheme + ':') return { matched: false, reason: 'scheme mismatch' };
2760+
var hostPattern = parsed[2];
2761+
if (scheme === 'file') {
2762+
if (hostPattern && hostPattern !== '*' && hostPattern !== url.host) return { matched: false, reason: 'file host mismatch' };
2763+
} else {
2764+
var patternPort = '';
2765+
var hostOnly = hostPattern;
2766+
var portMatch = hostPattern.match(/^(.*):(\d+)$/);
2767+
if (portMatch) { hostOnly = portMatch[1]; patternPort = portMatch[2]; }
2768+
if (!hostMatchesPattern(url.hostname, hostOnly)) return { matched: false, reason: 'host mismatch' };
2769+
if (patternPort && url.port !== patternPort) return { matched: false, reason: 'port mismatch' };
2770+
}
2771+
if (!globToRegex(parsed[3]).test(url.pathname + url.search)) return { matched: false, reason: 'path mismatch' };
2772+
return { matched: true, reason: '' };
2773+
}
2774+
2775+
function includeDirectiveAgainstUrl(pattern, url) {
2776+
try {
2777+
return globToRegex(pattern).test(url.toString()) ? { matched: true, reason: '' } : { matched: false, reason: 'URL glob mismatch' };
2778+
} catch(e) {
2779+
return { matched: false, reason: 'invalid URL glob' };
2780+
}
2781+
}
2782+
27372783
function metaArray(meta, key) {
27382784
var val = meta && meta[key];
27392785
if (!val) return [];
27402786
return Array.isArray(val) ? val : [val];
27412787
}
27422788

27432789
function buildAppliesToEvidence(item, site, meta) {
2744-
var host = normalizeSiteHost(site);
2745-
if (!host) return null;
2790+
var target = normalizeSiteTarget(site);
2791+
if (!target) return null;
2792+
var host = target.host;
27462793
var matches = [];
27472794
var excludes = [];
2748-
metaArray(meta, 'match').forEach(function(p) { if (matchDirectiveTargetsHost(p, host)) matches.push('@match ' + p); });
2749-
metaArray(meta, 'include').forEach(function(p) { if (includeDirectiveTargetsHost(p, host)) matches.push('@include ' + p); });
2750-
metaArray(meta, 'exclude').forEach(function(p) { if (includeDirectiveTargetsHost(p, host) || matchDirectiveTargetsHost(p, host)) excludes.push('@exclude ' + p); });
2751-
metaArray(meta, 'exclude-match').forEach(function(p) { if (matchDirectiveTargetsHost(p, host)) excludes.push('@exclude-match ' + p); });
2795+
var failed = [];
2796+
metaArray(meta, 'match').forEach(function(p) {
2797+
var result = target.url ? matchDirectiveAgainstUrl(p, target.url) : { matched: matchDirectiveTargetsHost(p, host), reason: 'host hint mismatch' };
2798+
if (result.matched) matches.push('@match ' + p); else if (target.url) failed.push('@match ' + p + ' — ' + result.reason);
2799+
});
2800+
metaArray(meta, 'include').forEach(function(p) {
2801+
var result = target.url ? includeDirectiveAgainstUrl(p, target.url) : { matched: includeDirectiveTargetsHost(p, host), reason: 'host hint mismatch' };
2802+
if (result.matched) matches.push('@include ' + p); else if (target.url) failed.push('@include ' + p + ' — ' + result.reason);
2803+
});
2804+
metaArray(meta, 'exclude').forEach(function(p) {
2805+
var result = target.url ? includeDirectiveAgainstUrl(p, target.url) : { matched: includeDirectiveTargetsHost(p, host) || matchDirectiveTargetsHost(p, host) };
2806+
if (result.matched) excludes.push('@exclude ' + p);
2807+
});
2808+
metaArray(meta, 'exclude-match').forEach(function(p) {
2809+
var result = target.url ? matchDirectiveAgainstUrl(p, target.url) : { matched: matchDirectiveTargetsHost(p, host) };
2810+
if (result.matched) excludes.push('@exclude-match ' + p);
2811+
});
27522812
return {
27532813
host: host,
2814+
target: target.label,
2815+
mode: target.mode,
27542816
source: item._appliesSource || null,
27552817
checkedMeta: !!meta,
27562818
matchedPatterns: matches,
27572819
excludedPatterns: excludes,
2820+
failedPatterns: failed.slice(0, 8),
27582821
};
27592822
}
27602823

@@ -2774,7 +2837,7 @@ <h3>Search userscripts everywhere</h3>
27742837
function appliesSummary(applies) {
27752838
if (!applies) return 'Not checked';
27762839
if (applies.excludedPatterns && applies.excludedPatterns.length) return 'Excluded by metadata';
2777-
if (applies.matchedPatterns && applies.matchedPatterns.length) return 'Metadata match';
2840+
if (applies.matchedPatterns && applies.matchedPatterns.length) return applies.mode === 'exact-url' ? 'Exact URL match' : 'Host hint match';
27782841
if (applies.source && applies.checkedMeta) return 'Source match; metadata not matched';
27792842
if (applies.source) return 'Source site match';
27802843
if (applies.checkedMeta) return 'No metadata match';
@@ -2783,10 +2846,11 @@ <h3>Search userscripts everywhere</h3>
27832846

27842847
function renderAppliesToEvidence(applies) {
27852848
if (!applies) return '';
2786-
var html = '<div class="applies-matrix"><strong>Applies to ' + esc(applies.host) + ':</strong> ' + esc(appliesSummary(applies));
2849+
var html = '<div class="applies-matrix"><strong>' + (applies.mode === 'exact-url' ? 'Exact URL simulation' : 'Host hint') + ' — ' + esc(applies.target || applies.host) + ':</strong> ' + esc(appliesSummary(applies));
27872850
if (applies.source) html += '<div><span class="applies-pill source">' + esc(applies.source.label) + '</span></div>';
27882851
if (applies.matchedPatterns && applies.matchedPatterns.length) html += '<div>' + applies.matchedPatterns.map(function(p) { return '<span class="applies-pill">' + esc(p) + '</span>'; }).join('') + '</div>';
27892852
if (applies.excludedPatterns && applies.excludedPatterns.length) html += '<div>' + applies.excludedPatterns.map(function(p) { return '<span class="applies-pill exclude">' + esc(p) + '</span>'; }).join('') + '</div>';
2853+
if (applies.failedPatterns && applies.failedPatterns.length) html += '<div>' + applies.failedPatterns.map(function(p) { return '<span class="applies-pill miss">' + esc(p) + '</span>'; }).join('') + '</div>';
27902854
if (applies.checkedMeta && !applies.matchedPatterns.length && !applies.excludedPatterns.length) html += '<div><span class="applies-pill miss">No @match/@include hit</span></div>';
27912855
if (!applies.source && !applies.checkedMeta) html += '<div><span class="applies-pill miss">Metadata unavailable</span></div>';
27922856
return html + '</div>';
@@ -2917,7 +2981,7 @@ <h3>Search userscripts everywhere</h3>
29172981
var srcDef = SOURCES[srcId];
29182982
var page = state.sourcePages[srcId] || 1;
29192983
try {
2920-
var result = await srcDef.search(parsed.query, page, siteVal, filters.language, searchController.signal);
2984+
var result = await srcDef.search(parsed.query, page, normalizeSiteHost(siteVal), filters.language, searchController.signal);
29212985
if (!result || result.schemaVersion !== SOURCE_ENVELOPE_VERSION || !Array.isArray(result.items)) throw new Error('invalid source result envelope');
29222986
var items = result.items;
29232987
var total = result.total;
@@ -3369,7 +3433,7 @@ <h3>Search userscripts everywhere</h3>
33693433
h += '<div class="compare-row"><span class="compare-label">License</span><span class="compare-value">' + esc(item.license || 'N/A') + '</span></div>';
33703434
h += '<div class="compare-row"><span class="compare-label">Staleness</span><span class="compare-value">' + (stale ? stale.label : 'N/A') + '</span></div>';
33713435
h += '<div class="compare-row"><span class="compare-label">Updated</span><span class="compare-value">' + (item.updatedAt ? timeAgo(item.updatedAt) : 'N/A') + '</span></div>';
3372-
if (state.siteFilter) h += '<div class="compare-row"><span class="compare-label">Applies to ' + esc(normalizeSiteHost(state.siteFilter)) + '</span><span class="compare-value">' + esc(appliesSummary(item._appliesTo)) + '</span></div>';
3436+
if (state.siteFilter) h += '<div class="compare-row"><span class="compare-label">Applies to ' + esc((item._appliesTo && item._appliesTo.target) || normalizeSiteHost(state.siteFilter)) + '</span><span class="compare-value">' + esc(appliesSummary(item._appliesTo)) + '</span></div>';
33733437
if (item._scan) {
33743438
h += '<div class="compare-row"><span class="compare-label">Scan score</span><span class="compare-value">' + (item._scan.unavailable ? 'Unknown' : item._scan.score + '/100') + '</span></div>';
33753439
h += '<div class="compare-row"><span class="compare-label">Code Size</span><span class="compare-value">' + fmtNum(item._scan.codeSize || 0) + 'B</span></div>';

tests/adapter.spec.js

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,3 +672,50 @@ test('custom source manifests constrain mapping, limits, and legacy migration',
672672
expect(outcome.legacy.response.itemsPath).toBe('$legacy');
673673
expect(outcome.unsafeError).toContain('safe declarative data paths');
674674
});
675+
676+
test('exact URL applicability evaluates scheme, host, port, path, globs, and exclusions', async ({ page }) => {
677+
await page.goto('/');
678+
679+
const evidence = await page.evaluate(() => {
680+
const item = { _appliesSource: { label: 'Fixture source match', domain: 'example.com' } };
681+
const meta = {
682+
match: [
683+
'https://*.example.com:8443/app/*',
684+
'http://*.example.com:8443/app/*',
685+
'https://*.example.com:9443/app/*',
686+
'https://*.example.com:8443/admin/*',
687+
],
688+
include: ['https://sub.example.com:8443/app/*'],
689+
'exclude-match': ['https://*.example.com:8443/app/private*'],
690+
exclude: ['*logout*'],
691+
};
692+
return {
693+
exact: buildAppliesToEvidence(item, 'https://sub.example.com:8443/app/page?x=1', meta),
694+
excluded: buildAppliesToEvidence(item, 'https://sub.example.com:8443/app/private/settings', meta),
695+
host: buildAppliesToEvidence(item, 'sub.example.com', meta),
696+
file: buildAppliesToEvidence({}, 'file:///C:/scripts/test.user.js', {
697+
match: ['file:///*', '<all_urls>'],
698+
}),
699+
};
700+
});
701+
702+
expect(evidence.exact).toMatchObject({
703+
mode: 'exact-url',
704+
host: 'sub.example.com',
705+
target: 'https://sub.example.com:8443/app/page?x=1',
706+
});
707+
expect(evidence.exact.matchedPatterns).toEqual([
708+
'@match https://*.example.com:8443/app/*',
709+
'@include https://sub.example.com:8443/app/*',
710+
]);
711+
expect(evidence.exact.failedPatterns).toEqual(expect.arrayContaining([
712+
expect.stringContaining('scheme mismatch'),
713+
expect.stringContaining('port mismatch'),
714+
expect.stringContaining('path mismatch'),
715+
]));
716+
expect(evidence.exact.excludedPatterns).toEqual([]);
717+
expect(evidence.excluded.excludedPatterns).toContain('@exclude-match https://*.example.com:8443/app/private*');
718+
expect(evidence.host.mode).toBe('host-hint');
719+
expect(evidence.host.matchedPatterns).toContain('@match https://*.example.com:8443/app/*');
720+
expect(evidence.file.matchedPatterns).toEqual(['@match file:///*', '@match <all_urls>']);
721+
});

tests/smoke.spec.js

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -746,7 +746,7 @@ test('metadata viewer caps oversized repeated directives without losing parsed f
746746
await runSearch(page, 'oversized');
747747

748748
const card = page.locator('.result-card').filter({ hasText: 'YouTube Enhancer' });
749-
await expect(card.locator('.applies-matrix')).toContainText('Metadata match');
749+
await expect(card.locator('.applies-matrix')).toContainText('Host hint match');
750750
const metadataButton = card.locator('[data-action="meta"]');
751751
await expect(metadataButton).toBeVisible();
752752
await metadataButton.click();
@@ -884,7 +884,7 @@ test('site filter shows source and metadata applies-to evidence', async ({ page
884884
await runSearch(page, 'youtube');
885885

886886
const youtube = page.locator('.result-card').filter({ hasText: 'YouTube Enhancer' });
887-
await expect(youtube.locator('.applies-matrix')).toContainText('Metadata match');
887+
await expect(youtube.locator('.applies-matrix')).toContainText('Host hint match');
888888
await expect(youtube.locator('.applies-matrix')).toContainText('Greasy Fork site match');
889889
await expect(youtube.locator('.applies-matrix')).toContainText('@match https://*.youtube.com/*');
890890

@@ -893,6 +893,19 @@ test('site filter shows source and metadata applies-to evidence', async ({ page
893893
await expect(darkMode.locator('.applies-matrix')).toContainText('No @match/@include hit');
894894
});
895895

896+
test('exact URL filter renders exclusion simulation and rule failures', async ({ page }) => {
897+
await page.goto('/');
898+
await page.fill('#siteFilter', 'https://ads.youtube.com/watch?v=1');
899+
await runSearch(page, 'youtube');
900+
901+
const card = page.locator('.result-card').filter({ hasText: 'YouTube Enhancer' });
902+
const evidence = card.locator('.applies-matrix');
903+
await expect(evidence).toContainText('Exact URL simulation');
904+
await expect(evidence).toContainText('Excluded by metadata');
905+
await expect(evidence).toContainText('@exclude-match https://ads.youtube.com/*');
906+
await expect(evidence).toContainText('URL glob mismatch');
907+
});
908+
896909
test('comparison modal includes applies-to summary for site-filtered results', async ({ page }) => {
897910
await page.goto('/');
898911
await page.fill('#siteFilter', 'youtube.com');
@@ -905,7 +918,7 @@ test('comparison modal includes applies-to summary for site-filtered results', a
905918

906919
await expect(page.locator('.modal-overlay')).toHaveClass(/visible/);
907920
await expect(page.locator('.compare-label', { hasText: 'Applies to youtube.com' })).toHaveCount(2);
908-
await expect(page.locator('.compare-value', { hasText: 'Metadata match' })).toBeVisible();
921+
await expect(page.locator('.compare-value', { hasText: 'Host hint match' })).toBeVisible();
909922
});
910923

911924
test('grant query filters by metadata and labels unknown metadata', async ({ page }) => {

0 commit comments

Comments
 (0)