Skip to content

Commit 1fa1e95

Browse files
authored
Merge pull request #124 from CyberStrikeus/feat/spa-nav-discovery
feat(hackbrowser): declarative nav harvest (Yön A of #120)
2 parents 01e1f7f + ebbcd20 commit 1fa1e95

1 file changed

Lines changed: 47 additions & 22 deletions

File tree

packages/hackbrowser/src/agent.ts

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ function waitForBrowserClose(browser: import("playwright").Browser, signal?: Abo
198198
/**
199199
* Mark that login was detected. The actual re-queue happens in the BFS loop
200200
* AFTER the current page finishes exploration (so new discoveries from
201-
* collectDOMLinks are enqueued first, before re-visit URLs).
201+
* collectNavLinks are enqueued first, before re-visit URLs).
202202
*/
203203
function triggerReDiscovery(globalState: ReturnType<typeof createGlobalState>): void {
204204
if (globalState.authPhase === "authenticated") return
@@ -1084,12 +1084,12 @@ async function explorePageWithAI(
10841084

10851085
// 6. Collect same-host links from DOM (BFS supplement)
10861086
try {
1087-
const domLinks = await collectDOMLinks(page, pageUrl, inScope)
1087+
const domLinks = await collectNavLinks(page, pageUrl, inScope)
10881088
for (const url of domLinks) {
10891089
if (!linksToEnqueue.includes(url)) linksToEnqueue.push(url)
10901090
}
10911091
} catch {
1092-
log.debug("collectDOMLinks failed (page may have navigated)")
1092+
log.debug("collectNavLinks failed (page may have navigated)")
10931093
}
10941094

10951095
return linksToEnqueue
@@ -1743,27 +1743,52 @@ function resolveUrl(href: string, baseUrl: string, inScope: ScopeMatcher): strin
17431743
return null
17441744
}
17451745

1746-
/** Collect <a href> links from DOM as BFS supplement. */
1747-
async function collectDOMLinks(page: Page, pageUrl: string, inScope: ScopeMatcher): Promise<string[]> {
1748-
const hrefs: string[] = await page.$$eval("a[href]", (els) =>
1749-
els.map((el) => (el as HTMLAnchorElement).href).filter(Boolean),
1746+
// Attributes that DECLARATIVELY encode a navigation destination readable
1747+
// without clicking (a real URL, path, or hash-route). Order = priority per
1748+
// element. `routerLink`/`[to]`/`data-route` are intentionally excluded — their
1749+
// values are router-relative and need scheme-guessing; those routes are
1750+
// discovered imperatively by clicking (phase B, #120).
1751+
const DECLARATIVE_NAV_ATTRS = ["href", "data-href", "data-url"] as const
1752+
const NAV_TARGET_SELECTOR = "a[href], [role=link], [data-href], [data-url]"
1753+
1754+
/** Collect declarative navigation links from DOM as a BFS supplement. */
1755+
async function collectNavLinks(page: Page, pageUrl: string, inScope: ScopeMatcher): Promise<string[]> {
1756+
// Beyond <a href>: many SPAs navigate via non-anchor elements that still
1757+
// declare their destination in an attribute (data-href/data-url) or carry
1758+
// role=link. Harvest the first destination-bearing attribute per element; the
1759+
// raw value is resolved against the page URL below (so "/x" and "#/x" work).
1760+
const hrefs: string[] = await page.$$eval(
1761+
NAV_TARGET_SELECTOR,
1762+
(els, attrs) => {
1763+
const out: string[] = []
1764+
for (const el of els) {
1765+
for (const attr of attrs) {
1766+
const value = el.getAttribute(attr)
1767+
if (value) {
1768+
out.push(value)
1769+
break
1770+
}
1771+
}
1772+
}
1773+
return out
1774+
},
1775+
[...DECLARATIVE_NAV_ATTRS],
17501776
)
17511777

1752-
const results: string[] = []
1778+
// Route every harvested href through resolveUrl (resolve-against-page +
1779+
// in-scope + normalizeUrl) rather than an ad-hoc inline normalization. This
1780+
// aligns DOM-link dedup with the rest of the crawl — e.g. bare "#section"
1781+
// scroll anchors collapse to the base page instead of spawning phantom
1782+
// targets, while "#/route" hash-router URLs are preserved.
17531783
const seen = new Set<string>()
1754-
1784+
const results: string[] = []
17551785
for (const href of hrefs) {
1756-
try {
1757-
const u = new URL(href)
1758-
if (!inScope(u.hostname)) continue
1759-
const normalized = u.origin + u.pathname + u.search + u.hash
1760-
if (!seen.has(normalized)) {
1761-
seen.add(normalized)
1762-
results.push(normalized)
1763-
}
1764-
} catch {}
1786+
const resolved = resolveUrl(href, pageUrl, inScope)
1787+
if (resolved && !seen.has(resolved)) {
1788+
seen.add(resolved)
1789+
results.push(resolved)
1790+
}
17651791
}
1766-
17671792
return results
17681793
}
17691794

@@ -2145,7 +2170,7 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo
21452170
continue
21462171
}
21472172

2148-
// Mark URL as visited BEFORE exploration — prevents re-enqueue during explore/collectDOMLinks
2173+
// Mark URL as visited BEFORE exploration — prevents re-enqueue during explore/collectNavLinks
21492174
const normalizedEntryUrl = normalizeUrl(entry.url)
21502175
visitedPages.add(normalizedEntryUrl)
21512176
globalState.visitedPages.add(normalizedEntryUrl) // sync for explorePageWithAI's filterVisitedLinks
@@ -2253,7 +2278,7 @@ async function runMultiCredential(config: AgentConfig, credentials: CredentialCo
22532278

22542279
// Collect DOM links from each context — enqueue with context tag
22552280
for (const ctx of visitableContexts) {
2256-
const domLinks = await collectDOMLinks(ctx.page, entry.url, inScope)
2281+
const domLinks = await collectNavLinks(ctx.page, entry.url, inScope)
22572282
for (const url of domLinks) {
22582283
enqueueWithContext(url, ctx.id, pageQueue, visitedPages, inScope, pathPatternCounts)
22592284
}
@@ -2632,7 +2657,7 @@ export async function run(config: AgentConfig): Promise<CrawlResult> {
26322657
if (newFingerprint === oldFingerprint) {
26332658
log.info("page unchanged after auth, skipping exploration", { url: currentUrl })
26342659
// Still collect DOM links — navbar may have new links after login
2635-
const domLinks = await collectDOMLinks(page, currentUrl, inScope)
2660+
const domLinks = await collectNavLinks(page, currentUrl, inScope)
26362661
for (const url of domLinks) {
26372662
enqueueUrl(url, globalState, inScope)
26382663
}

0 commit comments

Comments
 (0)