- Workflow
defaults:deep-merge + reusable fragments — a top-leveldefaults:block in a workflow file is deep-merged into every job (per-job keys win, nested maps merge recursively);include:(aliasuse:) pulls in a reusable fragment file — a partial job mapping shared across jobs — resolved against$UJIN_WORKFLOWS_DIRfirst then the including file's directory, with nested-include support and a cycle guard (WorkflowIncludeError). Precedence:defaults < file-level include < per-job include < inline job keys. A missing fragment, cyclic include, or malformed directive is non-fatal for a workflows-directory file — logged and surfaced underGET /healthworkflows.failed— and fatal (fail-fast) for an explicit--configfile. Existing single-file workflows with nodefaults/includekeys are unaffected. Shipsexamples/workflows/site-feeds.yaml+examples/workflows/fragments/sqlite-sink.yaml. Documented indocs/WORKFLOWS.mdandREADME.md. - Workflow
matrix:/for_each:fan-out — amatrix:(aliasfor_each:) key turns one workflow template into one job per entry: either a list of variable maps, or a mapping of axes expanded to the cartesian product. Each entry's variables substitute into the whole tree (source/transforms/sinks/schedule) via${VAR}/${VAR:-default}, with a whole-scalar${VAR}reference preserving its native type (int/float/bool) rather than stringifying. Generated ids are distinct and deterministic (<template>-<slug-of-entry>, or an explicit var-drivenid:template); a malformed matrix or a genuine duplicate identity raisesWorkflowMatrixError, reported non-fatally underGET /healthworkflows.failed. Matrix fan-out composes afterdefaults:/include:resolution, so a matrix template inherits shared defaults and fragments. Shipsexamples/workflows/matrix-status.yaml+examples/workflows/matrix-defaults.yaml. Documented indocs/WORKFLOWS.mdandREADME.md. - Coverage floor raised to 90% (
pyproject.tomlfail_under) to match the orchestrator's own ratcheted floor (orchestrator/state/cov_floor.txt), so a humanmake gaterun and the autonomous merge gate enforce the same bar.
- External ingest plans — a new generic loader (
ujin/jobs/plan.py:load_plan/parse_plan/PlanError) lets one mounted file declare a whole batch of jobs at once, sharing config throughdefaults(deep-merged into every job), namedtemplates(opted into withuse:), and a per-entrymatrix(cartesian expansion with type-preserving${VAR}/${VAR:-default}substitution). The path comes from a new--planflag onujin jobs-serveor$UJIN_INGEST_PLAN(mirroring$UJIN_MARKETPLACE_PROFILES);create_jobs_app/serveload it alongside the existing config-path and workflows-dir paths, which are unchanged. A bad or missing plan is non-fatal — logged and surfaced on/healthunder a newplanblock (path/loaded/failed/error), just like a bad workflow file. Shipsexamples/ingest-plan.yamlanddocs/INGEST_PLAN.md; the loader carries no consumer-specific identifiers. Documented inREADME.md.
- Workflow/job files now support a top-level
defaults:block (deep-merged into every resulting job, with per-job keys winning recursively over nested maps likesource/transforms/sinks/schedule) and aninclude:/use:directive — usable both file-level and per-job — that pulls in reusable fragment files (a shared sink, transform pipeline, or schedule). Fragment paths resolve against$UJIN_WORKFLOWS_DIRfirst, then the including file's directory; nested includes are supported with a cycle guard, and precedence isdefaults < included-fragment < inline-job-keys. A missing or cyclic fragment raises a clearujin.jobs.app.WorkflowIncludeError; the directory loader logs and skips a bad file (now also surfaced inGET /healthworkflows.failed) instead of crashing. Applied consistently to both the workflows-dir loader and the--configpreload path. Shipsexamples/workflows/site-feeds.yaml+fragments/sqlite-sink.yamland is documented indocs/WORKFLOWS.md. Files withoutdefaults:/include:parse exactly as before, and${VAR}/${VAR:-default}substitution is unchanged.
- Added offline, deterministic tests to
tests/test_robots.pyandtests/test_diff.pycovering previously-uncovered paths: repeated-agent parse branch (55->57), unknown-directive skip (75->37), empty-pattern_matchshort-circuit (line 131),re.errorguard (lines 144–145), full_http_fetcherbody (lines 161–171, via aiohttp mock/import-failure), andextract_regionsempty-selectors early return (region.py line 30); liftingujin/robots.pyandujin/diff/region.pyto 100% line and branch coverage. - Added 8 offline, deterministic tests to
tests/test_extract_contacts.pycovering previously-uncovered edge paths inujin/extract/contacts.py: empty/whitespace-onlyhrefanchor (line 153), emptymailto:andtel:address/number branches (lines 158, 162), and the three exception-guard fallbacks in_is_social_href,_resolve, andextract_contacts(lines 85–86, 94–95, 123–124), lifting the module to 100% line and branch coverage. - Added
tests/test_adapt_coverage.pywith 8 offline, deterministic tests covering previously-uncovered branches inujin/adapt/jitter.py(apply("full"),apply("equal"),apply(unknown)ValueError),ujin/adapt/interval.py(min_interval > max_intervalvalidation,reset()), andujin/adapt/backoff.py(Backoff.reset()), lifting each module's line coverage to 100%. - Added
tests/test_poll_multi.pywith 15 offline, deterministic unit tests forMultiPollable(fan-out composition, list/scalar/None payloads, exception and not-ok child skipping, fingerprint change detection, empty-children edge case), bringingujin/poll/multi.pyto 100% line and branch coverage. - Added offline, deterministic unit tests for the generic marketplace engine (
ujin/fetch/browser.py,ujin/extract/product.py,ujin/poll/amazon.py,ujin/poll/marketplace.py), covering browser fingerprint rotation / stealth context, JSON-LD/schema.orgProductedge cases, href-patternsource_idrecovery, AliExpressrunParamsparsing branches,_SeenStoreTTL hit/miss/expiry, and_HarvestStoresave/cap behaviour. Module-level coverage for these files improved from 80–91% to 96–100%.
- Absorbed the generic engine improvements from the marketplace development line, keeping
them site-agnostic: browser fingerprint rotation / stealth-context hardening
(
fetch/browser.py), JSON-LD/schema.orgProductdetail extraction (extract/product.py), href-patternsource_idrecovery + detail scraping (poll/amazon.py), and an opt-in per-host detail-page cache (_SeenStore;marketplace_searchconfigdetail_cache,detail_cache_path,detail_cache_ttl_secs) that skips re-fetching detail for source_ids seen within a TTL. No site profiles are baked in — see below.
- Marketplace profiles are now externally supplied, not built in.
ujin.poll.marketplacekeeps the generic engine (MarketplaceSearchPollable) but no longer ships the hardcodedSITE_PROFILES(amazon/newegg/ebay/walmart). Profiles are loaded from an inline mapping (profiles=), a file (profiles_path=), or$UJIN_MARKETPLACE_PROFILES(a YAML/JSON path, mountable as a volume) — inline overrides file. An unknownprofilenow raisesValueErrorinstead of falling back to amazon. Newmarketplace_searchsource config keys:profiles,profiles_path. The four prior profiles ship as a reference atexamples/marketplace_profiles.yaml; mount or pass it to retain previous behaviour. Seedocs/MARKETPLACE.md. This lets the specific scraping config live in the consuming app (e.g. wordle-max) rather than inside ujin.
- Coverage hardening (extract + browser) — 116 new offline, deterministic unit tests close coverage gaps in
ujin/extract/links.py(80% → 95%),ujin/extract/article.py(78% → 100%),ujin/extract/structured.py(82% → 99%), andujin/poll/browser.py(80% → 100%); total suite coverage moves from ~90% to 97%. Tests target error/edge/fallback branches:_is_boilerplate_textvariants, slop URL/text filters, photo-credit patterns, excluded-element detection (role/aria-label/class),normalize_urledge cases,extract_article_lenient,_run_trafilaturaImportError and generic-exception paths, JSON-LD/OpenGraph/microdata edge inputs, and all fourBrowserPollableextract modes plus lazy-fetcher init. - docs(sync/0.17): Audited README and all
docs/pages against the shipped 0.17 surface — corrected four omissions: (1)docs/JOBS.mdtransforms table was missing thefilterkind added in 0.17; (2)docs/LIST_TRANSFORMS.mdintro said "Eight" job kinds but nine are now documented (filteradded); (3)README.mddocs-index reference for LIST_TRANSFORMS omittedfilter; (4)docs/API.mdabridgedScrapeResponseJSON example omitted thetotal/next_cursorpagination fields. Also added "feed-URL extraction" to the README intro feature description to reflect the 0.17feedsmode. No production code changed.
- Contact information extraction — new
ujin.extract.extract_contacts(html, base_url=None) -> dictcollects email addresses (frommailto:hrefs and visible-text patterns), phone numbers (fromtel:hrefs and international/NANP patterns in visible text), and social/profile links (from<a>hrefs pointing to known platforms or carryingrel="me") into a normalized dict{"emails": [...], "phones": [...], "links": [...]}with each list de-duplicated in document order; relative hrefs in social links are resolved againstbase_url; text inside<script>/<style>tags is skipped; empty or malformed input returns{}rather than raising. Pure stdlib (html.parser,re). A new additivecontactsscrape mode surfaces it:POST /scrape {"mode":"contacts"}(orcontactsinside amodesmulti-extract list) returns the dict in the newScrapeResponse.contactsfield. Strictly additive — every existing mode, field, and default is byte-for-byte unchanged. Documented inREADME.mdanddocs/API.md. filtertransform — new built-in that keeps or drops items from a list payload by a configurable predicate over a dottedkey, supporting operatorseq,ne,gt,lt,ge,le,in,contains,exists, andregex/matches, plus anegate/excludeflag to invert selection; on a dict payload the whole event is kept or dropped; non-list/non-dict payloads and empty inputs pass through unchanged. Registered askind: filter, discoverable atGET /kinds, and documented indocs/LIST_TRANSFORMS.md.- Declared feed discovery — new
ujin.extract.extract_feeds(html, base_url=None) -> list[dict]parses every<link rel="alternate">in the document head whosetypeis a recognized feed MIME type (application/rss+xml,application/atom+xml,application/feed+json) into a normalized dict with an absolutehref(resolved againstbase_url), a lowercasetype, and an optionaltitlewhen present; identical hrefs are de-duplicated in document order; empty/malformed input returns[]rather than raising. Pure stdlib (html.parser). A new additivefeedsscrape mode surfaces it:POST /scrape {"mode":"feeds"}(orfeedsinside amodesmulti-extract list) returns the dicts in the newScrapeResponse.feedsfield. Strictly additive — every existing mode, field, and default is byte-for-byte unchanged. Documented inREADME.mdanddocs/API.md. - docs(sync/final): Final docs-sync pass — two omissions corrected and full audit confirmed inline. (1)
docs/JOBS.mdbrowser source row was missing theheadless?config key and the validextractvalues (links|article|structured|raw) — both now listed, matchingBrowserPollable.__init__. (2)docs/BROWSER.mdjob-source config example was missing"headless": true— added with a "set false for headed debugging" note, matching the same constructor default. All other doc surfaces verified against the 0.16.0 code:docs/API.md—POST /scrapefield table (render,actions,page_size,cursor),UJIN_LEARN_STRATEGY/UJIN_STRATEGY_DBenv vars,htmlmulti-extract mode, and all seven response fields (links,article,structured,tables,images,metadata,html) present and correct;docs/JOBS.md— all 12 transform kinds (selectregextemplatededupechunkflattensortlimitrenameaggregateuniquefill), all 9 source kinds (httpsiterssapigraphqlcommandscrapebrowserplugin:<name>), and all 8 sinks (webhookforwardwsjsonl/filestdoutsqlitecsvplugin:<name>) documented;docs/LIST_TRANSFORMS.md— all 8 list-reshaping transforms +csvsink documented with YAML examples;docs/ADAPTIVE.md—ujin learnedCLI,--host/--strategy-db/--jsonflags,SiteStore.hosts(), and all adaptive symbols referenced (SiteStore,HostRecord,derive_signals,PolicySignals,SignalAdvisor,StrategyFeedback,StrategyOutcome,LearnedRateLimiter,RobotsPolicy,RobotsCache) present and accurate;docs/BROWSER.md— exists with full content, all recipe actions matchBrowserFetcherimplementation; README — feature list (GraphQL + browser-driven recipes), adaptive-learning quickstart (adaptive=True,site_store_path,respect_robots=True,robots_ttl,robots_fetcher), CLI section (all 11 subcommands includingdoctor,init,learned), and all runnable examples (from ujin import PollEngine, HttpPollable, CallablePollable, CommandPollable;from ujin.adapt import SiteStore, derive_signals, LearnedRateLimiter) verified importable against currentujin/__init__.pyandujin/adapt/__init__.py. - docs(api): Reconciled
docs/API.mdagainst the shipped 0.16.0 surface: addedrender,actions,page_size, andcursorto thePOST /scraperequest-field table (all four are accepted byScrapeRequestand referenced in prose, but were absent from the reference table); addedUJIN_LEARN_STRATEGYandUJIN_STRATEGY_DBto the configuration table (both ship viaScrapeConfig.from_env()and were mentioned inREADME.mdbut missing from the API config reference). - docs(sync): Comprehensive audit of
README.mdand alldocs/pages against the shipped 0.16.0 surface. Added "GraphQL endpoints" and "browser-driven interaction recipes" to the README intro feature list — bothGraphQLPollableandBrowserPollablehave shipped inujin.poll(since 0.15.0 and 0.5.0 respectively) but were absent from the top-level feature pitch. AddedBrowserPollableto the README "How it works → Roles" list alongside the other pollable classes.
- HTML image extraction — new
ujin.extract.extract_images(html, base_url=None) -> list[dict]parses every<img>on a page into one normalized dict with an absolutesrc, analtstring, and an optional integerwidth/heightandtitlewhen present. Relative srcs are resolved againstbase_url; lazy-loaddata-src/data-originaland the firstsrcsetcandidate are honored (adata:placeholder is skipped whenever a real src exists for the same image); identical srcs are de-duplicated in document order; and empty/malformed input returns[]rather than raising. A new additiveimagesscrape mode surfaces it:POST /scrape {"mode":"images"}(orimagesinside amodesmulti-extract list) returns the dicts in the newScrapeResponse.imagesfield — under theextractsmap for multi-extract requests. Strictly additive — every existing mode, field, and default is byte-for-byte unchanged. Documented inREADME.mdanddocs/API.md.
uniqueandfilltransforms —uniquedrops duplicate items from a list payload by a dottedkey(or whole-item identity when key omitted), preserving first-occurrence order and passing non-list payloads through unchanged;fillensures named dotted fields exist on a dict payload or each dict in a list-of-dicts, setting a per-path or shared default without overwriting existing non-None values and passing non-dict items through unchanged. Both are discoverable atGET /kindsand documented indocs/LIST_TRANSFORMS.md.
- docs-sync — audited all docs against the 0.15.0 shipped set; added
graphqlto the poller-controlkindlist indocs/API.md; addedunique/fillto theLIST_TRANSFORMSreference inREADME.md; and addedaggregate,unique, andfillrows to the transforms table indocs/JOBS.mdso every built-in transform kind is discoverable from the job reference. - HTML head-metadata extraction — new
ujin.extract.extract_metadata(html, base_url=None) -> dictparses page-level head metadata into one flat, normalized summary:title, metadescription,canonicalURL,language(<html lang>), optionalauthor/published/modified/favicon, plus the OpenGraph (og:*) and Twitter-card (twitter:*) fields collected underog/twittersub-dicts with the prefix stripped.canonical,favicon, and og/twitter URL values are resolved againstbase_url; flattitle/descriptionfall back toog:title/og:descriptionwhen absent; empty/malformed input returns{}rather than raising. Pure stdlib (html.parser), and a deliberately flat complement toextract_structured(it does not duplicate JSON-LD/microdata). A new additivemetadatascrape mode surfaces it:POST /scrape {"mode":"metadata"}(ormetadatainside amodesmulti-extract list) returns the summary in the newScrapeResponse.metadatafield — under theextractsmap for multi-extract requests. Strictly additive — every existing mode, field, and default is byte-for-byte unchanged. Documented inREADME.mdanddocs/API.md.
graphqlsource kind — newGraphQLPollablePOSTs a configuredquery(plus optionalvariables,headers) to aurland fingerprints events narrowed from a dotteddata_pathin the JSON response, reusing the same aiohttp client/timeout path asApiPollable. A GraphQLerrorsarray, non-200 status codes, and network exceptions are all surfaced as poll failures without crashing the poll loop. Registered askind: graphqlin YAML targets and the jobs control plane.
ujin learnedCLI +SiteStore.hosts()— a new additiveSiteStore.hosts() -> list[str]read method enumerates every host persisted in the store (sorted, never mutating), and a newujin learned [DB_PATH] [--host HOST] [--strategy-db PATH] [--json]subcommand opens an existingSiteStoreread-only and prints, per host, the learned state: recommended interval (viaujin.adapt.derive_signals), concurrency factor, penalty/backoff (health, cooldown, rate-limited), last observed status/latency, and any observedCrawl-delay; with--strategy-dbit also showsStrategyFeedback.recommend(host). Defaults to a human-readable table,--jsonemits machine-readable output, and--hostfilters to one host. A missing/empty DB path (or a missing--strategy-db) fails with a clean actionableujin: ...message and non-zero exit (no traceback); an existing-but-empty store prints a friendly note. Strictly additive — every existing public name and CLI subcommand is unchanged. Documented inREADME.mdanddocs/ADAPTIVE.md.- test(cov-social-discover): Added 8 fully-offline unit tests closing remaining branch/line gaps in
ujin/sources/social/x_trends.py(lines 48, 98, 100-102, 108, 111, 114) andujin/sources/discover.py(lines 82-83, 106-107, 114, 123) —x_trends.pyreaches 100% line coverage,discover.pyreaches 98%; total suite coverage rises to 96%.
- perf(bench): Added
benchmarks/test_extract_throughput.pymeasuring single-process CPU-bound extraction throughput forextract_headline_links,extract_article,extract_structured, andextract_tables(events/sec and ms/page); per-poll cost (all four extractors, fetch excluded) recorded inbaseline.jsonat ~7.1 ms/page (~140 pages/sec ceiling). New Multiprocessing (Track 3) gate section indocs/PERFORMANCE.mdreports the measured ceiling and explicit go/no-go recommendation: Track 3 is not justified for polling workloads (≈17 pages/sec, 14× below the ceiling) and is only warranted above ~140 pages/sec sustained fetch rate (full extraction) or ~815 pages/sec (links-only mode).
- test(social-source-coverage): Added 10 fully-offline unit tests closing the remaining branch gaps in
ujin/sources/social/_syndication.py— text/html response wherejson.loadssucceeds but returns a non-dict (list/number) falls through to_from_html(branch 82→86);_from_jsonwith empty results and a non-stringbodyvalue returns[]directly (branch 114→116);_from_htmlskips tweet nodes with valid permalinks but whitespace-only text (line 134continue); and_from_htmlstops collecting once thecountceiling is hit (line 137break). All four target modules (mastodon.py,twitter.py,_nitter.py,_syndication.py) now report 100 % line coverage; total suite coverage rises from 95.48 % to 95.56 %.
- HTML table extraction — new
ujin.extract.extract_tables(html) -> list[dict]parses every<table>on a page into one dict per data row, keyed by the table's header cells (a first row carrying<th>) or positionally (col0/col1…) for header-less tables.colspan/rowspanare expanded so each logical cell lands in its grid slot, nested tables are parsed as their own rows (their text never leaks into the enclosing cell), and empty/malformed input returns[]rather than raising. A new additivetablesscrape mode surfaces it:POST /scrape {"mode":"tables"}(ortablesinside amodesmulti-extract list) returns the rows in the newScrapeResponse.tablesfield — under theextractsmap for multi-extract requests. Strictly additive — every existing mode, field, and default is byte-for-byte unchanged. aggregatetransform — new built-in kind that groups a list payload by a dottedbykey and emits one dict per group withcountplus optionalsum/min/max/collectaggregates over configurable dottedfields; supports a separateoutpath; non-list and empty payloads pass through unchanged. Discoverable atGET /kinds.- test(poll-coverage): Added 10 offline unit tests covering previously-uncovered error/edge branches in the live poll subsystem — empty-argv
ValueError(command.py:25),asyncio.TimeoutErrortimeout path (command.py:42-44), generic subprocess exception (command.py:47-48), feedparserImportError(rss.py:23-24),parse_feedexception (rss.py:29-30),decide_changed(None, ...)short-circuit (base.py:79), bytes/bytearray fingerprint branch (base.py:26),aiohttpImportError(api.py:53-54), request-level network exception (api.py:72-73), andrender=TrueObscuraFetcher path (site.py:54-58); all five targeted files reach 100 % line coverage and total suite coverage rises from 95.43 % to 95.50 %.
- Opt-in strategy-feedback loop in the scrape service —
ScrapeConfig(learn_strategy=True, strategy_db=...)constructs a durableujin.adapt.StrategyFeedback(built/closed bybuild_scrape_components; emptystrategy_db→ ephemeral:memory:). When on, theautobackend path biases the first(backend, render_mode)it tries toward the host's proven-bestrecommend(), skips a recommendation flagged byis_penalized()(via an optional injectedSiteStore), and records every fetch outcome withrecord(host, backend, render_mode, ok, latency)so the loop closes. Strictly additive and off by default — a no-config scrape is byte-identical to before. PollEngine(respect_robots=True)— whenadaptive=Trueis also set, automatically builds aRobotsCache(injectablerobots_fetcher, configurablerobots_ttl, 1 h default) and wires it into the engine'srobots=hook onLearnedRateLimiter:Crawl-delaybecomes a hard floor on the learned per-host interval, and any URL whose path is disallowed is silently skipped — counted as a poll but not a failure so backoff and penalty logic are unaffected. Off by default; the pre-existing engine/poll path is byte-identical when the flag is unset.- test(cli): Added 9 tests covering previously-uncovered
ujin/cli.pypaths —_version()exception/metadata-fallback/"unknown"branches (lines 48-59), the YAML-error-without-problem_markbranch (line 99),_cmd_obscura_buildsuccess and missing-Cargo.toml paths (lines 200-216), and_cmd_watchcallback/webhook/selector+render paths (lines 299-319);cli.pycoverage rises from 82 % to 99 % and total suite coverage from ~95 % to 95.43 %.
- Opt-in adaptive poll engine —
PollEngine(adaptive=True)wires the already-shipped Track-1 governor into the live loop: the engine constructs a per-processSiteStore+LearnedRateLimiter, paces each poll through the asyncacquire(host)gate, floors every target's next interval byinterval_for(host), and persists each response viaobserve(...)so a 429 durably backs the host off and a restarted process resumes calibrated. The store path (site_store_path, default in-process:memory:),adaptive_base_interval, and an optionalrobotsadapter are configurable, and the limiter shares the engine's injectableclock/sleep. Strictly additive and off by default — with the flag unset noSiteStore/limiter is built, no extra I/O happens, and the poll path is byte-identical to before. - scrape multi-URL batch:
POST /scrapenow accepts an optionalurlslist so one request scrapes several URLs and returns one result per URL under a new additivebatchlist (in request order); the top-level fields mirror the first URL's result. The URLs are fetched concurrently with a bounded concurrency cap —ScrapeService.scrape_urls()fans out the per-URLscrape()calls under anasyncio.Semaphore(batch_max_concurrency, envBATCH_MAX_CONCURRENCY, default 8) and isolates per-URL failures askind='error'entries so one failing URL never sinks the batch. The batch form is single-mode(themodesmulti-extract map andpage_size/cursorpagination are not applied per URL) and is bounded bybatch_max_items(default 64). Omittingurlskeeps the classic single-urlbehaviour byte-for-byte unchanged. - docs/ADAPTIVE.md — end-to-end user guide for the durable adaptive-learning subsystem (SiteStore/HostRecord, derive_signals/PolicySignals/SignalAdvisor, StrategyFeedback/StrategyOutcome, LearnedRateLimiter, ujin.robots); surfaces all adaptive symbols in the README.md feature list.
- Learned rate governor (
ujin/adapt/rate.py, pure stdlib) —LearnedRateLimiter(store, robots=None, *, base_interval=0.0, clock=..., sleep=..., max_concurrency=8)composesderive_signals(record)output (recommended_interval,concurrency_factor,rate_limited,cooldown_secs) via theSignalAdvisorbridge with an optional robotsCrawl-delay, backed by the existingAdaptiveInterval/AIMDLimiter/TokenBucketprimitives.interval_for(host)/concurrency_for(host)report the effective cadence and concurrency (the interval never belowmax(observed Crawl-delay, robots.crawl_delay(host))); the asyncacquire(host)gate (alsoasync with) paces a per-host token bucket and caps in-flight requests;observe(host, status=..., latency=..., error=...)feeds each response back into the store and the in-process controllers so a 429 raises the interval and throttles concurrency while clean responses relax both toward baseline. Persisted state warm-starts the controllers on restart. Exported additively fromujin.adapt; opt-in and wired into nothing by default — the scrape/poll path is unchanged unless the limiter is explicitly used.crawl_delayand the host-policy signals now drive this governor. - scrape multi-extract:
POST /scrapenow accepts an optionalmodeslist (links/article/auto/structured/html) so one request fetches the page once and returns a result per mode under a new additiveextractsmap (keyed by mode); the top-level fields mirror the first listed mode. Backed byScrapeService.scrape_multi(), which runs each mode over the already-fetched body and isolates per-mode failures askind='error'entries so one failing mode never sinks the others. Thehtmlmode returns the raw fetched HTML in the newhtmlresponse field. Omittingmodeskeeps the classic single-modebehaviour byte-for-byte unchanged.
- tests: Added
tests/test_cov_trends_mcp.py(35 offline tests) raising per-file coverage ofujin/trends/corroboration.py,ujin/trends/scorer.py,ujin/mcp/server.py,ujin/service.py, andujin/sources/social/x.pyto ≥99% each, lifting TOTAL coverage from ~89% to 90.7%.
Additive only — no public symbol, CLI subcommand, flag, env var, response field, or Docker target was renamed or removed, so the three consumer-contract surfaces (awork / hct-site / wordle-max) stay frozen and green.
SiteStore/HostRecord(ujin/adapt/site_store.py, pure stdlib) — a durable per-host observed-state store on SQLite. Persists last status, p50/last latency, error count, 429 count, observedCrawl-delay, the adaptive interval, andlast_seenso a fresh process resumes calibrated, polite polling.SiteStore(path=':memory:', clock=time.time);get(host)returns a zero-valuedHostRecordfor unknown hosts;record(host, **signals)is an atomic serialized upsert (counters accumulate, gauges overwrite,last_seenstamped from the injectable clock);close()runs a truncatingwal_checkpoint. Reuses the disk cache's WAL-mode /synchronous=NORMALdurability pattern. Both names are exported fromujin.adapt. This is the foundation other Track-1 adaptive units consume.
ujin.robots:RobotsPolicyparses robots.txt into per-User-agent groups with Allow/Disallow longest-match precedence,*and$wildcard handling,Crawl-delayextraction, andSitemap:directive collection; malformed/empty/missing file → allow-all.RobotsPolicy.is_allowed(path, agent='*') -> boolandRobotsPolicy.crawl_delay(agent='*') -> float | Noneare pure methods over already-parsed text.RobotsCache(ttl, fetcher, clock)adds a TTL fetch+cache layer with injectable fetcher and clock for deterministic tests; opt-in only — default scrape/poll behavior is unchanged unlessRobotsCacheis explicitly used.crawl_delay()values are a future input to the learned-rate-limit system (ujin.adapt.concurrency).
StrategyFeedback/StrategyOutcome(ujin/adapt/strategy.py, pure stdlib) — a durable per-host, per-strategy outcome store on SQLite. A strategy is a(backend, render_mode)pair.StrategyFeedback(store=':memory:', clock=time.time):record(host, strategy, *, ok, latency)is an atomic serialized upsert (counters accumulate, latency gauges overwrite,last_seenstamped from the injectable clock);recommend(host)returns the highest-success-rate known strategy deterministically (ties broken by attempts then lexicographic order), orNonefor an unseen host;is_penalized(host, strategy, record)is a pure no-I/O helper that returnsTruewhenderive_signals(record).rate_limitedorhealthis low;close()runs a truncatingwal_checkpoint. Reuses the WAL-mode /synchronous=NORMALdurability pattern fromSiteStore. Both names exported additively fromujin.adapt; opt-in only — not wired into any default scrape/poll path. Input layer for future learned strategy-selection.- tests: Added
tests/test_jobs_coverage.py(38 offline tests) raising per-file coverage ofujin/jobs/app.pyto 99%,ujin/jobs/pipeline.pyto 100%,ujin/jobs/cron.pyto 100%, andujin/jobs/transforms.pyto 98%, lifting TOTAL coverage from ~91% to 95%. - Host policy signals (
ujin/adapt/signals.py, pure stdlib) — a deterministic interpretation layer overSiteStore/HostRecord.derive_signals(record, *, base_interval=0.0, robots_crawl_delay=None)returns a frozenPolicySignals(recommended_interval,cooldown_secs,should_cooldown,rate_limited,concurrency_factor,healthin 0..1) doing no I/O: a 429 (counter or last status) setsrate_limited, raises the interval and throttles concurrency;recommended_intervalis never belowmax(crawl_delay, robots_crawl_delay); risingerror_countlowershealthand raisescooldown_secs; a clean record is pristine (health==1.0, no cooldown, full concurrency, interval ==base_interval).SignalAdvisor(store)is a read-only bridge whosefor_host(host)readsstore.get(host)and derives signals without mutating it. Exported additively fromujin.adapt; opt-in and wired into nothing by default — it is the input layer the planned strategy-feedback and learned-rate-limit units consume. - Test coverage for social sources and jobs client — fixture-driven offline unit tests for
mastodon.py(47%→100%),twitter.py(44%→100%),jobs/client.py(67%→100%), andsitemap.py(79%→100%); total suite coverage rises to 88.9% (floor 85%). - Coverage gap-fill — offline tests for
poll/__init__lazy imports,_nitter.nitter_posts(success/failure/cooldown paths), and_syndication.syndication_posts(JSON/HTML/error paths); closes the 87%→88% gap flagged in prior review. - Scrape subsystem coverage (
tests/test_cov_scrape.py) — 49 offline fixture-driven tests raisingapp.py67%→97%,build.py75%→100%,config.py76%→100%,host_overrides.py78%→97%,service.py83%→97%; total suite coverage rises to 91.4% (floor 89%).
Feature + performance + developer-experience cycle. Every change is additive — no public symbol, CLI subcommand, flag, env var, response field, or Docker target was renamed or removed, so the three consumer-contract surfaces (awork / hct-site / wordle-max) stay frozen and green.
- List-reshaping transforms (
ujin/jobs/transforms.py, pure stdlib):flatten— fan a list payload into one event per item (inverse ofchunk), with an optionalindexfield; non-list payloads pass through.sort— sort a list payload by a dottedkey(or natural order),reverseoptional; missing/uncomparable values sort last without raising.limit— cap a list payload to the first/last N items (count,from).rename— remap dict keys (mapping) across a dict or list-of-dicts;drop_missingmaterializes absent keys as null.
csvsink (ujin/jobs/sinks.py, pure stdlib) — append event rows to a CSV/TSV file with auto header on create, explicit-or-inferred (and then locked)columns, configurabledelimiter/path_in_event; non-dict items are skipped and a no-row event is a silent no-op.- All five kinds are additive, registered as built-ins, discoverable at
GET /kinds, and documented in docs/LIST_TRANSFORMS.md. ujin doctor— reports which fetch backends (http/obscura/playwright/ selenium) and optional Python extras are installed, what each unlocks, and the exactpip installto enable a missing one. Reusesujin/fetch/capabilities.py.ujin init [targets.yaml]— scaffolds a commented, ready-to-run startertargets.yaml(HTTP page, RSS feed, JSON API, shell command).-f/--forceoverwrites; refuses to clobber otherwise.ujin --version— prints the installed version.- Usage examples in
--helpfor every subcommand (epilogs), clearer top-level help, default-value hints on flags, andmetavars. - README: a 60-second quickstart and a Troubleshooting section mapping each
common error to its fix;
ujin doctorreferenced from docs/BACKENDS.md. - CLI tests for
doctor/init/--versionand every actionable error path.
- Disk cache (SQLite) runs in WAL mode with
synchronous=NORMAL. Per-put commits no longer fsync the whole database file, lifting the per-put commit cost from ~1.3 ms to ~20 µs (~49x) and the put+get-via-to_threadroundtrip from ~1.45 ms to ~0.12 ms — raising the durable-write ceiling from ~600 to ~40k writes/s. The publicDiskCacheAPI and its durability contract are unchanged: committed rows survive process death and reopen (new teststest_disk_durable_across_reopen_without_clean_close,test_disk_close_checkpoints_wal).close()now runs a truncatingwal_checkpointso the on-disk file stays self-contained after shutdown. New benchmarktest_disk_cache_putisolates the commit path; thedisk_cache_roundtripasync baseline was re-recorded. - Actionable CLI errors (no tracebacks; clean
ujin: …messages):- missing targets file → names the path + suggests
ujin init; - invalid YAML → names the file and line/column;
- non-mapping document or target entry → explains the expected shape;
- unknown source kind → lists the valid kinds;
- missing required config key (e.g.
url) → names the key.
- missing targets file → names the path + suggests
Hardening release: the test/coverage/benchmark infrastructure, API normalization, the MCP server, and the backend capability matrix.
- Builtin transforms built through the registry crashed with
'BuildContext' object is not callable— every workflow/job usingselect/dedupe/etc. throughJobManagerwas broken. (The old tests calledjobs.transforms.build_transformdirectly and missed it.) render="http"on a thin page now returns the thin link-set instead of discarding the body and failing..coverageaccidentally tracked in git; now ignored.
- MCP server (
ujin[mcp]extra):ujin mcp-serveexposes scrape/jobs as agent tools over stdio or streamable HTTP —scrape_url,scrape_feed,discover_site,get_capabilities,get_metrics, and the job lifecycle (list/get/create/run/pause/resume/get_job_results). See docs/MCP.md. - Backend capability matrix (
ujin/fetch/capabilities.py) +GET :8901/capabilitieswith live availability for http/obscura/playwright/selenium. Human version: docs/BACKENDS.md. - Benchmark harness (
benchmarks/): pytest-benchmark sync paths + a custom async runner gated at 4x the committedbaseline.json(make bench,make bench-record). Findings: docs/PERFORMANCE.md. - CI (GitHub Actions): 3.11/3.12 matrix, offline suite with coverage
gate (
fail_under=85, branch coverage), separate benchmark job. No obscura build, no browser downloads. - ~250 new tests (now ~440, fully offline, <10 s) including consumer-contract tripwires for awork / hct-site / wordle-max (docs/CONSUMERS.md) and shared fixtures (fake aiohttp origin, full-protocol FakePage, obscura stub binary, HTML corpus) — docs/TESTING.md.
- New docs: ARCHITECTURE, TESTING, CONSUMERS, BACKENDS, PERFORMANCE, MCP.
- Health responses normalized to
{ok, status, service, ...}on all three services (additive everywhere;:8901keepsstatus). - Breaking:
:8900 GET /statsrenamed toGET /metrics(no known consumer; the poller control surface had none). UJIN_API_KEYnow guards all services (was :8902 only). Opt-in via env;/healthstays open.ApiKeyMiddlewaremoved toujin.auth(ujin.jobs.authremains as a shim).- Stable import surfaces declared:
ujin.scrapeexportsScrapeService,ScrapeResult,build_scrape_service;ujin.jobsexportsJobManager; everything else is internal.
File-driven workflows (setup → collect → serve), plugin system, obscura submodule, scrape/jobs services, containerization. (Pre-changelog.)