Skip to content

feat(markdown): convert crawled HTML and documents to Markdown via convert-markdown.ifad.org - #1

Open
lleirborras wants to merge 16 commits into
1.0from
feature/markdown-conversion
Open

feat(markdown): convert crawled HTML and documents to Markdown via convert-markdown.ifad.org#1
lleirborras wants to merge 16 commits into
1.0from
feature/markdown-conversion

Conversation

@lleirborras

Copy link
Copy Markdown
Member

Summary

Adds an opt-in markdown_conversion feature: when enabled, every crawled HTML page and PDF/DOCX/XLSX/PPTX is uploaded to the shared IFAD service convert-markdown.ifad.org and the returned Markdown becomes the Elasticsearch body, so all IFAD apps index identical conversions. Plain-text extraction (HTML) and the _attachment Tika path (binaries) remain the fallback.

Design spec: docs/superpowers/specs/2026-09-04-markdown-conversion-design.md. Feature doc: docs/features/MARKDOWN_CONVERSION.md.

How it works

  • Coordinator#process_crawl_result calls config.markdown_converter.convert! on the crawl-task thread after the rule engine and before the sink, so no HTTP call ever runs under the Elasticsearch sink's queue lock. Coordinator delta is one call site plus one small helper.
  • New Crawler::MarkdownConverter (lib/crawler/markdown_converter.rb) owns everything: MIME map, Jsoup pre-processing (honours exclude_tags / data-elastic-exclude, UTF-8 charset, clone so link extraction is untouched), hand-built multipart upload via net/http, submit + poll with backoff and a hard deadline, retry-once rules, SSRF guard on the server-supplied status_url, health check, circuit breaker, atomic stats.
  • DocumentMapper: body = markdown when present, plus body_format (markdown|text) and content_hash on every doc; _attachment omitted only when markdown is present.
  • Elasticsearch sink defaults _reduce_whitespace to false when the feature is on (the ingest pipeline would otherwise flatten markdown); an explicit true is honoured with a warning.
  • Crawl#start! and start_url_test! fail fast with Errors::MarkdownConverterUnavailableError when the converter is unhealthy, so an outage cannot silently produce a text-only index. Mid-crawl outages trip a circuit breaker (20 consecutive failures, 60 s cooldown, single-thread re-probe).

Config

markdown_conversion:
  enabled: true
  base_url: https://convert-markdown.ifad.org
  wait_seconds: 10
  poll_interval: 2
  timeout: 900
  on_failure: text   # or skip — see doc for the purge interaction
  ca_file:           # optional PEM

Note: changes that apply even when disabled

body_format and content_hash are added to every document (HTML and binary) regardless of enabled, and both names are reserved for extraction rules. Deliberate: content_hash enables a later skip-unchanged re-crawl. Documented in the feature doc.

Verification

  • Full suite in the crawler-ci Docker image: 791 examples, 0 failures, 16 pre-existing pending. Rubocop project-wide clean.
  • New coverage: unit specs for config validation, converter core/transport (WebMock, every retry/poll/deadline/SSRF branch), coordinator hook incl. strict-double disabled path, mapper, sinks; integration spec crawling a Faux site (HTML + PDF) against a stubbed converter for success, 5xx fallback and unhealthy-abort.
  • Manual: with bin/crawler's JVM properties (https.protocols=SSLv3, force.http.jre.executor=true) set inside the container, Net::HTTP reached https://convert-markdown.ifad.org/api/v1/health and got HTTP 200, proving jruby-openssl is unaffected by that property.

Follow-ups (not in this PR)

  • Service side: replica affinity or shared job store (the in-process job store 404s polls that land on another replica), light/heavy executors, content-hash result cache.
  • Crawler phase 3: skip-unchanged re-crawl using content_hash.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ

lleirborras and others added 16 commits September 4, 2026 09:12
Reviewed design for routing crawled HTML and binary documents through
convert-markdown.ifad.org so body holds Markdown. Records the insertion
point, transport choice, config shape, failure semantics and test plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
Nine TDD tasks derived from the design spec, validated by tech-lead review.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…ctory

Adds the markdown_conversion nested config block (default disabled),
its validation (booleans, on_failure enum, wait_seconds range, positive
poll_interval/timeout, http(s) base_url, and an ES bulk-size guard), and
a memoised Config#markdown_converter factory backed by a new
Crawler::MarkdownConverter skeleton class.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…/content_hash

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…builders

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…heck and stats

Adds the HTTP side of MarkdownConverter: a hand-built multipart upload to
POST /api/v1/convert/upload?wait=N, status polling with 1.5x backoff capped
at 5s against a hard deadline, one retry after 1s for network errors, 5xx and
an expired job (404 while polling), a 5s /api/v1/health check, markdown
truncation to max_body_size and thread-safe converted/failed counters.
convert! never raises; it returns :converted, :skipped or :failed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
The status_url in a converter job document is server-controlled and was
concatenated onto base_url unchecked, so a response of "@evil.host/x" parsed
as host evil.host with the configured base_url demoted to userinfo and the
poll left the intended service.

Accept only an absolute path and require the parsed URI to keep the
configured host with no userinfo; both failures raise ConversionError naming
the offending status_url and are not retried.

Also pins the final-failure log assertions to their exact message instead of
at_least(:once), and asserts p_addr is nil on the constructed Net::HTTP.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…unhealthy converter

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
DocumentMapper now emits body_format and content_hash for both HTML
and binary documents. When Success#markdown is present, it becomes
the body (body_format: 'markdown'); binary docs then omit
_attachment since the ingest attachment processor is unneeded.
Otherwise behavior is unchanged (body_format: 'text', _attachment
kept for binary docs).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
… markdown on the console sink

_reduce_whitespace now defaults to false when markdown_conversion is
enabled, since the default ingest pipeline collapses whitespace in
`body` and would destroy markdown formatting. An explicit
_reduce_whitespace: true is still honoured, with a one-time warning.
The console sink now prints crawl_result.markdown when present,
instead of the raw HTML/binary-content placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
…lock

Adds config/crawler.yml.example markdown_conversion block and xlsx MIME
type, docs/features/MARKDOWN_CONVERSION.md, and a README link, matching
the paths already referenced by lib/crawler/api/config.rb and
lib/crawler/markdown_converter.rb.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
A converter that dies mid-crawl used to cost every remaining document a
full upload, a retry and possibly the whole per-document timeout before
falling back to plain text. MarkdownConverter now counts consecutive
failures and opens a circuit breaker after 20 of them: for the next 60
seconds convert! returns :failed without any HTTP call, so on_failure
keeps deciding as before (text -> plain-text body, skip -> not indexed)
and the failures still show up in the final stats line. Exactly one
thread re-probes /health after the cooldown; success closes the breaker,
failure re-arms it. A successful conversion resets the counter.

healthy? now retries once after RETRY_DELAY so a single blip neither
aborts a crawl nor keeps the breaker open, and the per-document retry
announcement drops from warn to debug: only the final failure of a
document deserves a warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
- parse_job: a valid JSON body that is not an object (`null`, an array)
  reached job['status'] and blew up with a NoMethodError inside the
  generic rescue; it now fails as a plain ConversionError.
- raise_on_http_error!: the error body is remote input, so inspect it
  before interpolating - a body with newlines could otherwise forge
  entries in the system log.
- html_payload: exclude_tags may remove the <body> element itself, which
  made Transformer.transform! raise on a nil tag; the remaining document
  is now uploaded as-is.
- require_dependency the ContentEngine modules the converter uses instead
  of relying on another file having loaded them first.
- Config::SENSITIVE_FIELDS gains markdown_conversion, so a converter
  base_url (which can carry credentials) is redacted from Config#to_s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
log_markdown_conversion_stats shadowed the delegated `stats` method with
a local; rename it. The line is now also printed on the resumable
shutdown path (so the documented "log ends with Markdown conversions:"
holds) and suppressed when the crawl aborted on the start-up health
check, where there is nothing to report.

start_url_test! runs verify_markdown_converter! too: a URL test used to
report plain-text bodies without ever asking the converter, and it also
warms the markdown_converter memo on the main thread. The duplicated
crawl-start event and unexpected-error handling move into helpers to
keep both entry points inside the metrics limits.

Also assign the converter once in Coordinator#convert_and_output_crawl_result
and fix the stale comment claiming Jsoup's .clone is shallow - it is a
deep copy, which is what MarkdownConverter#html_payload relies on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
Explain that body_format and content_hash are written to every document
even when the feature is disabled, and that both names stay reserved for
extraction rules, so the index shape never depends on the converter and
a later re-crawl can skip unchanged content. Document the circuit
breaker, the health check running for both crawl and urltest, and the
retry announcement now being a debug line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XMpa3bomxH6WW3GBBfmoVZ
@lleirborras
lleirborras changed the base branch from main to 1.0 September 4, 2026 12:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant