Sign inSign up

fredfeng033/greenfinger-api

By fredfeng033

•Updated 17 days ago

A distributed web crawler for the JVM. Files, a full text index and vectors in one pass.

Image
0

175

fredfeng033/greenfinger-api repository overview

⁠Greenfinger

One url in. A searchable archive out.

A distributed web crawler for the JVM. One pass writes plain files, a full text index and a vector collection. Every node runs the same jar, there is no coordinator to deploy, and the first crawl needs no database, no search server and no API key.

This image is one crawler node. For the console in front of it, see fredfeng033/greenfinger-ui⁠.

Watching a crawl


⁠Quick start

One node, nothing else running:

docker run -d --init --name greenfinger \
  -p 50080:50080 \
  -v greenfinger-data:/app/data \
  fredfeng033/greenfinger-api:latest

Then open http://localhost:50080. The jar serves the console itself, so a single container is a complete installation. Sign in with admin and admin123.

Give the container 2GB. Both the ONNX embedding models and the headless chromium live off heap, so a 1g node is killed the first time it meets either.

For three nodes with one address in front of them, see Running a cluster below.


⁠What it does

Crawls a whole siteTwo boundary rules that cannot be switched off: the registrable domain has to match, and every url has to sit under the start url. robots.txt on top
Three outputs, one passFiles on disk or in MinIO, a full text index in Lucene or Elasticsearch, vectors in Lucene, Qdrant, Weaviate or Elasticsearch
Rebuilds without recrawlingreplay rebuilds the index or the vectors from the files. The site is never touched again
Keeps picturesFrom <img>, srcset, <picture> and og:image, filtered by size and type. Identical bytes stored once
Reads past htmlDocument links recorded on every crawl. Text, markdown and csv parse out of the box once reading is switched on
Searches three waysBy words, by meaning, and by describing a picture. The embedding models run locally as ONNX
Resumable and versionedThe frontier is on disk. A rebuild opens a new version beside the old one, which keeps answering searches
DistributedOne url dispatched is one call to whichever node owns it. No central queue and no leader in the fetch path

⁠Tags

TagWhat it is
latestThe most recent release, pre-releases included
2.0.0An exact version
2.0The latest patch of that minor version
edgeBuilt by hand from main, between releases

Both linux/amd64 and linux/arm64. The same images are on GHCR as ghcr.io/paganini2008/greenfinger-api.


⁠Running a cluster

Nodes agreeing on the cluster name and port find each other and share one crawl. Every node is told the whole peer list including itself, so they can start in any order.

Save this as docker-compose.yml:

name: greenfinger

# Three crawler nodes and one console in front of them. Static addresses because the cluster
# setting is a list of ip addresses, so a node has to advertise something its peers can dial.
x-api: &api
  image: fredfeng033/greenfinger-api:latest
  restart: unless-stopped
  init: true      # reaps the headless browser's helper processes
  mem_limit: 2g   # the embedding models and chromium are both off heap
  environment: &api-env
    GF_CLUSTER_NAME: greenfinger
    GF_CLUSTER_PORT: '22020'
    GF_CLUSTER_HOSTS: 172.28.0.10,172.28.0.11,172.28.0.12
    # Tokens are signed, not looked up, so every node needs the same key or it rejects the
    # others' sign-ins and the console logs you out on two requests in three.
    GF_TOKEN_SECRET: "${GF_TOKEN_SECRET:?missing. Run - echo GF_TOKEN_SECRET=$$(openssl rand -base64 32) > .env}"
  networks:
    cluster:

services:
  api-1:
    <<: *api
    environment:
      <<: *api-env
      GF_CLUSTER_ADVERTISE_HOST: 172.28.0.10
    networks:
      cluster:
        ipv4_address: 172.28.0.10
    ports:
      - "50080:50080"   # one node published, for curl. The console does not need it
    volumes:
      - api-1-data:/app/data

  api-2:
    <<: *api
    environment:
      <<: *api-env
      GF_CLUSTER_ADVERTISE_HOST: 172.28.0.11
    networks:
      cluster:
        ipv4_address: 172.28.0.11
    volumes:
      - api-2-data:/app/data

  api-3:
    <<: *api
    environment:
      <<: *api-env
      GF_CLUSTER_ADVERTISE_HOST: 172.28.0.12
    networks:
      cluster:
        ipv4_address: 172.28.0.12
    volumes:
      - api-3-data:/app/data

  ui:
    image: fredfeng033/greenfinger-ui:latest
    restart: unless-stopped
    environment:
      GF_UPSTREAMS: 172.28.0.10:50080,172.28.0.11:50080,172.28.0.12:50080
    ports:
      - "9700:80"
    networks:
      cluster:
    depends_on: [api-1, api-2, api-3]

networks:
  cluster:
    ipam:
      config:
        - subnet: 172.28.0.0/16

volumes:
  api-1-data:
  api-2-data:
  api-3-data:

Then:

echo "GF_TOKEN_SECRET=$(openssl rand -base64 32)" > .env
docker compose up -d
open http://localhost:9700

Or take the annotated version, which has commented blocks for PostgreSQL, MySQL, Elasticsearch, Qdrant and MinIO:

curl -O https://raw.githubusercontent.com/paganini2008/greenfinger/main/backend/greenfinger-docker/docker-compose.yml

Four things in there are easy to get wrong:

Why
GF_TOKEN_SECRET, the same on every nodeTokens are signed rather than kept in a session. Without a shared key each node signs with its own and rejects the others', so the console logs you out on two requests in three
init: trueThe entry point execs java, so without it nothing reaps the browser's helper processes when a page is rendered and they stay as zombies for the life of the container
A volume per nodeEach node keeps its own complete copy. A container replaced without one starts from an empty database and the whole crawl has to be sent to it again
Static addressesThe cluster setting is a list of ip addresses, so a node has to advertise something its peers can dial. Service names do not work here

Give Docker 6GB for this. A warm node holds about 1.7GB. Short of it the host kernel kills whichever jvm it reaches first, and because that is the host rather than the container's own limit, docker reports neither an OOM nor a non zero exit: all you see is containers restarting with nothing in the log. One node and the console fit in 2GB.


⁠Configuration

Nothing below is required. H2 and an embedded Lucene index are the defaults.

⁠Metadata store
VariableDefaultDescription
GF_DB_URLan H2 file in the volumePostgreSQL, MySQL, SQL Server, Oracle, SQLite
GF_DB_USERNAMEempty
GF_DB_PASSWORDempty
GF_DB_DDLupdateUse none with the schema scripts on anything you cannot lose
⁠Full text and vectors
VariableDefaultDescription
GF_INDEX_PROVIDERluceneOr elasticsearch, with GF_ES_URIS
GF_ES_URISemptyComma separated
GF_LUCENE_ANALYZERstandardstandard, smartcn or cjk
GF_ES_ANALYZERstandardik_max_word needs the analysis-ik plugin
GF_VECTOR_STOREluceneOr elasticsearch, qdrant, weaviate
GF_QDRANT_URLempty
GF_WEAVIATE_URLempty
⁠Files
VariableDefaultDescription
GF_FILE_TARGETlocalOr minio. Object keys match the local paths exactly
GF_MINIO_ENDPOINTemptyAWS S3 and GCS publish S3 compatible endpoints too
GF_MINIO_ACCESS_KEYempty
GF_MINIO_SECRET_KEYempty
GF_MINIO_BUCKETgreenfinger
⁠Embeddings
VariableDefaultDescription
GF_EMBEDDING_PROVIDERlocalONNX in this process. Or ollama, openai
GF_PRELOAD_TEXT_MODELtrueLoaded at startup on a background thread
GF_PRELOAD_IMAGE_MODELtrue
GF_MODEL_DIR/app/data/modelsInside the volume, so they are downloaded once
OPENAI_API_KEYempty
⁠Cluster
VariableDefaultDescription
GF_CLUSTER_NAMEgreenfingerNodes agreeing on name and port find each other
GF_CLUSTER_PORT22000
GF_CLUSTER_HOSTS127.0.0.1The whole peer list, including this node
GF_CLUSTER_ADVERTISE_HOST127.0.0.1What peers dial. Set it per node
GF_CLUSTER_TRANSPORTNETTYOr the built in NIO
GF_TOKEN_SECRETgeneratedSet the same value on every node. Tokens are signed rather than kept in a session, so a node cannot verify one signed with another key. Left unset, each node generates its own and warns about it
⁠Crawl defaults

Every one of these is also a per catalog setting, and the catalog wins.

VariableDefaultDescription
GF_OUTPUT_TYPESfilefile,index,vector. file is always on
GF_EXTRACTORadaptiveadaptive, restclient, htmlunit, playwright, selenium
GF_MAX_FETCH_SIZE10000Saved pages before a crawl stops
GF_FETCH_INTERVAL1000Milliseconds between fetches, per node
GF_WORK_THREADS16
GF_DOCUMENTSfalseWhether linked documents are fetched and read
GF_MAX_VERSIONS10
⁠Server
VariableDefaultDescription
GF_SERVER_PORT50080
GF_SECURITY_ENABLEDtrue
GF_USERS_FILEconfig/api/users.xmlTwo roles, ADMIN and SUPPORT
GF_TOKEN_SECRETgeneratedSet it so a token survives a restart
GF_TOKEN_VALIDITY8h
GF_PROFILEdevdev or prod
JAVA_OPTS-XX:MaxRAMPercentage=70No fixed -Xmx, so the heap follows the container limit

⁠Volumes and ports

/app/dataEverything. system/ is working state, user/ is what was crawled. Use a volume
/app/configMount your own users.xml or application.yml over the packaged ones
50080The api and the console
22000Cluster traffic between nodes. Not for the outside world

⁠Memory

GF_MEMORY or --memory caps heap and off heap together, and both the ONNX embedding models and a headless chromium live off heap.

LimitVectorsA browser
1gOOMKilled after about 20 secondsOOMKilled as the browser starts
1.5gOOMKilled while the image model loadsnot reached
2gpassespasses

The 1.5g row is the one worth knowing about, because a node looks fine at 1.5g until the image model arrives: the text model loads, the node reports healthy, joins the cluster and takes work, and the kill comes a minute later on a thread called greenfinger-emb.

Give a node 2g, and give it more if it is doing both at once.

That is the container's own limit. The host needs the sum of them: a warm node settles around 1.7GB, so three nodes and the console want 6GB of Docker memory. When the host runs short it is the host kernel that kills a jvm, not the container's limit, and docker then reports neither an OOM nor a non zero exit. A container that restarts every minute or two with nothing in its log is this, every time.


⁠Own your own accounts

Two roles, in users.xml. ADMIN can crawl, edit, replay and delete. SUPPORT can read every page and is offered no button that writes.

docker run -d --init \
  -v ./users.xml:/app/config/api/users.xml:ro \
  -v greenfinger-data:/app/data \
  -p 50080:50080 \
  fredfeng033/greenfinger-api:latest
<users>
  <user name="admin"  password="..." roles="ADMIN"/>
  <user name="tester" password="..." roles="SUPPORT"/>
</users>

Apache License 2.0. Issues and pull requests at https://github.com/paganini2008/greenfinger⁠.

Tag summary

Content type

Image

Digest

sha256:44e7acce2…

Size

853.2 MB

Last updated

17 days ago

docker pull fredfeng033/greenfinger-api