๐ Asset Search & Link-Finder
LibreFolio resolves an interactive asset search through a three-layer stack. The goal is to turn whatever the user types โ a name, a ticker, an ISIN, or a whole line copied from a broker report โ into a concrete, priceable asset candidate, while keeping automated price fetches completely free of any web search.
This page documents the generic machinery (the orchestration in AssetSearchService,
the web_link_finder module, the resolve_url capability, the hints mechanism, and the
identifier post-filter). Provider-specific behaviour lives on each provider page โ see
Borsa Italiana, which is currently the only provider that opts
into layers 2 and 3.
๐ Where it lives
The system is not one function โ it is a small standalone module (the engine) plus service-layer orchestration plus a thin per-provider hook:
| Piece | File | Role |
|---|---|---|
web_link_finder module |
backend/app/services/web_link_finder.py |
Self-contained subsystem: the external-engine client (ddgs metasearch by default), config, TTL cache, domain filter. Knows nothing about providers or assets. |
| Search orchestration | backend/app/services/asset_sources/search.py โ AssetSearchService |
Ties it together: _augment_with_link_finder, _build_link_finder_queries, _filter_items_by_known_identifiers, _provider_url_for_item. Decides when to fall back and how to narrow. |
| Provider hook | each provider's resolve_url / resolvable_url_domains |
Turns a resolved URL back into search-item(s). Only Borsa Italiana implements it today. |
| Endpoints | backend/app/api/v1/assets.py |
/assets/provider/search[/stream] accept the optional hints query param. |
So the answer to "is it its own subsystem or just a service function?" is both: a dedicated module for the engine, orchestrated by the service layer, with a thin per-provider hook.
graph LR
EP["API /assets/provider/search[/stream]"] --> SVC
subgraph SVC["asset_sources/search.py โ AssetSearchService (orchestration)"]
A["_augment_with_link_finder"]
B["_build_link_finder_queries"]
C["_filter_items_by_known_identifiers"]
end
SVC -->|"query + domains"| WLF["web_link_finder.py<br/>(standalone engine module)"]
WLF -->|"candidate URLs"| SVC
SVC -->|"resolve_url(url)"| PROV["provider.resolve_url<br/>(e.g. Borsa Italiana)"]
PROV -->|"search-item(s)"| SVC
style WLF fill:#fff3e0,stroke:#e65100
style PROV fill:#e8f5e9,stroke:#2e7d32
๐งฑ The three layers
graph TD
Q["User query<br/>(name / ticker / ISIN / report line)"] --> L1
subgraph L1["Layer 1 โ On-site search"]
S1["provider.search(query)"]
end
L1 -->|"results"| OUT["Aggregated results"]
L1 -->|"0 results"| L2
subgraph L2["Layer 2 โ web_link_finder (last resort)"]
F1["find_candidate_urls(query, allowed_domains)"]
F2["candidate provider-domain URLs"]
F1 --> F2
end
L2 --> L3
subgraph L3["Layer 3 โ resolve_url"]
R1["provider.resolve_url(url)"]
R2["search-item dict or list<br/>{identifier, type,<br/>currency, provider_params}"]
R1 --> R2
end
L3 -->|"non-None items"| OUT
style S1 fill:#e3f2fd,stroke:#1565c0
style F1 fill:#fff3e0,stroke:#e65100
style R1 fill:#e8f5e9,stroke:#2e7d32
style R2 fill:#e8f5e9,stroke:#2e7d32
| Layer | Where | When it runs | Purpose |
|---|---|---|---|
| L1 โ on-site search | provider.search(query) |
Always | The provider's own search (e.g. borsaitaliana.it cerca, Yahoo, justETF). |
| L2 โ web link-finder | web_link_finder.find_candidate_urls() |
Only when L1 returns 0 items and the provider opts in and the finder is enabled | Uses an external metasearch engine (ddgs by default โ bing / brave / google / DuckDuckGo / โฆ aggregated) to find candidate provider-domain URLs for the query. |
| L3 โ URL resolver | provider.resolve_url(url) |
For each L2 candidate URL | Opens the page, extracts data, and returns one search-item dict, a list of search-item dicts, or None โ the inverse of get_asset_url. |
Invariant โ external search is interactive-only
Layers 2 and 3 are hit only during interactive asset search. Price fetches (frequent,
automated) must never touch web_link_finder. Once an asset exists it is priced by its
stored identifier and provider_params (for Borsa Italiana funds, provider_params.codice_fondo),
never by re-searching. This keeps scheduled syncs deterministic and independent from external
metasearch availability.
๐ผ Orchestration (AssetSearchService)
Both search() (batch) and search_stream() (SSE) query every eligible provider in parallel.
Each provider.search() call goes through asset_sources.core._run_provider_in_thread(). After a
provider yields its L1 results, the shared augment helper decides whether to fall back to the
external stack:
# backend/app/services/asset_sources/search.py
async def search(query: str, provider_codes=None, hints: Optional[list[str]] = None) -> FAProviderSearchResponse
async def search_stream(query: str, provider_codes=None, hints: Optional[list[str]] = None) -> AsyncGenerator[str]
_augment_with_link_finder(code, provider, query, hints=None)โ invoked when a provider returns 0 items andprovider.supports_url_resolutionandweb_link_finder.is_enabled(). It builds the candidate queries (see Hints), callsfind_candidate_urls, then resolves each hit throughasset_sources.core._run_provider_in_thread()(resolve_urlmay do sync I/O). The helper flattens one-item/list results, de-duplicates by(identifier, language), and returns the non-Noneitems. Any failure is caught and yields[]โ the search always completes and emitsdone._provider_url_for_item(code, item)โ DRY helper used during serialization so theprovider_urldisplay link is (re)computed from the item'sprovider_paramson every request.- Results are flagged as "found via web" for the UI.
Why the fallback fixes the old \"searchingโฆ hangs\" bug
Because the augment path is fully time-boxed and best-effort, an SSE search always reaches a
terminal done event even when the external engine is slow or blocked. A provider returning 0
results no longer leaves the stream open with no No-Match state.
๐ web_link_finder module
backend/app/services/web_link_finder.py is a LibreFolio-level module, deliberately kept
out of any scraping library โ the choice of external engine is a LibreFolio concern, not a
provider's.
async def find_candidate_urls(query: str, allowed_domains: list[str], *, max_results: int = 5, path_hint: str | None = None) -> list[str]
def is_enabled() -> bool
- Best-effort: any failure (rate-limit, network error, missing optional
dependency) yields
[]and is never fatal. - Async-safe: the engine's synchronous
search()runs insideasyncio.to_thread; short timeout, small TTL cache, domain-filtered hits, structured logs. - Pluggable engine: the default
DdgsEnginewraps theddgsmetasearch library (the maintained successor ofduckduckgo_search), which aggregates ~10 upstream engines (bing / brave / google / duckduckgo / startpage / โฆ) behind onetext(query, backend="auto")call. AnApiKeyEngineseam is in place for a paid API (Brave / Bing / SerpAPI) later; thesearxngengine name is reserved for the deferred SearXNG adapter (see Transport history below). - Defensive import:
ddgsis imported behind a_DDGS_OKguard, so a missing dependency simply disables the module instead of breaking search.
โ๏ธ Configuration (env vars, all optional)
| Variable | Default | Meaning |
|---|---|---|
LIBREFOLIO_WEB_LINK_FINDER_ENABLED |
1 |
1/0 master switch. |
LIBREFOLIO_WEB_LINK_FINDER_ENGINE |
ddgs |
ddgs | apikey (searxng reserved for Fase B). |
LIBREFOLIO_WEB_LINK_FINDER_API_KEY |
"" |
Key for the apikey engine (engine self-disables if absent). |
LIBREFOLIO_WEB_LINK_FINDER_DDGS_REGION |
wt-wt |
ddgs region (e.g. wt-wt worldwide, it-it). The worldwide default avoids US bias so IT pages aren't down-ranked. |
LIBREFOLIO_WEB_LINK_FINDER_DDGS_BACKEND |
auto |
ddgs backend(s): auto rotates/aggregates, or a comma list (e.g. google,bing). |
LIBREFOLIO_WEB_LINK_FINDER_TIMEOUT |
6 |
Per-request timeout, seconds. |
LIBREFOLIO_WEB_LINK_FINDER_MAX |
5 |
Max candidate URLs returned. |
๐ฐ๏ธ Transport history (why ddgs)
The original transport was a hand-rolled DuckDuckGoEngine that scraped the
html.duckduckgo.com HTML endpoint. It proved fragile: under back-to-back backend queries DDG's
bot-detection returns an HTTP 202 "anomaly" page with zero results (the same query works in a
browser โ a trusted human session). Because 202 is a 2xx, raise_for_status() never fired, so the
empty page was parsed as "no hits" and โ worse โ cached for 15 minutes. Net effect: a query that
Google/DDG resolve fine in a browser returned nothing from the backend.
The scraper was retired in favour of the ddgs metasearch library (2026-07-28). By rotating
and aggregating multiple upstream engines it sidesteps any single engine's rate-limiting, needs
no extra infrastructure (a pure pip dependency), and drops straight into the existing
synchronous _SearchEngine seam. DuckDuckGo remains one of its backends, so no coverage is lost.
Deferred: SearXNG (Fase B)
A self-hosted, anonymized SearXNG metasearch container is planned as an optional
power-user upgrade, sitting in front of ddgs as the runtime chain [searxng โ ddgs]. It is
deferred until search usage outgrows ddgs; the searxng engine name is already reserved
in _build_engine. See the plan under Release_2/Phase_0/04_webSearchEngine/ and the backlog
note in Release_2/todo_futuri.md.
๐ resolve_url โ the generic URL โ search-item capability
Any provider can opt into turning a page URL back into search-item(s) (the inverse of
get_asset_url). It is defined on the AssetSourceProvider base class:
| Member | Default | Description |
|---|---|---|
resolvable_url_domains: list[str] |
[] |
Domains this provider can resolve. |
supports_url_resolution |
derived | bool(resolvable_url_domains). |
async resolve_url(url) -> dict \| list[dict] \| None |
None |
Open โ extract โ return search-item(s), or None when the URL is not a recognisable asset page for this provider. |
resolve_url is only an alternative entry point into search: it returns the same shape
search would for that instrument. When a single page maps to several canonical rows โ e.g.
one item per language (Italian + English, with flags), exactly like an on-site cerca hit โ
return them all as a list. The orchestration flattens the result and de-duplicates by
(identifier, language), so sibling language-URLs of the same instrument don't pile up.
Each item must match the search-item shape
(identifier, identifier_type, display_name, currency, type, provider_params) so the
create-asset flow can consume it directly. resolve_url must be best-effort โ parse/fetch
errors return None, never raise.
Scope
The capability is generic in the base class, but Borsa Italiana is the only provider that
implements it in the current pass: a fund detail page resolves to the IT + EN pair
(Italian first, with flags), each carrying the fund's codice_fondo.
๐งต Hints โ the two-stage "stringone"
When the caller holds extra technical identifiers and names (typically extracted from a broker
report during import), it passes them as hints. Hints do two things:
1๏ธโฃ Two-stage candidate query
_build_link_finder_queries(query, hints) produces an ordered, de-duplicated candidate list:
- Rich string โ every hint plus the base query concatenated
(whitespace-collapsed, not truncated or reordered). Example:
"LU2178929613 EURIZON NEXT 2.0 DIVERSIFICATO 40 P". - Base query โ the bare query as a fallback.
_augment_with_link_finder tries them in order and returns items from the first candidate
that resolves anything. The rich query narrows the external engine when the bare query is too
ambiguous (a bare ISIN on a site-restricted external search often surfaces every sibling share class);
if it is over-constrained and finds nothing, the base query still runs. With no hints, the list is
just [base] (legacy behaviour).
Hints never touch the on-site cerca
The stringone lives only in the link-finder stage. It must not be fed to the provider's L1 on-site search, which is stricter and would return 0 for a concatenated blob โ losing the on-site path when the name alone would have matched.
2๏ธโฃ Identifier post-filter
Even with the rich query, the external engine can still return sibling instruments. So resolved
items are narrowed by _filter_items_by_known_identifiers(items, [*hints, query]):
- If any resolved item's
identifiermatches a known term (case-insensitive), only the matching items are kept. - If nothing matches, all items are returned (the user chooses).
Free-text name hints are inert here โ a name never equals an ISIN/ticker identifier โ so only real identifiers (ISIN, ticker) actually filter. This is what turns a bare-ISIN search that surfaced 5 sibling funds into the single exact match.
๐ฅ Where hints come from
| Source | Hints passed |
|---|---|
| Import wizard (create-asset) | Extracted ISIN + symbol + all candidate report names (_createNames). |
| Manual asset search | The query itself doubles as an identifier hint via the [*hints, query] merge in the filter. |
๐ API
Both search endpoints accept an optional, repeatable hints query parameter:
GET /api/v1/assets/provider/search?q=LU2178929613&providers=borsa_italiana&hints=LU2178929613&hints=EURIZON+NEXT+2.0+DIVERSIFICATO+40+P
GET /api/v1/assets/provider/search/stream?q=...&hints=...&hints=...
hints is an optional nullable array in the generated client (./dev.py api sync). Omitting it
preserves the legacy single-query behaviour.
๐ฅ๏ธ Frontend wiring
| Component | Prop | Role |
|---|---|---|
AssetSearchAutocomplete.svelte |
hints?: string[] |
Appended as repeated &hints= on the SSE fetch URL (deduped client-side). |
AssetModal.svelte |
searchHints?: string[] |
Forwarded to the autocomplete. |
ImportWizardModal.svelte |
searchHints |
Built from the extracted ISIN + symbol + all candidate report names. |
No UI change is required for the feature to work end-to-end โ an asset created from a resolved
result already carries its provider_params, so it is priceable immediately.
๐ Fragility & limits
- External metasearch is keyless by default and best-effort โ treat L2 as a convenience, not a
guarantee. The current transport uses the
ddgslibrary, not LibreFolio HTML scraping; upstream engine failures, missing dependencies, or timeouts degrade to[]. - L2/L3 only trigger on a 0-result L1 โ a provider that returns any on-site result never reaches the external stack (by design).
- Site-restricted ranking is fuzzy โ the identifier post-filter, not the engine, is what guarantees precision when a known identifier is available.
- For a paid, reliable engine, implement the
ApiKeyEngineand setโฆ_ENGINE=apikey+โฆ_API_KEY.
๐ Related Documentation
- ๐๏ธ Asset Architecture โ Sync pipeline, caching, search endpoints
- ๐ Asset Plugin Guide โ How to build a provider (incl.
resolve_url) - ๐ฎ๐น Borsa Italiana Provider โ The reference implementer of L2/L3