@@ -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 */
203203function 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