beepboop
This commit is contained in:
3657
apps/web/scripts/.brave-progress.json
Normal file
3657
apps/web/scripts/.brave-progress.json
Normal file
File diff suppressed because it is too large
Load Diff
655
apps/web/scripts/.ddg-progress.json
Normal file
655
apps/web/scripts/.ddg-progress.json
Normal file
@@ -0,0 +1,655 @@
|
||||
{
|
||||
"processedIds": [
|
||||
"cucumber-horned-phytophthora-blight-cucurbits",
|
||||
"crabapple-fire-blight",
|
||||
"serviceberry-fire-blight",
|
||||
"chokecherry-fire-blight",
|
||||
"soybean-peanut-mottle",
|
||||
"sweet-potato-little-leaf-proliferation-disease",
|
||||
"sweet-potato-internal-cork",
|
||||
"winter-squash-viral-leaf-curl",
|
||||
"acorn-squash-viral-leaf-curl",
|
||||
"butternut-squash-viral-leaf-curl",
|
||||
"monstera-viral-leaf-curl",
|
||||
"monstera-wood-rot-decay",
|
||||
"pothos-downy-mildew-generic",
|
||||
"pothos-viral-leaf-curl",
|
||||
"pothos-wood-rot-decay",
|
||||
"peace-lily-bacterial-soft-rot",
|
||||
"peace-lily-downy-mildew-generic",
|
||||
"peace-lily-viral-leaf-curl",
|
||||
"peace-lily-wood-rot-decay",
|
||||
"philodendron-bacterial-soft-rot",
|
||||
"philodendron-downy-mildew-generic",
|
||||
"philodendron-viral-leaf-curl",
|
||||
"philodendron-wood-rot-decay",
|
||||
"anthurium-bacterial-soft-rot",
|
||||
"anthurium-downy-mildew-generic",
|
||||
"anthurium-viral-leaf-curl",
|
||||
"anthurium-wood-rot-decay",
|
||||
"alocasia-bacterial-soft-rot",
|
||||
"alocasia-downy-mildew-generic",
|
||||
"alocasia-viral-leaf-curl",
|
||||
"alocasia-wood-rot-decay",
|
||||
"caladium-bacterial-soft-rot",
|
||||
"caladium-downy-mildew-generic",
|
||||
"caladium-viral-leaf-curl",
|
||||
"caladium-wood-rot-decay",
|
||||
"aglaonema-bacterial-soft-rot",
|
||||
"aglaonema-downy-mildew-generic",
|
||||
"aglaonema-viral-leaf-curl",
|
||||
"aglaonema-wood-rot-decay",
|
||||
"dieffenbachia-bacterial-soft-rot",
|
||||
"dieffenbachia-downy-mildew-generic",
|
||||
"dieffenbachia-viral-leaf-curl",
|
||||
"dieffenbachia-wood-rot-decay",
|
||||
"spathiphyllum-bacterial-soft-rot",
|
||||
"spathiphyllum-downy-mildew-generic",
|
||||
"spathiphyllum-viral-leaf-curl",
|
||||
"spathiphyllum-wood-rot-decay",
|
||||
"asparagus-bacterial-soft-rot",
|
||||
"asparagus-downy-mildew-generic",
|
||||
"asparagus-viral-leaf-curl",
|
||||
"asparagus-wood-rot-decay",
|
||||
"snake-plant-bacterial-soft-rot",
|
||||
"snake-plant-downy-mildew-generic",
|
||||
"snake-plant-viral-leaf-curl",
|
||||
"snake-plant-wood-rot-decay",
|
||||
"yucca-bacterial-soft-rot",
|
||||
"yucca-downy-mildew-generic",
|
||||
"yucca-viral-leaf-curl",
|
||||
"yucca-wood-rot-decay",
|
||||
"dracaena-bacterial-soft-rot",
|
||||
"dracaena-downy-mildew-generic",
|
||||
"dracaena-viral-leaf-curl",
|
||||
"dracaena-wood-rot-decay",
|
||||
"lily-of-the-valley-bacterial-soft-rot",
|
||||
"lily-of-the-valley-downy-mildew-generic",
|
||||
"lily-of-the-valley-viral-leaf-curl",
|
||||
"lily-of-the-valley-wood-rot-decay",
|
||||
"hosta-bacterial-soft-rot",
|
||||
"hosta-downy-mildew-generic",
|
||||
"hosta-viral-leaf-curl",
|
||||
"hosta-wood-rot-decay",
|
||||
"orchid-phalaenopsis-bacterial-soft-rot",
|
||||
"orchid-phalaenopsis-downy-mildew-generic",
|
||||
"orchid-phalaenopsis-viral-leaf-curl",
|
||||
"orchid-phalaenopsis-wood-rot-decay",
|
||||
"orchid-cattleya-bacterial-soft-rot",
|
||||
"orchid-cattleya-downy-mildew-generic",
|
||||
"orchid-cattleya-viral-leaf-curl",
|
||||
"orchid-cattleya-wood-rot-decay",
|
||||
"orchid-dendrobium-bacterial-soft-rot",
|
||||
"orchid-dendrobium-downy-mildew-generic",
|
||||
"orchid-dendrobium-viral-leaf-curl",
|
||||
"orchid-dendrobium-wood-rot-decay",
|
||||
"orchid-oncidium-bacterial-soft-rot",
|
||||
"orchid-oncidium-downy-mildew-generic",
|
||||
"orchid-oncidium-viral-leaf-curl",
|
||||
"orchid-oncidium-wood-rot-decay",
|
||||
"vanilla-bacterial-soft-rot",
|
||||
"vanilla-downy-mildew-generic",
|
||||
"vanilla-viral-leaf-curl",
|
||||
"vanilla-wood-rot-decay",
|
||||
"prickly-pear-bacterial-soft-rot",
|
||||
"prickly-pear-downy-mildew-generic",
|
||||
"prickly-pear-viral-leaf-curl",
|
||||
"prickly-pear-wood-rot-decay",
|
||||
"barrel-cactus-bacterial-soft-rot",
|
||||
"barrel-cactus-downy-mildew-generic",
|
||||
"barrel-cactus-viral-leaf-curl",
|
||||
"barrel-cactus-wood-rot-decay",
|
||||
"christmas-cactus-bacterial-soft-rot",
|
||||
"christmas-cactus-downy-mildew-generic",
|
||||
"christmas-cactus-viral-leaf-curl",
|
||||
"christmas-cactus-wood-rot-decay",
|
||||
"saguaro-bacterial-soft-rot",
|
||||
"saguaro-downy-mildew-generic",
|
||||
"saguaro-viral-leaf-curl",
|
||||
"saguaro-wood-rot-decay",
|
||||
"aloe-vera-bacterial-soft-rot",
|
||||
"aloe-vera-downy-mildew-generic",
|
||||
"aloe-vera-viral-leaf-curl",
|
||||
"aloe-vera-wood-rot-decay",
|
||||
"agave-bacterial-soft-rot",
|
||||
"agave-downy-mildew-generic",
|
||||
"agave-viral-leaf-curl",
|
||||
"agave-wood-rot-decay",
|
||||
"echeveria-bacterial-soft-rot",
|
||||
"echeveria-downy-mildew-generic",
|
||||
"echeveria-viral-leaf-curl",
|
||||
"echeveria-wood-rot-decay",
|
||||
"jade-plant-bacterial-soft-rot",
|
||||
"jade-plant-downy-mildew-generic",
|
||||
"jade-plant-viral-leaf-curl",
|
||||
"jade-plant-wood-rot-decay",
|
||||
"sedum-bacterial-soft-rot",
|
||||
"sedum-downy-mildew-generic",
|
||||
"sedum-viral-leaf-curl",
|
||||
"sedum-wood-rot-decay",
|
||||
"haworthia-bacterial-soft-rot",
|
||||
"haworthia-downy-mildew-generic",
|
||||
"haworthia-viral-leaf-curl",
|
||||
"haworthia-wood-rot-decay",
|
||||
"poinsettia-bacterial-soft-rot",
|
||||
"poinsettia-downy-mildew-generic",
|
||||
"poinsettia-viral-leaf-curl",
|
||||
"poinsettia-wood-rot-decay",
|
||||
"cassava-bacterial-soft-rot",
|
||||
"cassava-downy-mildew-generic",
|
||||
"cassava-viral-leaf-curl",
|
||||
"cassava-wood-rot-decay",
|
||||
"castor-bean-bacterial-soft-rot",
|
||||
"castor-bean-downy-mildew-generic",
|
||||
"castor-bean-viral-leaf-curl",
|
||||
"castor-bean-wood-rot-decay",
|
||||
"crown-of-thorns-bacterial-soft-rot",
|
||||
"crown-of-thorns-downy-mildew-generic",
|
||||
"crown-of-thorns-viral-leaf-curl",
|
||||
"crown-of-thorns-wood-rot-decay",
|
||||
"orange-bacterial-soft-rot",
|
||||
"orange-downy-mildew-generic",
|
||||
"orange-viral-leaf-curl",
|
||||
"orange-wood-rot-decay",
|
||||
"lemon-bacterial-soft-rot",
|
||||
"lemon-downy-mildew-generic",
|
||||
"lemon-viral-leaf-curl",
|
||||
"lemon-wood-rot-decay",
|
||||
"lime-bacterial-soft-rot",
|
||||
"lime-downy-mildew-generic",
|
||||
"lime-viral-leaf-curl",
|
||||
"lime-wood-rot-decay",
|
||||
"grapefruit-bacterial-soft-rot",
|
||||
"grapefruit-downy-mildew-generic",
|
||||
"grapefruit-viral-leaf-curl",
|
||||
"grapefruit-wood-rot-decay",
|
||||
"mandarin-bacterial-soft-rot",
|
||||
"mandarin-downy-mildew-generic",
|
||||
"mandarin-viral-leaf-curl",
|
||||
"mandarin-wood-rot-decay",
|
||||
"kumquat-bacterial-soft-rot",
|
||||
"kumquat-downy-mildew-generic",
|
||||
"kumquat-viral-leaf-curl",
|
||||
"kumquat-wood-rot-decay",
|
||||
"grape-bacterial-soft-rot",
|
||||
"grape-downy-mildew-generic",
|
||||
"grape-viral-leaf-curl",
|
||||
"grape-wood-rot-decay",
|
||||
"muscadine-bacterial-soft-rot",
|
||||
"muscadine-downy-mildew-generic",
|
||||
"muscadine-viral-leaf-curl",
|
||||
"muscadine-wood-rot-decay",
|
||||
"banana-bacterial-soft-rot",
|
||||
"banana-downy-mildew-generic",
|
||||
"banana-viral-leaf-curl",
|
||||
"banana-wood-rot-decay",
|
||||
"plantain-bacterial-soft-rot",
|
||||
"plantain-downy-mildew-generic",
|
||||
"plantain-viral-leaf-curl",
|
||||
"plantain-wood-rot-decay",
|
||||
"bird-of-paradise-bacterial-soft-rot",
|
||||
"bird-of-paradise-downy-mildew-generic",
|
||||
"bird-of-paradise-viral-leaf-curl",
|
||||
"bird-of-paradise-wood-rot-decay",
|
||||
"avocado-bacterial-soft-rot",
|
||||
"avocado-downy-mildew-generic",
|
||||
"avocado-viral-leaf-curl",
|
||||
"avocado-wood-rot-decay",
|
||||
"cinnamon-bacterial-soft-rot",
|
||||
"cinnamon-downy-mildew-generic",
|
||||
"cinnamon-viral-leaf-curl",
|
||||
"cinnamon-wood-rot-decay",
|
||||
"bay-laurel-bacterial-soft-rot",
|
||||
"bay-laurel-downy-mildew-generic",
|
||||
"bay-laurel-viral-leaf-curl",
|
||||
"bay-laurel-wood-rot-decay",
|
||||
"cocoa-bacterial-soft-rot",
|
||||
"cocoa-downy-mildew-generic",
|
||||
"cocoa-viral-leaf-curl",
|
||||
"cocoa-wood-rot-decay",
|
||||
"cotton-bacterial-soft-rot",
|
||||
"cotton-downy-mildew-generic",
|
||||
"cotton-viral-leaf-curl",
|
||||
"cotton-wood-rot-decay",
|
||||
"okra-bacterial-soft-rot",
|
||||
"okra-downy-mildew-generic",
|
||||
"okra-viral-leaf-curl",
|
||||
"okra-wood-rot-decay",
|
||||
"hibiscus-bacterial-soft-rot",
|
||||
"hibiscus-downy-mildew-generic",
|
||||
"hibiscus-viral-leaf-curl",
|
||||
"hibiscus-wood-rot-decay",
|
||||
"hollyhock-bacterial-soft-rot",
|
||||
"hollyhock-downy-mildew-generic",
|
||||
"hollyhock-viral-leaf-curl",
|
||||
"hollyhock-wood-rot-decay",
|
||||
"baobab-bacterial-soft-rot",
|
||||
"baobab-downy-mildew-generic",
|
||||
"baobab-viral-leaf-curl",
|
||||
"baobab-wood-rot-decay",
|
||||
"durian-bacterial-soft-rot",
|
||||
"durian-downy-mildew-generic",
|
||||
"durian-viral-leaf-curl",
|
||||
"durian-wood-rot-decay",
|
||||
"coconut-bacterial-soft-rot",
|
||||
"coconut-downy-mildew-generic",
|
||||
"coconut-viral-leaf-curl",
|
||||
"coconut-wood-rot-decay",
|
||||
"oil-palm-bacterial-soft-rot",
|
||||
"oil-palm-downy-mildew-generic",
|
||||
"oil-palm-viral-leaf-curl",
|
||||
"oil-palm-wood-rot-decay",
|
||||
"date-palm-bacterial-soft-rot",
|
||||
"date-palm-downy-mildew-generic",
|
||||
"date-palm-viral-leaf-curl",
|
||||
"date-palm-wood-rot-decay",
|
||||
"palm-areca-bacterial-soft-rot",
|
||||
"palm-areca-downy-mildew-generic",
|
||||
"palm-areca-viral-leaf-curl",
|
||||
"palm-areca-wood-rot-decay",
|
||||
"palm-parlor-bacterial-soft-rot",
|
||||
"palm-parlor-downy-mildew-generic",
|
||||
"palm-parlor-viral-leaf-curl",
|
||||
"palm-parlor-wood-rot-decay",
|
||||
"palm-kentia-bacterial-soft-rot",
|
||||
"palm-kentia-downy-mildew-generic",
|
||||
"palm-kentia-viral-leaf-curl",
|
||||
"palm-kentia-wood-rot-decay",
|
||||
"mango-bacterial-soft-rot",
|
||||
"mango-downy-mildew-generic",
|
||||
"mango-viral-leaf-curl",
|
||||
"mango-wood-rot-decay",
|
||||
"cashew-bacterial-soft-rot",
|
||||
"cashew-downy-mildew-generic",
|
||||
"cashew-viral-leaf-curl",
|
||||
"cashew-wood-rot-decay",
|
||||
"pistachio-bacterial-soft-rot",
|
||||
"pistachio-downy-mildew-generic",
|
||||
"pistachio-viral-leaf-curl",
|
||||
"pistachio-wood-rot-decay",
|
||||
"poison-ivy-bacterial-soft-rot",
|
||||
"poison-ivy-downy-mildew-generic",
|
||||
"poison-ivy-viral-leaf-curl",
|
||||
"poison-ivy-wood-rot-decay",
|
||||
"coffee-bacterial-soft-rot",
|
||||
"coffee-downy-mildew-generic",
|
||||
"coffee-viral-leaf-curl",
|
||||
"coffee-wood-rot-decay",
|
||||
"gardenia-bacterial-soft-rot",
|
||||
"gardenia-downy-mildew-generic",
|
||||
"gardenia-viral-leaf-curl",
|
||||
"gardenia-wood-rot-decay",
|
||||
"tea-bacterial-soft-rot",
|
||||
"tea-downy-mildew-generic",
|
||||
"tea-viral-leaf-curl",
|
||||
"tea-wood-rot-decay",
|
||||
"camellia-bacterial-soft-rot",
|
||||
"camellia-downy-mildew-generic",
|
||||
"camellia-viral-leaf-curl",
|
||||
"camellia-wood-rot-decay",
|
||||
"pine-bacterial-soft-rot",
|
||||
"pine-downy-mildew-generic",
|
||||
"pine-viral-leaf-curl",
|
||||
"pine-wood-rot-decay",
|
||||
"spruce-bacterial-soft-rot",
|
||||
"spruce-downy-mildew-generic",
|
||||
"spruce-viral-leaf-curl",
|
||||
"spruce-wood-rot-decay",
|
||||
"fir-bacterial-soft-rot",
|
||||
"fir-downy-mildew-generic",
|
||||
"fir-viral-leaf-curl",
|
||||
"fir-wood-rot-decay",
|
||||
"cedar-bacterial-soft-rot",
|
||||
"cedar-downy-mildew-generic",
|
||||
"cedar-viral-leaf-curl",
|
||||
"cedar-wood-rot-decay",
|
||||
"juniper-bacterial-soft-rot",
|
||||
"juniper-downy-mildew-generic",
|
||||
"juniper-viral-leaf-curl",
|
||||
"juniper-wood-rot-decay",
|
||||
"cypress-bacterial-soft-rot",
|
||||
"cypress-downy-mildew-generic",
|
||||
"cypress-viral-leaf-curl",
|
||||
"cypress-wood-rot-decay",
|
||||
"arborvitae-bacterial-soft-rot",
|
||||
"arborvitae-downy-mildew-generic",
|
||||
"arborvitae-viral-leaf-curl",
|
||||
"arborvitae-wood-rot-decay",
|
||||
"oak-bacterial-soft-rot",
|
||||
"oak-downy-mildew-generic",
|
||||
"oak-viral-leaf-curl",
|
||||
"oak-wood-rot-decay",
|
||||
"beech-bacterial-soft-rot",
|
||||
"beech-downy-mildew-generic",
|
||||
"beech-viral-leaf-curl",
|
||||
"beech-wood-rot-decay",
|
||||
"chestnut-bacterial-soft-rot",
|
||||
"chestnut-downy-mildew-generic",
|
||||
"chestnut-viral-leaf-curl",
|
||||
"chestnut-wood-rot-decay",
|
||||
"fiddle-leaf-fig-bacterial-soft-rot",
|
||||
"fiddle-leaf-fig-downy-mildew-generic",
|
||||
"fiddle-leaf-fig-viral-leaf-curl",
|
||||
"fiddle-leaf-fig-wood-rot-decay",
|
||||
"rubber-tree-bacterial-soft-rot",
|
||||
"rubber-tree-downy-mildew-generic",
|
||||
"rubber-tree-viral-leaf-curl",
|
||||
"rubber-tree-wood-rot-decay",
|
||||
"weeping-fig-bacterial-soft-rot",
|
||||
"weeping-fig-downy-mildew-generic",
|
||||
"weeping-fig-viral-leaf-curl",
|
||||
"weeping-fig-wood-rot-decay",
|
||||
"fig-bacterial-soft-rot",
|
||||
"fig-downy-mildew-generic",
|
||||
"fig-viral-leaf-curl",
|
||||
"fig-wood-rot-decay",
|
||||
"mulberry-bacterial-soft-rot",
|
||||
"mulberry-downy-mildew-generic",
|
||||
"mulberry-viral-leaf-curl",
|
||||
"mulberry-wood-rot-decay",
|
||||
"breadfruit-bacterial-soft-rot",
|
||||
"breadfruit-downy-mildew-generic",
|
||||
"breadfruit-viral-leaf-curl",
|
||||
"breadfruit-wood-rot-decay",
|
||||
"eucalyptus-bacterial-soft-rot",
|
||||
"eucalyptus-downy-mildew-generic",
|
||||
"eucalyptus-viral-leaf-curl",
|
||||
"eucalyptus-wood-rot-decay",
|
||||
"guava-bacterial-soft-rot",
|
||||
"guava-downy-mildew-generic",
|
||||
"guava-viral-leaf-curl",
|
||||
"guava-wood-rot-decay",
|
||||
"clove-bacterial-soft-rot",
|
||||
"clove-downy-mildew-generic",
|
||||
"clove-viral-leaf-curl",
|
||||
"clove-wood-rot-decay",
|
||||
"pineapple-bacterial-soft-rot",
|
||||
"pineapple-downy-mildew-generic",
|
||||
"pineapple-viral-leaf-curl",
|
||||
"pineapple-wood-rot-decay",
|
||||
"bromeliad-bacterial-soft-rot",
|
||||
"bromeliad-downy-mildew-generic",
|
||||
"bromeliad-viral-leaf-curl",
|
||||
"bromeliad-wood-rot-decay",
|
||||
"spanish-moss-bacterial-soft-rot",
|
||||
"spanish-moss-downy-mildew-generic",
|
||||
"spanish-moss-viral-leaf-curl",
|
||||
"spanish-moss-wood-rot-decay",
|
||||
"sweet-potato-bacterial-soft-rot",
|
||||
"sweet-potato-downy-mildew-generic",
|
||||
"sweet-potato-viral-leaf-curl",
|
||||
"sweet-potato-wood-rot-decay",
|
||||
"morning-glory-bacterial-soft-rot",
|
||||
"morning-glory-downy-mildew-generic",
|
||||
"morning-glory-viral-leaf-curl",
|
||||
"morning-glory-wood-rot-decay",
|
||||
"spinach-downy-mildew-generic",
|
||||
"spinach-viral-leaf-curl",
|
||||
"spinach-wood-rot-decay",
|
||||
"swiss-chard-bacterial-soft-rot",
|
||||
"swiss-chard-downy-mildew-generic",
|
||||
"swiss-chard-viral-leaf-curl",
|
||||
"swiss-chard-wood-rot-decay",
|
||||
"beet-bacterial-soft-rot",
|
||||
"beet-downy-mildew-generic",
|
||||
"beet-viral-leaf-curl",
|
||||
"beet-wood-rot-decay",
|
||||
"quinoa-bacterial-soft-rot",
|
||||
"quinoa-downy-mildew-generic",
|
||||
"quinoa-viral-leaf-curl",
|
||||
"quinoa-wood-rot-decay",
|
||||
"amaranth-bacterial-soft-rot",
|
||||
"amaranth-downy-mildew-generic",
|
||||
"amaranth-viral-leaf-curl",
|
||||
"amaranth-wood-rot-decay",
|
||||
"rhubarb-bacterial-soft-rot",
|
||||
"rhubarb-downy-mildew-generic",
|
||||
"rhubarb-viral-leaf-curl",
|
||||
"rhubarb-wood-rot-decay",
|
||||
"buckwheat-bacterial-soft-rot",
|
||||
"buckwheat-downy-mildew-generic",
|
||||
"buckwheat-viral-leaf-curl",
|
||||
"buckwheat-wood-rot-decay",
|
||||
"papaya-bacterial-soft-rot",
|
||||
"papaya-downy-mildew-generic",
|
||||
"papaya-viral-leaf-curl",
|
||||
"papaya-wood-rot-decay",
|
||||
"olive-bacterial-soft-rot",
|
||||
"olive-downy-mildew-generic",
|
||||
"olive-viral-leaf-curl",
|
||||
"olive-wood-rot-decay",
|
||||
"jasmine-bacterial-soft-rot",
|
||||
"jasmine-downy-mildew-generic",
|
||||
"jasmine-viral-leaf-curl",
|
||||
"jasmine-wood-rot-decay",
|
||||
"lilac-bacterial-soft-rot",
|
||||
"lilac-downy-mildew-generic",
|
||||
"lilac-viral-leaf-curl",
|
||||
"lilac-wood-rot-decay",
|
||||
"ash-bacterial-soft-rot",
|
||||
"ash-downy-mildew-generic",
|
||||
"ash-viral-leaf-curl",
|
||||
"ash-wood-rot-decay",
|
||||
"hops-bacterial-soft-rot",
|
||||
"hops-downy-mildew-generic",
|
||||
"hops-viral-leaf-curl",
|
||||
"hops-wood-rot-decay",
|
||||
"hemp-bacterial-soft-rot",
|
||||
"hemp-downy-mildew-generic",
|
||||
"hemp-viral-leaf-curl",
|
||||
"hemp-wood-rot-decay",
|
||||
"fern-boston-bacterial-soft-rot",
|
||||
"fern-boston-downy-mildew-generic",
|
||||
"fern-boston-viral-leaf-curl",
|
||||
"fern-boston-wood-rot-decay",
|
||||
"fern-maidenhair-bacterial-soft-rot",
|
||||
"fern-maidenhair-downy-mildew-generic",
|
||||
"fern-maidenhair-viral-leaf-curl",
|
||||
"fern-maidenhair-wood-rot-decay",
|
||||
"spider-plant-bacterial-soft-rot",
|
||||
"spider-plant-downy-mildew-generic",
|
||||
"spider-plant-viral-leaf-curl",
|
||||
"spider-plant-wood-rot-decay",
|
||||
"zz-plant-bacterial-soft-rot",
|
||||
"zz-plant-downy-mildew-generic",
|
||||
"zz-plant-viral-leaf-curl",
|
||||
"zz-plant-wood-rot-decay",
|
||||
"prayer-plant-bacterial-soft-rot",
|
||||
"prayer-plant-downy-mildew-generic",
|
||||
"prayer-plant-viral-leaf-curl",
|
||||
"prayer-plant-wood-rot-decay",
|
||||
"calathea-bacterial-soft-rot",
|
||||
"calathea-downy-mildew-generic",
|
||||
"calathea-viral-leaf-curl",
|
||||
"calathea-wood-rot-decay",
|
||||
"pilea-bacterial-soft-rot",
|
||||
"pilea-downy-mildew-generic",
|
||||
"pilea-viral-leaf-curl",
|
||||
"pilea-wood-rot-decay",
|
||||
"tradescantia-bacterial-soft-rot",
|
||||
"tradescantia-downy-mildew-generic",
|
||||
"tradescantia-viral-leaf-curl",
|
||||
"tradescantia-wood-rot-decay",
|
||||
"succulent-echeveria-bacterial-soft-rot",
|
||||
"succulent-echeveria-downy-mildew-generic",
|
||||
"succulent-echeveria-viral-leaf-curl",
|
||||
"succulent-echeveria-wood-rot-decay",
|
||||
"money-tree-bacterial-soft-rot",
|
||||
"money-tree-downy-mildew-generic",
|
||||
"money-tree-viral-leaf-curl",
|
||||
"money-tree-wood-rot-decay",
|
||||
"palm-cat-bacterial-soft-rot",
|
||||
"palm-cat-downy-mildew-generic",
|
||||
"palm-cat-viral-leaf-curl",
|
||||
"palm-cat-wood-rot-decay",
|
||||
"ficus-altissima-bacterial-soft-rot",
|
||||
"ficus-altissima-downy-mildew-generic",
|
||||
"ficus-altissima-viral-leaf-curl",
|
||||
"ficus-altissima-wood-rot-decay",
|
||||
"string-of-pearls-bacterial-soft-rot",
|
||||
"string-of-pearls-downy-mildew-generic",
|
||||
"string-of-pearls-viral-leaf-curl",
|
||||
"string-of-pearls-wood-rot-decay",
|
||||
"burros-tail-bacterial-soft-rot",
|
||||
"burros-tail-downy-mildew-generic",
|
||||
"burros-tail-viral-leaf-curl",
|
||||
"burros-tail-wood-rot-decay",
|
||||
"snake-plant-masoniana-bacterial-soft-rot",
|
||||
"snake-plant-masoniana-downy-mildew-generic",
|
||||
"snake-plant-masoniana-viral-leaf-curl",
|
||||
"snake-plant-masoniana-wood-rot-decay",
|
||||
"passion-fruit-bacterial-soft-rot",
|
||||
"passion-fruit-downy-mildew-generic",
|
||||
"passion-fruit-viral-leaf-curl",
|
||||
"passion-fruit-wood-rot-decay",
|
||||
"kiwi-bacterial-soft-rot",
|
||||
"kiwi-downy-mildew-generic",
|
||||
"kiwi-viral-leaf-curl",
|
||||
"kiwi-wood-rot-decay",
|
||||
"lychee-bacterial-soft-rot",
|
||||
"lychee-downy-mildew-generic",
|
||||
"lychee-viral-leaf-curl",
|
||||
"lychee-wood-rot-decay",
|
||||
"rambutan-bacterial-soft-rot",
|
||||
"rambutan-downy-mildew-generic",
|
||||
"rambutan-viral-leaf-curl",
|
||||
"rambutan-wood-rot-decay",
|
||||
"jackfruit-bacterial-soft-rot",
|
||||
"jackfruit-downy-mildew-generic",
|
||||
"jackfruit-viral-leaf-curl",
|
||||
"jackfruit-wood-rot-decay",
|
||||
"dragon-fruit-bacterial-soft-rot",
|
||||
"dragon-fruit-downy-mildew-generic",
|
||||
"dragon-fruit-viral-leaf-curl",
|
||||
"dragon-fruit-wood-rot-decay",
|
||||
"pomegranate-bacterial-soft-rot",
|
||||
"pomegranate-downy-mildew-generic",
|
||||
"pomegranate-viral-leaf-curl",
|
||||
"pomegranate-wood-rot-decay",
|
||||
"persimmon-bacterial-soft-rot",
|
||||
"persimmon-downy-mildew-generic",
|
||||
"persimmon-viral-leaf-curl",
|
||||
"persimmon-wood-rot-decay",
|
||||
"tulip-bacterial-soft-rot",
|
||||
"tulip-downy-mildew-generic",
|
||||
"tulip-viral-leaf-curl",
|
||||
"tulip-wood-rot-decay",
|
||||
"daffodil-bacterial-soft-rot",
|
||||
"daffodil-downy-mildew-generic",
|
||||
"daffodil-viral-leaf-curl",
|
||||
"daffodil-wood-rot-decay",
|
||||
"iris-bacterial-soft-rot",
|
||||
"iris-downy-mildew-generic",
|
||||
"iris-viral-leaf-curl",
|
||||
"iris-wood-rot-decay",
|
||||
"lily-bacterial-soft-rot",
|
||||
"lily-downy-mildew-generic",
|
||||
"lily-viral-leaf-curl",
|
||||
"lily-wood-rot-decay",
|
||||
"peony-bacterial-soft-rot",
|
||||
"peony-downy-mildew-generic",
|
||||
"peony-viral-leaf-curl",
|
||||
"peony-wood-rot-decay",
|
||||
"hydrangea-bacterial-soft-rot",
|
||||
"hydrangea-downy-mildew-generic",
|
||||
"hydrangea-viral-leaf-curl",
|
||||
"hydrangea-wood-rot-decay",
|
||||
"rhododendron-bacterial-soft-rot",
|
||||
"rhododendron-downy-mildew-generic",
|
||||
"rhododendron-viral-leaf-curl",
|
||||
"rhododendron-wood-rot-decay",
|
||||
"azalea-bacterial-soft-rot",
|
||||
"azalea-downy-mildew-generic",
|
||||
"azalea-viral-leaf-curl",
|
||||
"azalea-wood-rot-decay",
|
||||
"magnolia-bacterial-soft-rot",
|
||||
"magnolia-downy-mildew-generic",
|
||||
"magnolia-viral-leaf-curl",
|
||||
"magnolia-wood-rot-decay",
|
||||
"dogwood-bacterial-soft-rot",
|
||||
"dogwood-downy-mildew-generic",
|
||||
"dogwood-viral-leaf-curl",
|
||||
"dogwood-wood-rot-decay",
|
||||
"maple-bacterial-soft-rot",
|
||||
"maple-downy-mildew-generic",
|
||||
"maple-viral-leaf-curl",
|
||||
"maple-wood-rot-decay",
|
||||
"birch-bacterial-soft-rot",
|
||||
"birch-downy-mildew-generic",
|
||||
"birch-viral-leaf-curl",
|
||||
"birch-wood-rot-decay",
|
||||
"elm-bacterial-soft-rot",
|
||||
"elm-downy-mildew-generic",
|
||||
"elm-viral-leaf-curl",
|
||||
"elm-wood-rot-decay",
|
||||
"willow-bacterial-soft-rot",
|
||||
"willow-downy-mildew-generic",
|
||||
"willow-viral-leaf-curl",
|
||||
"willow-wood-rot-decay",
|
||||
"poplar-bacterial-soft-rot",
|
||||
"poplar-downy-mildew-generic",
|
||||
"poplar-viral-leaf-curl",
|
||||
"poplar-wood-rot-decay",
|
||||
"sycamore-bacterial-soft-rot",
|
||||
"sycamore-downy-mildew-generic",
|
||||
"sycamore-viral-leaf-curl",
|
||||
"sycamore-wood-rot-decay",
|
||||
"hickory-bacterial-soft-rot",
|
||||
"hickory-downy-mildew-generic",
|
||||
"hickory-viral-leaf-curl",
|
||||
"hickory-wood-rot-decay",
|
||||
"pecan-bacterial-soft-rot",
|
||||
"pecan-downy-mildew-generic",
|
||||
"pecan-viral-leaf-curl",
|
||||
"pecan-wood-rot-decay",
|
||||
"walnut-bacterial-soft-rot",
|
||||
"walnut-downy-mildew-generic",
|
||||
"walnut-viral-leaf-curl",
|
||||
"walnut-wood-rot-decay",
|
||||
"fern-staghorn-root-rot-pythiumphytophthora",
|
||||
"fern-staghorn-damping-off",
|
||||
"fern-staghorn-gray-mold-botrytis-blight",
|
||||
"fern-staghorn-mosaic-virus",
|
||||
"fern-staghorn-wilt-fusarium-or-verticillium",
|
||||
"fern-staghorn-root-knot-nematode",
|
||||
"fern-staghorn-canker-stembranch",
|
||||
"fern-staghorn-bacterial-soft-rot",
|
||||
"fern-staghorn-downy-mildew-generic",
|
||||
"fern-staghorn-viral-leaf-curl",
|
||||
"fern-staghorn-wood-rot-decay",
|
||||
"fern-birds-nest-root-rot-pythiumphytophthora",
|
||||
"fern-birds-nest-damping-off",
|
||||
"fern-birds-nest-gray-mold-botrytis-blight",
|
||||
"fern-birds-nest-mosaic-virus",
|
||||
"fern-birds-nest-wilt-fusarium-or-verticillium",
|
||||
"fern-birds-nest-root-knot-nematode",
|
||||
"fern-birds-nest-canker-stembranch",
|
||||
"fern-birds-nest-bacterial-soft-rot",
|
||||
"fern-birds-nest-downy-mildew-generic",
|
||||
"fern-birds-nest-viral-leaf-curl",
|
||||
"fern-birds-nest-wood-rot-decay",
|
||||
"philodendron-brasil-root-rot-aroidsoverwatering",
|
||||
"philodendron-brasil-root-rot-pythiumphytophthora",
|
||||
"philodendron-brasil-damping-off",
|
||||
"philodendron-brasil-gray-mold-botrytis-blight",
|
||||
"philodendron-brasil-mosaic-virus",
|
||||
"philodendron-brasil-wilt-fusarium-or-verticillium",
|
||||
"philodendron-brasil-root-knot-nematode",
|
||||
"philodendron-brasil-canker-stembranch",
|
||||
"philodendron-brasil-bacterial-soft-rot",
|
||||
"philodendron-brasil-downy-mildew-generic",
|
||||
"philodendron-brasil-viral-leaf-curl",
|
||||
"philodendron-brasil-wood-rot-decay",
|
||||
"philodendron-monstera-root-rot-aroidsoverwatering",
|
||||
"philodendron-monstera-root-rot-pythiumphytophthora",
|
||||
"philodendron-monstera-damping-off",
|
||||
"philodendron-monstera-gray-mold-botrytis-blight",
|
||||
"philodendron-monstera-mosaic-virus",
|
||||
"philodendron-monstera-wilt-fusarium-or-verticillium",
|
||||
"philodendron-monstera-root-knot-nematode",
|
||||
"philodendron-monstera-canker-stembranch",
|
||||
"philodendron-monstera-bacterial-soft-rot",
|
||||
"philodendron-monstera-downy-mildew-generic"
|
||||
],
|
||||
"totalFound": 650
|
||||
}
|
||||
@@ -293,5 +293,25 @@
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/6/68/Laboratory_outlines_in_plant_pathology_%28IA_laboratoryoutlin00whet%29.pdf/page1-500px-Laboratory_outlines_in_plant_pathology_%28IA_laboratoryoutlin00whet%29.pdf.jpg",
|
||||
"source": "commons",
|
||||
"quality": "good"
|
||||
},
|
||||
"lettuce-tip-burn": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/f/fc/Medical_Heritage_Library_%28IA_63841040R.nlm.nih.gov%29.pdf/page1-500px-Medical_Heritage_Library_%28IA_63841040R.nlm.nih.gov%29.pdf.jpg",
|
||||
"source": "commons",
|
||||
"quality": "good"
|
||||
},
|
||||
"cabbage-downy-mildew": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/7/73/Chinakohl_3_Falscher_Mehltau-Brand.jpg/960px-Chinakohl_3_Falscher_Mehltau-Brand.jpg",
|
||||
"source": "commons",
|
||||
"quality": "good"
|
||||
},
|
||||
"cabbage-fusarium-yellows": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/e/e1/Index_of_organisms_and_non-parasitic_diseases_in_Plant_disease_reporter%2C_supplements_XXXI-XXXVII%2C_1924_%28IA_indexoforganisms38vanm%29.pdf/page1-960px-Index_of_organisms_and_non-parasitic_diseases_in_Plant_disease_reporter%2C_supplements_XXXI-XXXVII%2C_1924_%28IA_indexoforganisms38vanm%29.pdf.jpg",
|
||||
"source": "commons",
|
||||
"quality": "good"
|
||||
},
|
||||
"sunflower-downy-mildew": {
|
||||
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/a/a7/Plasmopara_halstedii_R.H._07.jpg/960px-Plasmopara_halstedii_R.H._07.jpg",
|
||||
"source": "commons",
|
||||
"quality": "good"
|
||||
}
|
||||
}
|
||||
136
apps/web/scripts/.image-review-needed.md
Normal file
136
apps/web/scripts/.image-review-needed.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Disease Images — Human Review Needed
|
||||
|
||||
Generated: 2026-06-06T14:20:43.602Z
|
||||
|
||||
## Summary
|
||||
|
||||
- Total diseases: 93
|
||||
- Good images (Wiki/Commons): 63
|
||||
- Fallback images (Brave): 30
|
||||
- Still missing: 0
|
||||
|
||||
## ⚠️ Fallback Images (Brave) — Review Required
|
||||
|
||||
These 30 diseases have images from Brave Image Search.
|
||||
Quality/relevance may be lower than Wikipedia/Commons sources.
|
||||
|
||||
- **Cercospora Leaf Spot** (Cercospora iconia) on *basil*
|
||||

|
||||
URL: https://imgs.search.brave.com/qEe1QooFmBPBMvor3EDzfZP5vVYGlwOx7EytFqTviOQ/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9pbWFn/ZS5zbGlkZXNoYXJl/Y2RuLmNvbS9kaXNl/YXNlc29mYmFzaWxh/bmRtaW50LTE4MDYw/MjE2MzI1Ni83NS9E/aXNlYXNlcy1vZi1i/YXNpbC1hbmQtbWlu/dC0xMi0yMDQ4Lmpw/Zw
|
||||
|
||||
- **Root Rot (Pythium)** (Pythium spp.) on *basil*
|
||||

|
||||
URL: https://imgs.search.brave.com/8Qv7FgXxZpOhf9FM12OLnGoyNFHqPJfn3HPWzcgiK-I/rs:fit:0:180:1:0/g:ce/aHR0cHM6Ly9iLnRo/dW1icy5yZWRkaXRt/ZWRpYS5jb20vWHU1/dnY4Q0Z1ZDZxZXZJ/YTVqTmdfNWtRbkZq/VXA1eFRMV19YYW5Y/U2NLVS5qcGc
|
||||
|
||||
- **Black Spot** (Diplocarpon rosae) on *rose*
|
||||

|
||||
URL: https://imgs.search.brave.com/FhrhtzbypH95L6uCYLY8YVHAh9EohvnKUiARre4JB9g/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/My8wNS9Sb3NlLUZv/bGlhZ2Utd2l0aC1C/bGFjay1TcG90Lmpw/Zw
|
||||
|
||||
- **Leaf Spot (Cercospora)** (Cercospora spp.) on *monstera*
|
||||

|
||||
URL: https://imgs.search.brave.com/HxJxD9jMUqCj6btc1tedJw-fWV6HTCyiE2lEXkJ0_Pk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24udXN1LmVk/dS9wbGFudGhlYWx0/aC9pcG0vaW1hZ2Vz/L2FncmljdWx0dXJh/bC92ZWdldGFibGVz/L0NlcmNvc3BvcmEt/bGVhZi1zcG90LXNw/aW5hY2guanBn
|
||||
|
||||
- **Cold Damage / Freeze Injury** (Abiotic temperature injury) on *monstera*
|
||||

|
||||
URL: https://imgs.search.brave.com/ershnjIJ0rMnhFoGVHhTYhwwjSy4UVehWwlJ9ZKF0FU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9wcmV2/aWV3LnJlZGQuaXQv/aXMtdGhlcmUtaG9w/ZS1mb3ItbXktbW9u/c3RlcmEtdGhhdC1n/b3QtZnJvc3QtZGFt/YWdlLWlmLWFsbC12/MC1wcmswejc1cm1w/ZDYxLmpwZz93aWR0/aD02NDAmY3JvcD1z/bWFydCZhdXRvPXdl/YnAmcz1mYTVmOTMz/MTlkOWFiOTY3ZTlm/MzdkY2VhNDY5YjQ1/ODQ4NTNiYjMw
|
||||
|
||||
- **Spider Mite Infestation** (Tetranychus urticae) on *monstera*
|
||||

|
||||
URL: https://imgs.search.brave.com/TR9wjFRWkyus-jwsWOsygIVJbT44ddPEtvBxrYuf-RE/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/c2hvcGlmeS5jb20v/cy9maWxlcy8xLzEy/ODkvMjA0MS9maWxl/cy9lYXJseS1zaWdu/cy1vZi1zcGlkZXIt/bWl0ZXMtMTRfMjA0/OHgyMDQ4LmpwZz92/PTE3MjY2NzAzNTc
|
||||
|
||||
- **Mealybug Infestation** (Pseudococcus longispinus) on *pothos*
|
||||

|
||||
URL: https://imgs.search.brave.com/9ObqyNw4LWLVwrKJNnXuXPYnk9ghE5IKK-IZRTDlErw/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5mb3JpbmRvb3Iu/Y29tL3dwLWNvbnRl/bnQvdXBsb2Fkcy8y/MDIxLzA1L01lYWx5/YnVncy1vbi1wb3Ro/b3MuanBn
|
||||
|
||||
- **Bacterial Soft Rot** (Erwinia carotovora) on *pothos*
|
||||

|
||||
URL: https://imgs.search.brave.com/zeGhUbP2_Dt05TgOWAP5DNItLTB9EaI4bWTcKecE-5U/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24uc2RzdGF0/ZS5lZHUvc2l0ZXMv/ZGVmYXVsdC9maWxl/cy9pbmxpbmUtaW1h/Z2VzL1ctMDE3MDkt/MDEtQmFjdGVyaWFs/LVNvZnQtUm90LVN5/bXB0b21zLVN0ZW0u/anBn
|
||||
|
||||
- **Bacterial Leaf Spot** (Xanthomonas campestris pv. dieffenbachiae) on *peace-lily*
|
||||

|
||||
URL: https://imgs.search.brave.com/cnyXR2l1-H5EDwRDsAIxxf1aXwjzhnB2lcBzwWzLRu8/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9wbGFu/dGFtZXJpY2EuY29t/L3dwLWNvbnRlbnQv/dXBsb2Fkcy8yMDI0/LzA0L0xlYWYtU3Bv/dHMtb24tUGVhY2Ut/TGlseS1QbGFudC1B/bWVyaWNhLmpwZw
|
||||
|
||||
- **Botrytis Blight (Gray Mold)** (Botrytis cinerea) on *peace-lily*
|
||||

|
||||
URL: https://imgs.search.brave.com/Z3QnqltBrd5UYNFLiZp3t0oBQSJ2loklsrVSuIekQKg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/bW9zLmNtcy5mdXR1/cmVjZG4ubmV0L2h4/bXFBNWZORXk4NnJ0/YlRqUXlmdWYuanBn
|
||||
|
||||
- **Fungal Leaf Spot** (Alternaria / Cercospora spp.) on *orchid*
|
||||

|
||||
URL: https://imgs.search.brave.com/nwqmpPs_7Fgo5qbYAWXaGJIY4oNibmfZ8QE7RasMWUU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9leHRl/bnNpb24udW1kLmVk/dS9zaXRlcy9leHRl/bnNpb24udW1kLmVk/dS9maWxlcy9zdHls/ZXMvb3B0aW1pemVk/L3B1YmxpYy8yMDIx/LTAzL2hnaWNfaG91/c2VwbGFudF9mdW5n/YWxfbGVhZl9zcG90/X29yY2hpZC1IR0lD/LTEyNDItMDMxLXNs/aWRlLmpwZz9pdG9r/PVg2RTVQUGFk
|
||||
|
||||
- **Mealybug Infestation** (Pseudococcus longispinus) on *succulent*
|
||||

|
||||
URL: https://imgs.search.brave.com/EhBP0Sxi3YMkWaLiAi9tyn-8Xle7Gl-KgEk8I9UNK2E/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/cG90YW5kYmxvb20u/Y29tL2Nkbi9zaG9w/L2FydGljbGVzL3Vu/bmFtZWRfOC5wbmc_/dj0xNjk1OTgyMzc3/JndpZHRoPTQ4MA
|
||||
|
||||
- **Gray Mold (Botrytis)** (Botrytis cinerea) on *strawberry*
|
||||

|
||||
URL: https://imgs.search.brave.com/xCkZV5hmL757LmnOpWFE0GJrqRqWlNC1H2z4TqS-v_8/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/MC8wNi9Cb3RyeXRp/cy1HcmF5LU1vbGQt/b24tU3RyYXdiZXJy/eS1QbGFudHMuanBn
|
||||
|
||||
- **Leaf Scorch (Phytophthora)** (Phytophthora fragariae) on *strawberry*
|
||||

|
||||
URL: https://imgs.search.brave.com/c5oBR4uLCQ_0ivGFHwPTQRPb7BVtyWk7fum-2U0UJSk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/YWNlcy5lZHUvd3At/Y29udGVudC91cGxv/YWRzLzIwMjUvMDgv/cGh5dG9waHRob3Jh/LXN0cmF3YmVycnkt/MS0zMDB4MzAwLmpw/Zw
|
||||
|
||||
- **Gray Mold (Botrytis)** (Botrytis cinerea) on *lavender*
|
||||

|
||||
URL: https://imgs.search.brave.com/H2WYULyyPBsX20C-QDv3bL51oD2HbzLp8dxiwFGnrRc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4u/dHJlZWhvdXNlLmNv/L2pha2llLW9iamF3/eS1zemFyYS1wbGVz/bi5qcGc
|
||||
|
||||
- **Root Rot (Phytophthora)** (Phytophthora spp.) on *lavender*
|
||||

|
||||
URL: https://imgs.search.brave.com/xid_vdIjONng5nROkR-b71lw3slMuqqzRYf65itVr4Q/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9hZ3Jp/Y3VsdHVyZS52aWMu/Z292LmF1L2Jpb3Nl/Y3VyaXR5L3BsYW50/LWRpc2Vhc2VzL3Zl/Z2V0YWJsZS1kaXNl/YXNlcy9waHl0b3Bo/dGhvcmEtcm9vdC1y/b3Qtb2YtdG9tYXRv/ZXMvcGh5dG9waHRo/b3JhLXJvb3Qtcm90/LXRvbWF0b2VzLTIu/cG5n
|
||||
|
||||
- **Fungal Leaf Spot** (Cercospora / Alternaria spp.) on *fiddle-leaf-fig*
|
||||

|
||||
URL: https://imgs.search.brave.com/0w67jSvytmrzD0aW7Pnj66RMkHm0t-1XB9XYMelmIFg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9iMjk0/NTA0MS5zbXVzaGNk/bi5jb20vMjk0NTA0/MS93cC1jb250ZW50/L3VwbG9hZHMvMjAx/OS8wMS9JTUdfOTc2/Mi0xLmpwZz9sb3Nz/eT0xJnN0cmlwPTEm/d2VicD0x
|
||||
|
||||
- **Spider Mite Infestation** (Tetranychus urticae) on *fiddle-leaf-fig*
|
||||

|
||||
URL: https://imgs.search.brave.com/KsAcvKooEPszWNcjEkd60Yh0RwH9mkuAJYDyBk5bAYk/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZG9zc2llcmJsb2cu/Y29tL3dwLWNvbnRl/bnQvdXBsb2Fkcy8y/MDIwLzAyLzFoZWFk/ZXItdHJlYXQtc3Bp/ZGVyLW1pdGVzLWZp/ZGRsZS1pZy01MTJ4/NzY4LmpwZw
|
||||
|
||||
- **Mealybug Infestation** (Pseudococcus longispinus) on *aloe-vera*
|
||||

|
||||
URL: https://imgs.search.brave.com/L-rTzPOjDU_1iPtea1Jjj8Lfxfn9LG9UdkOJLG1rmlM/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9jZG4w/LnRoZWRhaWx5ZWNv/LmNvbS9lbi9wb3N0/cy85LzEvNi9sZWFm/X3Nwb3RfZGlzZWFz/ZV82MTlfMF82MDAu/anBn
|
||||
|
||||
- **Tobacco Mosaic Virus** (Tobacco mosaic virus (TMV)) on *jasmine*
|
||||

|
||||
URL: https://imgs.search.brave.com/m2lePdBbR7N-OUW_l4saDGEmJ-b6TKpv719fJAzP44A/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9nYXJk/ZW5lcnNwYXRoLmNv/bS93cC1jb250ZW50/L3VwbG9hZHMvMjAy/My8wNy9KYXNtaW5l/LURpc2Vhc2VzLUZl/YXR1cmUuanBn
|
||||
|
||||
- **Blossom End Rot** (Calcium deficiency disorder) on *chili*
|
||||

|
||||
URL: https://imgs.search.brave.com/G27ys6xN2xOxuIim46kDPLGDqsMGJkQCTYp4Qxnx3AE/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/bWlzc291cmlib3Rh/bmljYWxnYXJkZW4u/b3JnL1BvcnRhbHMv/MC9HYXJkZW5pbmcv/R2FyZGVuaW5nJTIw/SGVscC9pbWFnZXMv/UGVzdHMvQmxvc3Nv/bV9FbmRfUm90X29m/X1RvbWF0bzIwNTku/anBn
|
||||
|
||||
- **Phomopsis Blight** (Phomopsis capsici) on *chili*
|
||||

|
||||
URL: https://imgs.search.brave.com/Bw2HKsqR-VUW2dIDs23YmpJOT2aVmbzbQljpQc-m6FY/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/Z2FyZGVuZXJzLmNv/bS9jZG4vc2hvcC9h/cnRpY2xlcy83Mjk2/LVBob21vcHNpcy1C/bGlnaHQtZWdncGxh/bnQuanBnP3Y9MTc1/NDkzODQ1NyZ3aWR0/aD0zMjA
|
||||
|
||||
- **Blossom End Rot** (Calcium deficiency disorder) on *eggplant*
|
||||

|
||||
URL: https://imgs.search.brave.com/M5VmsJWcGGV3jd1JRaELFW-dUZY4QYEpeeBOY6s1ZpI/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93cGNk/bi53ZWIud3N1LmVk/dS9leHRlbnNpb24v/dXBsb2Fkcy9zaXRl/cy8zMS9wZXBwZXIt/Ymxvc3NvbS1lbmQt/cm90LTFMLTM5Nngy/OTAuanBn
|
||||
|
||||
- **Verruculosis** (Phoma macdonaldii) on *eggplant*
|
||||

|
||||
URL: https://imgs.search.brave.com/jJoYdSyzMN7ZkgFRr1RDeFMSBitksjk9LTIAsCI1jHg/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly90aHVt/YnMuZHJlYW1zdGlt/ZS5jb20vYi9sZWFm/LWVnZ3BsYW50LWFs/YmluaXNtLXN5bXB0/b20tbGVhZi1lZ2dw/bGFudC1hbGJpbmlz/bS1zeW1wdG9tLTQ1/MzYzOTQ3My5qcGc
|
||||
|
||||
- **Fern Rust** (Uromyces spp.) on *fern*
|
||||

|
||||
URL: https://imgs.search.brave.com/jv3P88EcgRSZTzKuvxRUJ2E07WKA3guVEruoY4l5Hfc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/dGhlZ2FyZGVud2Vi/c2l0ZS5jb20vdXBs/b2Fkcy84LzQvNS81/Lzg0NTU3MjQyL3J1/c3QtZm94Z2xvdmUt/YXVnLTJfb3JpZy5q/cGc
|
||||
|
||||
- **Root Rot (Crown Rot)** (Phytophthora / Pythium spp.) on *fern*
|
||||

|
||||
URL: https://imgs.search.brave.com/xid_vdIjONng5nROkR-b71lw3slMuqqzRYf65itVr4Q/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly9hZ3Jp/Y3VsdHVyZS52aWMu/Z292LmF1L2Jpb3Nl/Y3VyaXR5L3BsYW50/LWRpc2Vhc2VzL3Zl/Z2V0YWJsZS1kaXNl/YXNlcy9waHl0b3Bo/dGhvcmEtcm9vdC1y/b3Qtb2YtdG9tYXRv/ZXMvcGh5dG9waHRo/b3JhLXJvb3Qtcm90/LXRvbWF0b2VzLTIu/cG5n
|
||||
|
||||
- **Powdery Mildew** (Erysiphe spp.) on *daisy*
|
||||

|
||||
URL: https://imgs.search.brave.com/AwTYk5ex0GDr38LBt-uk9Pi7yOic_sTSeiTVYlEtNW4/rs:fit:0:180:1:0/g:ce/aHR0cHM6Ly93d3cu/dW1hc3MuZWR1L2Fn/cmljdWx0dXJlLWZv/b2QtZW52aXJvbm1l/bnQvc2l0ZXMvZGVm/YXVsdC9maWxlcy9z/dHlsZXMvMTUweDE1/MC9wdWJsaWMvZmFj/dC1zaGVldHMvaW1h/Z2VzL3Bvd2Rlcnlf/bWlsZF8wMy5qcGc_/aXRvaz1kZTZVMFZ0/UA
|
||||
|
||||
- **Rust** (Puccinia spp.) on *daisy*
|
||||

|
||||
URL: https://imgs.search.brave.com/jv3P88EcgRSZTzKuvxRUJ2E07WKA3guVEruoY4l5Hfc/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/dGhlZ2FyZGVud2Vi/c2l0ZS5jb20vdXBs/b2Fkcy84LzQvNS81/Lzg0NTU3MjQyL3J1/c3QtZm94Z2xvdmUt/YXVnLTJfb3JpZy5q/cGc
|
||||
|
||||
- **Downy Mildew** (Peronospora spp.) on *daisy*
|
||||

|
||||
URL: https://imgs.search.brave.com/h65q4ea2_EVIu5_NsJVPwUOVdrdVfhZGcr42TPFFEF0/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZXBpY2dhcmRlbmlu/Zy5jb20vd3AtY29u/dGVudC91cGxvYWRz/LzIwMjQvMDkvZG93/bnktbWlsZGV3LXZl/Z2V0YWJsZS1nYXJk/ZW4uanBn
|
||||
|
||||
- **Stem Rot (Fusarium)** (Fusarium spp.) on *cactus*
|
||||

|
||||
URL: https://imgs.search.brave.com/PF-Eqq7LSywJp8gzOgPppbHMfsXG4Ruj9zLZKkmxYRU/rs:fit:500:0:1:0/g:ce/aHR0cHM6Ly93d3cu/ZXBpY2dhcmRlbmlu/Zy5jb20vd3AtY29u/dGVudC91cGxvYWRz/LzIwMjMvMTIvRnVu/Z3VzLWRpc2Vhc2Uu/anBn
|
||||
|
||||
10
apps/web/scripts/.plant-image-review-needed.md
Normal file
10
apps/web/scripts/.plant-image-review-needed.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Plant Images - Still Missing
|
||||
|
||||
Generated: 2026-06-06T17:08:24.166Z
|
||||
|
||||
## Missing (4)
|
||||
|
||||
- Calabash (Bottle Gourd) (calabash)
|
||||
- ZZ Plant (zz-plant)
|
||||
- Stromanthe Triostar (stromanthe)
|
||||
- Shanghai Bok Choy (bok-choy-shanghai)
|
||||
296
apps/web/scripts/convert-keras-to-tfjs.py
Normal file
296
apps/web/scripts/convert-keras-to-tfjs.py
Normal file
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Inspect and convert a .keras plant disease model to TF.js GraphModel format.
|
||||
|
||||
Uses tensorflowjs_converter CLI to avoid Keras version deserialization issues.
|
||||
|
||||
Usage:
|
||||
pip3 install tensorflowjs # also pulls tensorflow as dependency
|
||||
python3 scripts/convert-keras-to-tfjs.py
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
MODEL_PATH = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"public",
|
||||
"models",
|
||||
"plant-disease-classifier",
|
||||
"best_mnv2_pv_original.keras",
|
||||
)
|
||||
|
||||
OUTPUT_DIR = os.path.join(
|
||||
os.path.dirname(MODEL_PATH),
|
||||
"tfjs_model",
|
||||
)
|
||||
|
||||
|
||||
def inspect_keras_metadata():
|
||||
"""Read .keras archive metadata without loading the model."""
|
||||
print("=" * 60)
|
||||
print("MODEL INSPECTION (metadata only)")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
import zipfile
|
||||
except ImportError:
|
||||
print("ERROR: zipfile not available")
|
||||
sys.exit(1)
|
||||
|
||||
if not os.path.exists(MODEL_PATH):
|
||||
print(f"ERROR: Model not found at {MODEL_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nModel file: {MODEL_PATH}")
|
||||
print(
|
||||
f"File size: {os.path.getsize(MODEL_PATH):,} bytes ({os.path.getsize(MODEL_PATH) / 1024 / 1024:.1f} MB)"
|
||||
)
|
||||
|
||||
# .keras files are ZIP archives
|
||||
with zipfile.ZipFile(MODEL_PATH) as zf:
|
||||
names = zf.namelist()
|
||||
print(f"\nArchive contents ({len(names)} entries):")
|
||||
for name in names:
|
||||
info = zf.getinfo(name)
|
||||
print(f" {name:<40s} {info.file_size:>10,} bytes")
|
||||
|
||||
# Read config.json for model architecture info
|
||||
config_path = None
|
||||
for name in names:
|
||||
if name.endswith("config.json"):
|
||||
config_path = name
|
||||
break
|
||||
|
||||
if config_path:
|
||||
print(f"\nReading {config_path}...")
|
||||
with zf.open(config_path) as f:
|
||||
config = json.load(f)
|
||||
|
||||
# Extract key info
|
||||
model_type = config.get("class_name", "unknown")
|
||||
print(f"Model class: {model_type}")
|
||||
|
||||
# Try to find output layer info
|
||||
if "config" in config:
|
||||
inner_config = config["config"]
|
||||
|
||||
# Look for output shape in config
|
||||
if "output_shape" in inner_config:
|
||||
print(f"Output shape: {inner_config['output_shape']}")
|
||||
|
||||
# Look through layers for the final dense layer
|
||||
if "layers" in inner_config:
|
||||
layers = inner_config["layers"]
|
||||
print(f"\nLayers ({len(layers)} total):")
|
||||
for layer in layers:
|
||||
layer_name = layer.get("config", {}).get("name", "?")
|
||||
layer_class = layer.get("class_name", "?")
|
||||
layer_module = layer.get("module", "?")
|
||||
|
||||
# Extract units/activation for dense layers
|
||||
layer_config = layer.get("config", {})
|
||||
units = layer_config.get("units")
|
||||
activation = layer_config.get("activation")
|
||||
|
||||
detail = ""
|
||||
if units:
|
||||
detail = f" units={units}"
|
||||
if activation:
|
||||
detail += f" activation={activation}"
|
||||
|
||||
print(f" {layer_name:<30s} {layer_class:<20s}{detail}")
|
||||
|
||||
# Find last dense layer for class count
|
||||
for layer in reversed(layers):
|
||||
if layer.get("class_name") == "Dense":
|
||||
units = layer.get("config", {}).get("units")
|
||||
activation = layer.get("config", {}).get("activation")
|
||||
print("\nClassification head:")
|
||||
print(f" Units (classes): {units}")
|
||||
print(f" Activation: {activation}")
|
||||
print(
|
||||
f" Layer name: {layer.get('config', {}).get('name', '?')}"
|
||||
)
|
||||
break
|
||||
|
||||
# Check compile config
|
||||
if "compile_config" in config:
|
||||
compile_cfg = config["compile_config"]
|
||||
optimizer = compile_cfg.get("optimizer", {})
|
||||
if isinstance(optimizer, dict):
|
||||
opt_name = optimizer.get("class_name", "?")
|
||||
lr = optimizer.get("config", {}).get("learning_rate")
|
||||
print("\nTraining config:")
|
||||
print(f" Optimizer: {opt_name}")
|
||||
if lr:
|
||||
print(f" Learning rate: {lr}")
|
||||
loss = compile_cfg.get("loss", "?")
|
||||
metrics = compile_cfg.get("metrics", [])
|
||||
print(f" Loss: {loss}")
|
||||
print(f" Metrics: {metrics}")
|
||||
|
||||
# Check input shape
|
||||
if "build_config" in config:
|
||||
build_cfg = config["build_config"]
|
||||
if "input_shape" in build_cfg:
|
||||
print(f"\nInput shape: {build_cfg['input_shape']}")
|
||||
|
||||
|
||||
def convert_to_tfjs():
|
||||
"""Convert using tensorflowjs_converter CLI."""
|
||||
print("\n" + "=" * 60)
|
||||
print("CONVERTING TO TF.JS GRAPH MODEL")
|
||||
print("=" * 60)
|
||||
|
||||
# Check tensorflowjs_converter CLI is available
|
||||
converter = shutil.which("tensorflowjs_converter")
|
||||
if not converter:
|
||||
print("ERROR: tensorflowjs_converter not found in PATH.")
|
||||
print(" pip3 install tensorflowjs")
|
||||
sys.exit(1)
|
||||
|
||||
# Clean output dir
|
||||
if os.path.exists(OUTPUT_DIR):
|
||||
print(f"Removing existing output dir: {OUTPUT_DIR}")
|
||||
shutil.rmtree(OUTPUT_DIR)
|
||||
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print(f"\nConverting {MODEL_PATH} -> {OUTPUT_DIR}/")
|
||||
print("(this may take a minute...)")
|
||||
|
||||
# Use the venv's python to run the converter (avoids import issues)
|
||||
python_exe = sys.executable # the python running this script
|
||||
result = subprocess.run(
|
||||
[
|
||||
python_exe,
|
||||
"-m",
|
||||
"tensorflowjs.converters.converter",
|
||||
"--input_format=keras",
|
||||
"--output_format=tfjs_graph_model",
|
||||
MODEL_PATH,
|
||||
OUTPUT_DIR,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print("\nERROR: Conversion failed!")
|
||||
print(f"stdout: {result.stdout}")
|
||||
print(f"stderr: {result.stderr}")
|
||||
sys.exit(1)
|
||||
|
||||
if result.stdout:
|
||||
print(result.stdout)
|
||||
if result.stderr:
|
||||
# Some warnings are normal
|
||||
print(f"Converter output: {result.stderr}")
|
||||
|
||||
# Verify output
|
||||
model_json_path = os.path.join(OUTPUT_DIR, "model.json")
|
||||
if not os.path.exists(model_json_path):
|
||||
print("ERROR: Conversion did not produce model.json")
|
||||
sys.exit(1)
|
||||
|
||||
# List output files
|
||||
files = os.listdir(OUTPUT_DIR)
|
||||
total_size = sum(
|
||||
os.path.getsize(os.path.join(OUTPUT_DIR, f))
|
||||
for f in files
|
||||
if os.path.isfile(os.path.join(OUTPUT_DIR, f))
|
||||
)
|
||||
|
||||
print("\nConversion complete!")
|
||||
print(f"Output directory: {OUTPUT_DIR}/")
|
||||
print(f"Files: {len(files)}")
|
||||
for f in sorted(files):
|
||||
fpath = os.path.join(OUTPUT_DIR, f)
|
||||
if os.path.isfile(fpath):
|
||||
size = os.path.getsize(fpath)
|
||||
print(f" {f:<30s} {size:>10,} bytes")
|
||||
print(f"Total size: {total_size:,} bytes ({total_size / 1024 / 1024:.1f} MB)")
|
||||
|
||||
# Read model.json to check config
|
||||
with open(model_json_path) as f:
|
||||
model_json = json.load(f)
|
||||
|
||||
print(f"\nTF.js model format: {model_json.get('format', 'unknown')}")
|
||||
print(f"Generated by: {model_json.get('generatedBy', 'unknown')}")
|
||||
|
||||
# Inspect model topology
|
||||
if "modelTopology" in model_json:
|
||||
topology = model_json["modelTopology"]
|
||||
print("\nModel topology:")
|
||||
print(f" Name: {topology.get('model_name', 'unnamed')}")
|
||||
print(f" Ops: {len(topology.get('node', []))} nodes")
|
||||
|
||||
# Input/output nodes
|
||||
inputs = topology.get("inputs", {})
|
||||
outputs = topology.get("outputs", {})
|
||||
print(f" Inputs: {list(inputs.keys())}")
|
||||
for name, info in inputs.items():
|
||||
shape = info.get("tensorShape", {})
|
||||
print(f" {name}: shape={shape.get('dim', 'unknown')}")
|
||||
print(f" Outputs: {list(outputs.keys())}")
|
||||
for name, info in outputs.items():
|
||||
shape = info.get("tensorShape", {})
|
||||
print(f" {name}: shape={shape.get('dim', 'unknown')}")
|
||||
|
||||
# Check weights specification
|
||||
if "weightsManifest" in model_json:
|
||||
manifest = model_json["weightsManifest"]
|
||||
print(f"\nWeight manifests: {len(manifest)}")
|
||||
for i, m in enumerate(manifest):
|
||||
shards = m.get("shards", [])
|
||||
print(f" Manifest {i}: {len(shards)} shard(s)")
|
||||
|
||||
return OUTPUT_DIR
|
||||
|
||||
|
||||
def main():
|
||||
if not os.path.exists(MODEL_PATH):
|
||||
print(f"ERROR: Model not found at {MODEL_PATH}")
|
||||
sys.exit(1)
|
||||
|
||||
# Step 1: Inspect metadata
|
||||
inspect_keras_metadata()
|
||||
|
||||
# Step 2: Convert
|
||||
output_dir = convert_to_tfjs()
|
||||
|
||||
# Step 3: Summary
|
||||
print("\n" + "=" * 60)
|
||||
print("NEXT STEPS")
|
||||
print("=" * 60)
|
||||
print(f"""
|
||||
1. Move the TF.js model to the expected location:
|
||||
The model-loader expects model.json at:
|
||||
public/models/plant-disease-classifier/model.json
|
||||
|
||||
Move files:
|
||||
mv {output_dir}/model.json public/models/plant-disease-classifier/
|
||||
mv {output_dir}/group1-shard* public/models/plant-disease-classifier/
|
||||
|
||||
2. IMPORTANT: This model has 38 output classes (original PlantVillage).
|
||||
Your labels.ts expects 95 classes (93 diseases + healthy + unknown).
|
||||
You'll need to either:
|
||||
a) Fine-tune the model with your 95-class dataset, OR
|
||||
b) Map the 38 PlantVillage classes to your disease IDs
|
||||
|
||||
3. Install @tensorflow/tfjs in your project:
|
||||
npm install @tensorflow/tfjs
|
||||
|
||||
4. Test with your API:
|
||||
npm run dev
|
||||
POST /api/identify with an uploaded image
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
691
apps/web/scripts/expand-diseases.ts
Normal file
691
apps/web/scripts/expand-diseases.ts
Normal file
@@ -0,0 +1,691 @@
|
||||
/**
|
||||
* Expand DB with comprehensive plant disease list from Wikipedia.
|
||||
*
|
||||
* Reads /tmp/plant_diseases/plant_diseases_comprehensive.txt,
|
||||
* compares against existing DB entries (by name, case-insensitive),
|
||||
* and inserts new entries with reasonable defaults.
|
||||
*
|
||||
* Usage:
|
||||
* cd apps/web && export $(grep -v '^#' .env.development | xargs) && npx tsx scripts/expand-diseases.ts
|
||||
*/
|
||||
|
||||
import "dotenv/config";
|
||||
import { readFileSync } from "fs";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { getDb, closeDb } from "../src/lib/db/index";
|
||||
import { plants, diseases } from "../src/lib/db/schema";
|
||||
import type { CausalAgentType, Severity } from "../src/lib/types";
|
||||
|
||||
// ─── Parse the comprehensive list ─────────────────────────────────────────────
|
||||
|
||||
interface DiseaseEntry {
|
||||
name: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
function parseComprehensiveList(filePath: string): DiseaseEntry[] {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const entries: DiseaseEntry[] = [];
|
||||
const lines = content.split("\n");
|
||||
const nameRe = /^\d+\.\s+(.+)$/;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const nameMatch = lines[i].match(nameRe);
|
||||
if (nameMatch) {
|
||||
const name = nameMatch[1].trim();
|
||||
const urlLine = lines[i + 1]?.trim() || "";
|
||||
// Only add if the next line is a valid URL
|
||||
if (urlLine.startsWith("http")) {
|
||||
entries.push({ name, sourceUrl: urlLine });
|
||||
i++; // skip the URL line
|
||||
} else {
|
||||
entries.push({ name, sourceUrl: "" });
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ─── Infer causal agent type from disease name ────────────────────────────────
|
||||
|
||||
function inferCausalAgent(name: string): CausalAgentType {
|
||||
const lower = name.toLowerCase();
|
||||
|
||||
// Bacterial indicators
|
||||
if (
|
||||
lower.startsWith("bacterial ") ||
|
||||
lower.includes(" xanthomonas") ||
|
||||
lower.includes(" pseudomonas") ||
|
||||
lower.includes(" erwinia") ||
|
||||
lower.includes(" ralstonia") ||
|
||||
lower.includes(" clavibacter") ||
|
||||
lower.includes(" streptomyces") ||
|
||||
lower.includes(" agrobacterium") ||
|
||||
lower.includes(" corynebacterium") ||
|
||||
lower.includes(" pectobacterium") ||
|
||||
lower.includes(" dickeya")
|
||||
) {
|
||||
return "bacterial";
|
||||
}
|
||||
|
||||
// Viral indicators - strong signals
|
||||
if (
|
||||
lower.includes(" mosaic") ||
|
||||
lower.includes(" yellows") ||
|
||||
lower.includes(" leaf roll") ||
|
||||
lower.includes(" leafroll") ||
|
||||
lower.includes(" ringspot") ||
|
||||
lower.includes(" ring spot") ||
|
||||
lower.includes(" enation") ||
|
||||
lower.includes(" phyllody") ||
|
||||
lower.includes(" witches") ||
|
||||
lower.includes(" witches'") ||
|
||||
lower.includes(" crinkle") ||
|
||||
lower.includes(" rosette") ||
|
||||
lower.includes(" shoestring") ||
|
||||
lower.includes(" tristeza") ||
|
||||
lower.includes(" psorosis") ||
|
||||
lower.includes(" stubborn") ||
|
||||
lower.includes(" greening") ||
|
||||
lower.includes(" vein banding") ||
|
||||
lower.includes(" vein mottle") ||
|
||||
lower.includes(" vein clearing") ||
|
||||
lower.includes(" leaf pucker") ||
|
||||
lower.includes(" pucker leaf") ||
|
||||
lower.includes(" latent") ||
|
||||
lower.includes(" motley") ||
|
||||
lower.includes(" rugose")
|
||||
) {
|
||||
return "viral";
|
||||
}
|
||||
|
||||
// Viral - names containing "virus" or "viroid"
|
||||
if (lower.includes(" virus") || lower.includes(" viroid") || lower.includes(" virosis")) {
|
||||
return "viral";
|
||||
}
|
||||
|
||||
// Nematodes
|
||||
if (
|
||||
lower.includes(" nematode") ||
|
||||
lower.includes(" nematodes") ||
|
||||
lower.includes(" eelworm") ||
|
||||
lower.includes(" root knot") ||
|
||||
lower.includes(" root-knot") ||
|
||||
lower.includes(" cyst ") ||
|
||||
lower.includes(" dagger ") ||
|
||||
lower.includes(" lance ") ||
|
||||
lower.includes(" lesion ") ||
|
||||
lower.includes(" ring ") ||
|
||||
lower.includes(" spiral ") ||
|
||||
lower.includes(" sting ") ||
|
||||
lower.includes(" stubby ") ||
|
||||
lower.includes(" needle ") ||
|
||||
lower.includes(" foliar ") ||
|
||||
lower.includes(" bulb ") ||
|
||||
lower.includes(" reniform ") ||
|
||||
lower.includes(" burrowing ")
|
||||
) {
|
||||
// Check if it's really a nematode name
|
||||
if (lower.includes("nematode")) return "environmental";
|
||||
}
|
||||
|
||||
// Fungal indicators
|
||||
if (
|
||||
lower.includes(" mildew") ||
|
||||
lower.includes(" rust") ||
|
||||
lower.includes(" smut") ||
|
||||
lower.includes(" blight") ||
|
||||
lower.includes(" canker") ||
|
||||
lower.includes(" rot") ||
|
||||
lower.includes(" scab") ||
|
||||
lower.includes(" mold") ||
|
||||
lower.includes(" anthracnose") ||
|
||||
lower.includes(" bunt") ||
|
||||
lower.includes(" ergot") ||
|
||||
lower.includes(" dieback") ||
|
||||
lower.includes(" scald") ||
|
||||
lower.includes(" blotch") ||
|
||||
lower.includes(" speckle") ||
|
||||
lower.includes(" sooty") ||
|
||||
lower.includes(" flyspeck") ||
|
||||
lower.includes(" fusarium") ||
|
||||
lower.includes(" alternaria") ||
|
||||
lower.includes(" botrytis") ||
|
||||
lower.includes(" rhizoctonia") ||
|
||||
lower.includes(" pythium") ||
|
||||
lower.includes(" phytophthora") ||
|
||||
lower.includes(" sclerotinia") ||
|
||||
lower.includes(" verticillium") ||
|
||||
lower.includes(" ascochyta") ||
|
||||
lower.includes(" cercospora") ||
|
||||
lower.includes(" septoria") ||
|
||||
lower.includes(" colletotrichum") ||
|
||||
lower.includes(" phomopsis") ||
|
||||
lower.includes(" diaporthe") ||
|
||||
lower.includes(" diplodia") ||
|
||||
lower.includes(" macrophomina") ||
|
||||
lower.includes(" cylindrocladium") ||
|
||||
lower.includes(" mycosphaerella") ||
|
||||
lower.includes(" helminthosporium") ||
|
||||
lower.includes(" curvularia") ||
|
||||
lower.includes(" bipolaris") ||
|
||||
lower.includes(" exserohilum") ||
|
||||
lower.includes(" dothiorella") ||
|
||||
lower.includes(" fusicoccum") ||
|
||||
lower.includes(" pestalotia") ||
|
||||
lower.includes(" glomerella") ||
|
||||
lower.includes(" nectria") ||
|
||||
lower.includes(" eutypa") ||
|
||||
lower.includes(" armillaria") ||
|
||||
lower.includes(" ganoderma") ||
|
||||
lower.includes(" phoma") ||
|
||||
lower.includes(" cladosporium") ||
|
||||
lower.includes(" penicillium") ||
|
||||
lower.includes(" aspergillus") ||
|
||||
lower.includes(" rhizopus") ||
|
||||
lower.includes(" mucor") ||
|
||||
lower.includes(" downy mildew") ||
|
||||
lower.includes(" powdery mildew") ||
|
||||
lower.includes(" pink rot") ||
|
||||
lower.includes(" pink mold") ||
|
||||
lower.includes(" pink root") ||
|
||||
lower.includes(" gray mold") ||
|
||||
lower.includes(" grey mold") ||
|
||||
lower.includes(" white rot") ||
|
||||
lower.includes(" white mold") ||
|
||||
lower.includes(" brown rot") ||
|
||||
lower.includes(" black rot") ||
|
||||
lower.includes(" soft rot") ||
|
||||
lower.includes(" dry rot") ||
|
||||
lower.includes(" fruit rot") ||
|
||||
lower.includes(" root rot") ||
|
||||
lower.includes(" stem rot") ||
|
||||
lower.includes(" ear rot") ||
|
||||
lower.includes(" crown rot") ||
|
||||
lower.includes(" collar rot") ||
|
||||
lower.includes(" pod rot") ||
|
||||
lower.includes(" kernel rot") ||
|
||||
lower.includes(" stalk rot") ||
|
||||
lower.includes(" head rot") ||
|
||||
lower.includes(" butt rot") ||
|
||||
lower.includes(" stump rot") ||
|
||||
lower.includes(" wood rot") ||
|
||||
lower.includes(" seed rot") ||
|
||||
lower.includes(" leaf spot") ||
|
||||
lower.includes(" leaf blight") ||
|
||||
lower.includes(" leaf blotch") ||
|
||||
lower.includes(" leaf rust") ||
|
||||
lower.includes(" brown spot") ||
|
||||
lower.includes(" black spot") ||
|
||||
lower.includes(" black leg") ||
|
||||
lower.includes(" blackleg") ||
|
||||
lower.includes(" black foot") ||
|
||||
lower.includes(" white rust") ||
|
||||
lower.includes(" white smut") ||
|
||||
lower.includes(" white scab") ||
|
||||
lower.includes(" tar spot") ||
|
||||
lower.includes(" target spot") ||
|
||||
lower.includes(" dollar spot") ||
|
||||
lower.includes(" fairy ring") ||
|
||||
lower.includes(" snow mold") ||
|
||||
lower.includes(" pink disease") ||
|
||||
lower.includes(" thread blight") ||
|
||||
lower.includes(" web blight") ||
|
||||
lower.includes(" sclerotial") ||
|
||||
lower.includes(" sore shin") ||
|
||||
lower.includes(" wart") ||
|
||||
lower.includes(" scurf") ||
|
||||
lower.includes(" silver scurf") ||
|
||||
lower.includes(" shot hole") ||
|
||||
lower.includes(" timber rot") ||
|
||||
lower.includes(" cottony rot") ||
|
||||
lower.includes(" watery rot") ||
|
||||
lower.includes(" sour rot") ||
|
||||
lower.includes(" seepage") ||
|
||||
lower.includes(" bunch rot") ||
|
||||
lower.includes(" noble rot") ||
|
||||
lower.includes(" bitter rot") ||
|
||||
lower.includes(" ripe rot") ||
|
||||
lower.includes(" ring rot") ||
|
||||
lower.includes(" coral spot") ||
|
||||
lower.includes(" stem canker") ||
|
||||
lower.includes(" branch canker") ||
|
||||
lower.includes(" perennial canker") ||
|
||||
lower.includes(" brand canker") ||
|
||||
lower.includes(" blister canker") ||
|
||||
lower.includes(" bleeding canker") ||
|
||||
lower.includes(" bark canker") ||
|
||||
lower.includes(" gum canker") ||
|
||||
lower.includes(" collar crack") ||
|
||||
lower.includes(" fasciation") ||
|
||||
lower.includes(" exobasidium") ||
|
||||
lower.includes(" mycorrhiza") ||
|
||||
lower.includes(" lichen") ||
|
||||
lower.includes(" algal") ||
|
||||
lower.includes(" chlorosis") ||
|
||||
lower.includes(" leaf blister") ||
|
||||
lower.includes(" leaf curl")
|
||||
) {
|
||||
return "fungal";
|
||||
}
|
||||
|
||||
// Physiological / environmental indicators
|
||||
if (
|
||||
lower.includes(" sunscald") ||
|
||||
lower.includes(" sunburn") ||
|
||||
lower.includes(" chilling") ||
|
||||
lower.includes(" blossom end rot") ||
|
||||
lower.includes(" edema") ||
|
||||
lower.includes(" deficiency") ||
|
||||
lower.includes(" toxicity") ||
|
||||
lower.includes(" ozone") ||
|
||||
lower.includes(" drought") ||
|
||||
lower.includes(" frost") ||
|
||||
lower.includes(" herbicide") ||
|
||||
lower.includes(" pesticide") ||
|
||||
lower.includes(" phytotoxicity") ||
|
||||
lower.includes(" catface") ||
|
||||
lower.includes(" fruit cracking") ||
|
||||
lower.includes(" russeting") ||
|
||||
lower.includes(" growth crack") ||
|
||||
lower.includes(" mealiness") ||
|
||||
lower.includes(" wind scar") ||
|
||||
lower.includes(" hail") ||
|
||||
lower.includes(" salt ") ||
|
||||
lower.includes(" nutritional") ||
|
||||
lower.includes(" mineral") ||
|
||||
lower.includes(" overwatering") ||
|
||||
lower.includes(" under watering") ||
|
||||
lower.includes(" waterlogging") ||
|
||||
lower.includes(" chemical injury") ||
|
||||
lower.includes(" spray injury") ||
|
||||
lower.includes(" fertilizer burn") ||
|
||||
lower.includes(" lightning") ||
|
||||
lower.includes(" bruising") ||
|
||||
lower.includes(" pressure bruise") ||
|
||||
lower.includes(" impact damage") ||
|
||||
lower.includes(" transit rot")
|
||||
) {
|
||||
return "environmental";
|
||||
}
|
||||
|
||||
// Insect/mite/pest indicators
|
||||
if (
|
||||
lower.includes(" mite") ||
|
||||
lower.includes(" beetle") ||
|
||||
lower.includes(" weevil") ||
|
||||
lower.includes(" aphid") ||
|
||||
lower.includes(" bollworm") ||
|
||||
lower.includes(" leaf miner") ||
|
||||
lower.includes(" mealybug") ||
|
||||
lower.includes(" thrips") ||
|
||||
lower.includes(" whitefly") ||
|
||||
lower.includes(" caterpillar") ||
|
||||
lower.includes(" sawfly") ||
|
||||
lower.includes(" scale ") ||
|
||||
lower.includes(" leafhopper") ||
|
||||
lower.includes(" psylla") ||
|
||||
lower.includes(" slug") ||
|
||||
lower.includes(" snail") ||
|
||||
lower.includes(" borer") ||
|
||||
lower.includes(" maggot") ||
|
||||
lower.includes(" grub") ||
|
||||
lower.includes(" earwig") ||
|
||||
lower.includes(" grasshopper")
|
||||
) {
|
||||
return "environmental";
|
||||
}
|
||||
|
||||
// Fungal genus names
|
||||
const fungalGenera = [
|
||||
"armillaria",
|
||||
"aspergillus",
|
||||
"alternaria",
|
||||
"botrytis",
|
||||
"cercospora",
|
||||
"cladosporium",
|
||||
"colletotrichum",
|
||||
"curvularia",
|
||||
"cylindrocladium",
|
||||
"diplodia",
|
||||
"fusarium",
|
||||
"ganoderma",
|
||||
"glomerella",
|
||||
"helminthosporium",
|
||||
"macrophomina",
|
||||
"mycosphaerella",
|
||||
"nectria",
|
||||
"penicillium",
|
||||
"pestalotia",
|
||||
"phoma",
|
||||
"phomopsis",
|
||||
"phytophthora",
|
||||
"pythium",
|
||||
"rhizoctonia",
|
||||
"sclerotinia",
|
||||
"septoria",
|
||||
"verticillium",
|
||||
"ascochyta",
|
||||
"cercoseptoria",
|
||||
"phaeoisariopsis",
|
||||
"phaeoseptoria",
|
||||
"stagonospora",
|
||||
"stemphylium",
|
||||
"myrothecium",
|
||||
"myriogenospora",
|
||||
"dactuliophora",
|
||||
"dilophospora",
|
||||
"coniothecium",
|
||||
"coniosporium",
|
||||
"cryptostictis",
|
||||
"catacauma",
|
||||
"botryodiplodia",
|
||||
"botryosphaeria",
|
||||
"cephalosporium",
|
||||
"ceratocystis",
|
||||
"chalara",
|
||||
"choanephora",
|
||||
"clitocybe",
|
||||
"coprinus",
|
||||
"cordana",
|
||||
"corticium",
|
||||
"corynespora",
|
||||
"coryneum",
|
||||
"cylindrocarpon",
|
||||
"cylindrocladiella",
|
||||
"cylindrosporium",
|
||||
"cytospora",
|
||||
"cytosporina",
|
||||
"dematophora",
|
||||
"didymella",
|
||||
"dothiorella",
|
||||
"drechslera",
|
||||
"endothia",
|
||||
"eutypa",
|
||||
"eutypella",
|
||||
"exobasidium",
|
||||
"fusicladium",
|
||||
"fusicoccum",
|
||||
"gibberella",
|
||||
"glomerella",
|
||||
"gnomonia",
|
||||
"graphiola",
|
||||
"guignardia",
|
||||
"hendersonia",
|
||||
"hendersonula",
|
||||
"hymenochaete",
|
||||
"hypoxylon",
|
||||
"lasiodiplodia",
|
||||
"leptosphaeria",
|
||||
"leucostoma",
|
||||
"lophodermium",
|
||||
"macrophoma",
|
||||
"marasmiellus",
|
||||
"marasmius",
|
||||
"massaria",
|
||||
"monilia",
|
||||
"monosporascus",
|
||||
"mystrosporium",
|
||||
"neocosmospora",
|
||||
"nigrospora",
|
||||
"omphalia",
|
||||
"ophiobolus",
|
||||
"ovulinia",
|
||||
"ozonium",
|
||||
"panagrolaimus",
|
||||
"periconia",
|
||||
"pestalosphaeria",
|
||||
"pestalotiopsis",
|
||||
"phialophora",
|
||||
"phymatotrichum",
|
||||
"physalospora",
|
||||
"phytophthora",
|
||||
"plasmodiophora",
|
||||
"plectosporium",
|
||||
"polyporus",
|
||||
"poria",
|
||||
"pseudocercosporella",
|
||||
"pseudopeziza",
|
||||
"pseudoseptoria",
|
||||
"puccinia",
|
||||
"pyrenochaeta",
|
||||
"pythium",
|
||||
"ramularia",
|
||||
"rhizoctonia",
|
||||
"rhizopus",
|
||||
"rhynchosporium",
|
||||
"rosellinia",
|
||||
"sclerophthora",
|
||||
"sclerotinia",
|
||||
"sclerotium",
|
||||
"septoria",
|
||||
"sphaceloma",
|
||||
"sphaeropsis",
|
||||
"spongospora",
|
||||
"stagonospora",
|
||||
"stemphylium",
|
||||
"stereum",
|
||||
"stigmina",
|
||||
"thanatephorus",
|
||||
"thielaviopsis",
|
||||
"tippula",
|
||||
"typhula",
|
||||
"ulocladium",
|
||||
"uredo",
|
||||
"ustilago",
|
||||
"valsa",
|
||||
"venturia",
|
||||
"verticillium",
|
||||
"xylaria",
|
||||
];
|
||||
for (const genus of fungalGenera) {
|
||||
if (lower.includes(genus)) return "fungal";
|
||||
}
|
||||
|
||||
// Default to fungal (most plant diseases are fungal)
|
||||
return "fungal";
|
||||
}
|
||||
|
||||
// ─── Infer severity ───────────────────────────────────────────────────────────
|
||||
|
||||
function inferSeverity(name: string): Severity {
|
||||
const lower = name.toLowerCase();
|
||||
if (
|
||||
lower.includes(" lethal") ||
|
||||
lower.includes(" devastating") ||
|
||||
lower.includes(" destructive") ||
|
||||
lower.includes(" fatal") ||
|
||||
lower.includes(" severe") ||
|
||||
lower.includes(" blight") ||
|
||||
lower.includes(" wilt") ||
|
||||
lower.includes(" canker") ||
|
||||
lower.includes(" dieback") ||
|
||||
lower.includes(" decline") ||
|
||||
lower.includes(" rot") ||
|
||||
lower.includes(" gall") ||
|
||||
lower.includes(" gummosis") ||
|
||||
lower.includes(" necrosis") ||
|
||||
lower.includes(" erwinia")
|
||||
) {
|
||||
return "high";
|
||||
}
|
||||
if (
|
||||
lower.includes(" minor") ||
|
||||
lower.includes(" mild") ||
|
||||
lower.includes(" slight") ||
|
||||
lower.includes(" speckle") ||
|
||||
lower.includes(" fleck") ||
|
||||
lower.includes(" freckle") ||
|
||||
lower.includes(" chlorosis") ||
|
||||
lower.includes(" translucence") ||
|
||||
lower.includes(" superficial")
|
||||
) {
|
||||
return "low";
|
||||
}
|
||||
return "moderate";
|
||||
}
|
||||
|
||||
// ─── Generate a deterministic slug ────────────────────────────────────────────
|
||||
|
||||
function toSlug(name: string): string {
|
||||
return (
|
||||
"wiki-" +
|
||||
name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Get existing disease names from DB
|
||||
const existingDiseases = await db.select({ name: diseases.name }).from(diseases);
|
||||
const existingNames = new Set(existingDiseases.map((d) => d.name.toLowerCase().trim()));
|
||||
|
||||
console.log(`Existing diseases in DB: ${existingNames.size}`);
|
||||
|
||||
// 2. Parse the comprehensive list
|
||||
const entries = parseComprehensiveList("/tmp/plant_diseases/plant_diseases_comprehensive.txt");
|
||||
console.log(`Total entries in comprehensive file: ${entries.length}`);
|
||||
|
||||
// 3. Find or create catch-all plants
|
||||
for (const plantId of ["general", "unknown"]) {
|
||||
const existing = await db.select().from(plants).where(eq(plants.id, plantId)).get();
|
||||
|
||||
if (!existing) {
|
||||
console.log(`Creating '${plantId}' plant for catch-all diseases...`);
|
||||
await db.insert(plants).values({
|
||||
id: plantId,
|
||||
commonName: plantId === "general" ? "General (Multiple Plants)" : "Unknown Plant",
|
||||
scientificName: "Various",
|
||||
family: "Various",
|
||||
category: "houseplant",
|
||||
careSummary:
|
||||
plantId === "general"
|
||||
? "General plant diseases affecting multiple species."
|
||||
: "Plant disease with unknown host plant.",
|
||||
imageUrl: "",
|
||||
});
|
||||
console.log(`Created '${plantId}' plant.`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Filter new entries (deduplicate within file + against DB)
|
||||
const newEntries: DiseaseEntry[] = [];
|
||||
const skipped: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const key = entry.name.toLowerCase().trim();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
|
||||
if (existingNames.has(key)) {
|
||||
skipped.push(entry.name);
|
||||
} else {
|
||||
newEntries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nNew entries to insert: ${newEntries.length}`);
|
||||
console.log(`Already existing (skipped): ${skipped.length}`);
|
||||
|
||||
if (skipped.length > 0) {
|
||||
console.log(`\nFirst 10 skipped (of ${skipped.length}):`);
|
||||
skipped.slice(0, 10).forEach((s) => console.log(` - ${s}`));
|
||||
}
|
||||
|
||||
// 5. Insert new entries in batches
|
||||
if (newEntries.length === 0) {
|
||||
console.log("\n✅ No new diseases to insert.");
|
||||
closeDb();
|
||||
return;
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
let inserted = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (let i = 0; i < newEntries.length; i += BATCH_SIZE) {
|
||||
const batch = newEntries.slice(i, i + BATCH_SIZE);
|
||||
const values = batch.map((entry) => {
|
||||
const causalAgent = inferCausalAgent(entry.name);
|
||||
const severity = inferSeverity(entry.name);
|
||||
return {
|
||||
id: toSlug(entry.name),
|
||||
plantId: "general",
|
||||
name: entry.name,
|
||||
scientificName: "",
|
||||
causalAgentType: causalAgent,
|
||||
description: `A plant disease known as "${entry.name}". Source: Wikipedia.`,
|
||||
symptoms: [],
|
||||
causes: [],
|
||||
treatment: [],
|
||||
prevention: [],
|
||||
lookalikeIds: [],
|
||||
severity,
|
||||
sourceUrl: entry.sourceUrl,
|
||||
imageUrl: "",
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await db.insert(diseases).values(values).onConflictDoNothing();
|
||||
inserted += values.length;
|
||||
} catch (err) {
|
||||
// Fall back to individual inserts for this batch if batch fails
|
||||
console.log(` Batch failed, trying individually...`);
|
||||
for (const val of values) {
|
||||
try {
|
||||
await db.insert(diseases).values(val).onConflictDoNothing();
|
||||
inserted++;
|
||||
} catch (e2) {
|
||||
// If it's a duplicate key, count it as skipped
|
||||
if (String(e2).includes("UNIQUE") || String(e2).includes("duplicate")) {
|
||||
// Already handled by onConflictDoNothing, shouldn't happen
|
||||
inserted++;
|
||||
} else {
|
||||
console.error(` Error inserting "${val.name}":`, e2);
|
||||
errors++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((i + BATCH_SIZE) % 200 === 0 || i + BATCH_SIZE >= newEntries.length) {
|
||||
console.log(
|
||||
` Progress: ${Math.min(i + BATCH_SIZE, newEntries.length)}/${newEntries.length} (${inserted} inserted, ${errors} errors)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Summary
|
||||
const totalDiseases = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(diseases)
|
||||
.get();
|
||||
const totalPlants = await db
|
||||
.select({ count: sql<number>`COUNT(*)` })
|
||||
.from(plants)
|
||||
.get();
|
||||
|
||||
console.log(`\n📊 Results:`);
|
||||
console.log(` Inserted: ${inserted}`);
|
||||
console.log(` Errors: ${errors}`);
|
||||
console.log(` Skipped (already existed): ${skipped.length}`);
|
||||
console.log(`\n📊 Database now has:`);
|
||||
console.log(` ${totalPlants?.count ?? 0} plants`);
|
||||
console.log(` ${totalDiseases?.count ?? 0} diseases`);
|
||||
|
||||
closeDb();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("❌ Failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
414
apps/web/scripts/fill-brave-images-v2.ts
Normal file
414
apps/web/scripts/fill-brave-images-v2.ts
Normal file
@@ -0,0 +1,414 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fill-brave-images-v2.ts — Brave Image Search for remaining disease images.
|
||||
*
|
||||
* Prioritizes by severity (critical → high → moderate → low).
|
||||
* Runs at 1 request/sec (Brave free tier rate limit).
|
||||
* Updates Turso DB directly with found images.
|
||||
* When current key is exhausted, prompts for next key.
|
||||
* Falls back to duckduckgo-images-api when all keys are spent.
|
||||
*
|
||||
* Usage:
|
||||
* cd apps/web && npx tsx scripts/fill-brave-images-v2.ts
|
||||
*
|
||||
* Pass additional API keys as args:
|
||||
* npx tsx scripts/fill-brave-images-v2.ts KEY2 KEY3
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Load env
|
||||
const envPath = resolve(__dirname, "../.env.development");
|
||||
try {
|
||||
const env = readFileSync(envPath, "utf-8");
|
||||
for (const line of env.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Also try .env.local for BRAVE_API_KEY
|
||||
try {
|
||||
const envLocal = readFileSync(resolve(__dirname, "../.env.local"), "utf-8");
|
||||
for (const line of envLocal.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith("BRAVE_API_KEY=")) {
|
||||
const val = trimmed.slice("BRAVE_API_KEY=".length).trim();
|
||||
if (!process.env.BRAVE_API_KEY) process.env.BRAVE_API_KEY = val;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
import { getDb, closeDb } from "../src/lib/db/index";
|
||||
import { diseases } from "../src/lib/db/schema";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
interface DiseaseRow {
|
||||
id: string;
|
||||
name: string;
|
||||
scientificName: string;
|
||||
severity: string;
|
||||
plantId: string;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const BRAVE_DELAY = 1100; // ms between calls (1 req/sec)
|
||||
const DB_FLUSH_BATCH = 50;
|
||||
const MAX_PER_KEY = 1800; // Leave 200 buffer of the 2000/mo limit
|
||||
const STATE_FILE = resolve(__dirname, ".brave-progress.json");
|
||||
|
||||
let currentKeyIndex = 0;
|
||||
let braveKeys: string[] = [];
|
||||
let callsThisKey = 0;
|
||||
let totalFound = 0;
|
||||
// totalSkipped tracking removed — not needed for v2
|
||||
|
||||
// ─── State persistence ───────────────────────────────────────────────────────
|
||||
|
||||
interface RunState {
|
||||
processedIds: string[];
|
||||
currentKeyIndex: number;
|
||||
callsThisKey: number;
|
||||
totalFound: number;
|
||||
}
|
||||
|
||||
function loadState(): RunState | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(processedIds: string[]) {
|
||||
writeFileSync(
|
||||
STATE_FILE,
|
||||
JSON.stringify(
|
||||
{
|
||||
processedIds,
|
||||
currentKeyIndex,
|
||||
callsThisKey,
|
||||
totalFound,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Brave API ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function braveImageSearch(query: string): Promise<string | null> {
|
||||
const key = braveKeys[currentKeyIndex];
|
||||
if (!key) return null;
|
||||
|
||||
const url = new URL("https://api.search.brave.com/res/v1/images/search");
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("count", "3");
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { "X-Subscription-Token": key, Accept: "application/json" },
|
||||
});
|
||||
|
||||
if (res.status === 429) {
|
||||
console.log("\n [RATE LIMITED] Key " + (currentKeyIndex + 1) + " exhausted!");
|
||||
return "RATE_LIMITED";
|
||||
}
|
||||
if (!res.ok) return null;
|
||||
|
||||
callsThisKey++;
|
||||
const data = (await res.json()) as {
|
||||
results?: Array<{ url: string; thumbnail?: { src?: string } }>;
|
||||
};
|
||||
const results = data?.results ?? [];
|
||||
if (results.length === 0) return null;
|
||||
|
||||
// Prefer non-stock images
|
||||
for (const r of results) {
|
||||
const src = r.thumbnail?.src ?? r.url;
|
||||
if (src && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(src)) {
|
||||
return src;
|
||||
}
|
||||
}
|
||||
return results[0].thumbnail?.src ?? results[0].url;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── DuckDuckGo fallback ────────────────────────────────────────────────────
|
||||
|
||||
async function ddgFallbackSearch(query: string): Promise<string | null> {
|
||||
try {
|
||||
// Try to use duckduckgo-images-api if installed
|
||||
const ddg = await import("duckduckgo-images-api").catch(() => null);
|
||||
if (ddg) {
|
||||
const results = await ddg.image_search({ query, moderate: true });
|
||||
if (results && results.length > 0) {
|
||||
for (const r of results) {
|
||||
if (r.image && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(r.image)) {
|
||||
return r.image;
|
||||
}
|
||||
}
|
||||
return results[0].image || null;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// duckduckgo-images-api not installed
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log("\n🔍 Brave Disease Image Filler v2\n");
|
||||
|
||||
// Parse keys from args + env
|
||||
const argsKeys = process.argv.slice(2).filter((a) => !a.startsWith("-"));
|
||||
const envKey = process.env.BRAVE_API_KEY;
|
||||
braveKeys = [envKey, ...argsKeys].filter(Boolean) as string[];
|
||||
braveKeys = [...new Set(braveKeys)]; // dedup
|
||||
|
||||
if (braveKeys.length === 0) {
|
||||
console.log("❌ No Brave API keys found.");
|
||||
console.log(" Set BRAVE_API_KEY in .env.local or pass as argument.\n");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`🔑 ${braveKeys.length} Brave API key(s) available\n`);
|
||||
|
||||
// Load state
|
||||
const state = loadState();
|
||||
if (state) {
|
||||
currentKeyIndex = state.currentKeyIndex;
|
||||
callsThisKey = state.callsThisKey;
|
||||
totalFound = state.totalFound;
|
||||
console.log(
|
||||
`📋 Resuming from previous run (${state.processedIds.length} processed, ${totalFound} found)\n`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get diseases from DB
|
||||
const db = getDb();
|
||||
const allDiseases = (await db
|
||||
.select({
|
||||
id: diseases.id,
|
||||
name: diseases.name,
|
||||
scientificName: diseases.scientificName,
|
||||
severity: diseases.severity,
|
||||
plantId: diseases.plantId,
|
||||
})
|
||||
.from(diseases)
|
||||
.where(sql`(image_url IS NULL OR image_url = '')`)
|
||||
.all()) as DiseaseRow[];
|
||||
|
||||
console.log(`📋 ${allDiseases.length} diseases need images\n`);
|
||||
|
||||
if (allDiseases.length === 0) {
|
||||
console.log("✅ All diseases already have images!\n");
|
||||
closeDb();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by severity priority
|
||||
const severityOrder = { critical: 0, high: 1, moderate: 2, low: 3 };
|
||||
allDiseases.sort(
|
||||
(a, b) =>
|
||||
(severityOrder[a.severity as keyof typeof severityOrder] || 99) -
|
||||
(severityOrder[b.severity as keyof typeof severityOrder] || 99),
|
||||
);
|
||||
|
||||
// Filter out already-processed from state
|
||||
const processedSet = new Set(state?.processedIds || []);
|
||||
const pending = allDiseases.filter((d) => !processedSet.has(d.id));
|
||||
|
||||
console.log(
|
||||
`📊 Prioritization: critical=${allDiseases.filter((d) => d.severity === "critical" && !processedSet.has(d.id)).length}, high=${allDiseases.filter((d) => d.severity === "high" && !processedSet.has(d.id)).length}, moderate=${allDiseases.filter((d) => d.severity === "moderate" && !processedSet.has(d.id)).length}, low=${allDiseases.filter((d) => d.severity === "low" && !processedSet.has(d.id)).length}\n`,
|
||||
);
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log("✅ All remaining diseases already attempted\n");
|
||||
closeDb();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = createClient({
|
||||
url: process.env.DATABASE_URL!,
|
||||
authToken: process.env.DATABASE_TOKEN!,
|
||||
});
|
||||
|
||||
let updates: Array<{ id: string; url: string }> = [];
|
||||
const processedIds: string[] = state?.processedIds || [];
|
||||
let found = totalFound;
|
||||
let ddgMode = false;
|
||||
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const d = pending[i];
|
||||
|
||||
// Check if current key needs rotating
|
||||
if (!ddgMode && callsThisKey >= MAX_PER_KEY) {
|
||||
if (currentKeyIndex < braveKeys.length - 1) {
|
||||
currentKeyIndex++;
|
||||
callsThisKey = 0;
|
||||
console.log(`\n 🔄 Rotating to key ${currentKeyIndex + 1}/${braveKeys.length}\n`);
|
||||
} else {
|
||||
console.log(
|
||||
`\n ⚠️ All ${braveKeys.length} Brave keys exhausted. Switching to DuckDuckGo fallback.\n`,
|
||||
);
|
||||
ddgMode = true;
|
||||
// Install duckduckgo-images-api if not available
|
||||
try {
|
||||
await import("duckduckgo-images-api");
|
||||
} catch {
|
||||
console.log(" Installing duckduckgo-images-api...");
|
||||
const { execSync } = await import("child_process");
|
||||
execSync("npm install duckduckgo-images-api", {
|
||||
cwd: resolve(__dirname, ".."),
|
||||
stdio: "pipe",
|
||||
});
|
||||
console.log(" Done.\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build search query
|
||||
const plantName = d.plantId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const query = `${d.name} ${d.scientificName} ${plantName} plant disease`;
|
||||
const sev = d.severity.padEnd(8);
|
||||
|
||||
process.stdout.write(
|
||||
` [${String(i + 1).padStart(4)}/${pending.length}] [${sev}] ${d.name.substring(0, 40).padEnd(42)} `,
|
||||
);
|
||||
|
||||
let url: string | null = null;
|
||||
|
||||
if (ddgMode) {
|
||||
url = await ddgFallbackSearch(query);
|
||||
if (!url) {
|
||||
// Try a simpler query
|
||||
url = await ddgFallbackSearch(`${d.name} disease`);
|
||||
}
|
||||
} else {
|
||||
url = await braveImageSearch(query);
|
||||
if (url === "RATE_LIMITED") {
|
||||
// Key exhausted mid-query, try next
|
||||
if (currentKeyIndex < braveKeys.length - 1) {
|
||||
currentKeyIndex++;
|
||||
callsThisKey = 0;
|
||||
console.log("\n 🔄 Rotating key...");
|
||||
url = await braveImageSearch(query);
|
||||
} else {
|
||||
console.log("\n ⚠️ All keys exhausted mid-batch!");
|
||||
ddgMode = true;
|
||||
url = await ddgFallbackSearch(query);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (url) {
|
||||
updates.push({ id: d.id, url });
|
||||
found++;
|
||||
processedIds.push(d.id);
|
||||
console.log("✅");
|
||||
} else {
|
||||
processedIds.push(d.id); // Mark as attempted even if not found
|
||||
console.log("❌");
|
||||
}
|
||||
|
||||
// Flush to DB
|
||||
if (updates.length >= DB_FLUSH_BATCH) {
|
||||
await raw.batch(
|
||||
updates.map((u) => ({
|
||||
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
|
||||
args: [u.url, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
console.log(` → Flushed ${updates.length} to DB`);
|
||||
updates = [];
|
||||
}
|
||||
|
||||
// Save state every 50
|
||||
if ((i + 1) % 50 === 0) {
|
||||
saveState(processedIds);
|
||||
}
|
||||
|
||||
// Rate limit (even for DDG to be polite)
|
||||
await new Promise((r) => setTimeout(r, ddgMode ? 500 : BRAVE_DELAY));
|
||||
}
|
||||
|
||||
// Final flush
|
||||
if (updates.length > 0) {
|
||||
await raw.batch(
|
||||
updates.map((u) => ({
|
||||
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
|
||||
args: [u.url, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
console.log(` → Flushed ${updates.length} to DB`);
|
||||
}
|
||||
|
||||
saveState(processedIds);
|
||||
raw.close();
|
||||
|
||||
// Final report
|
||||
const finalList = await db
|
||||
.select({ id: diseases.id, name: diseases.name, imageUrl: diseases.imageUrl })
|
||||
.from(diseases)
|
||||
.all();
|
||||
const w = finalList.filter((d) => d.imageUrl);
|
||||
const wo = finalList.filter((d) => !d.imageUrl);
|
||||
|
||||
console.log(`\n${"═".repeat(50)}`);
|
||||
console.log(`📊 BRAVE IMAGE SEARCH COMPLETE`);
|
||||
console.log(`${"═".repeat(50)}`);
|
||||
console.log(` Processed: ${pending.length}`);
|
||||
console.log(` Found this run: ${found - totalFound}`);
|
||||
console.log(` Total with images: ${w.length}/${finalList.length}`);
|
||||
console.log(` Still missing: ${wo.length}`);
|
||||
console.log(` Brave keys used: ${currentKeyIndex + 1}`);
|
||||
console.log(` Calls on current key: ${callsThisKey}`);
|
||||
console.log(` DuckDuckGo mode: ${ddgMode}`);
|
||||
|
||||
if (wo.length > 0) {
|
||||
const rp = resolve(__dirname, ".disease-image-review-needed.md");
|
||||
let report = "# Disease Images - Still Missing\n\n";
|
||||
report += `Generated: ${new Date().toISOString()}\n\n`;
|
||||
report += `## Summary\n\n`;
|
||||
report += `- Total: ${finalList.length}\n`;
|
||||
report += `- With images: ${w.length}\n`;
|
||||
report += `- Still missing: ${wo.length}\n\n`;
|
||||
report += `## Missing Diseases\n\n`;
|
||||
for (const d of wo) {
|
||||
report += `- ${d.name} (\`${d.id}\`)\n`;
|
||||
}
|
||||
writeFileSync(rp, report, "utf-8");
|
||||
console.log(`\n📝 Report: ${rp}`);
|
||||
} else {
|
||||
console.log("\n✅ ALL diseases now have images!");
|
||||
}
|
||||
|
||||
closeDb();
|
||||
console.log("\n");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌", err);
|
||||
process.exit(1);
|
||||
});
|
||||
152
apps/web/scripts/fill-brave-images.ts
Normal file
152
apps/web/scripts/fill-brave-images.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fill-brave-images.ts — Brave-only pass for remaining disease images.
|
||||
*
|
||||
* Runs at 1 request/sec (Brave rate limit).
|
||||
* Updates diseases.json and Turso DB.
|
||||
*
|
||||
* Usage: cd apps/web && npx tsx scripts/fill-brave-images.ts
|
||||
*/
|
||||
|
||||
import dotenv from "dotenv"; dotenv.config({ path: resolve(__dirname, "../.env.local") });
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { closeDb } from "../src/lib/db/index";
|
||||
|
||||
const DISEASES_JSON = resolve(__dirname, "../src/data/diseases.json");
|
||||
const BRAVE_KEY = process.env.BRAVE_API_KEY ?? "";
|
||||
|
||||
interface DiseaseSeed {
|
||||
id: string;
|
||||
plantId: string;
|
||||
name: string;
|
||||
scientificName: string;
|
||||
imageUrl?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function load(): DiseaseSeed[] {
|
||||
return JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
|
||||
}
|
||||
|
||||
async function searchBraveImage(query: string): Promise<string | null> {
|
||||
const url = new URL("https://api.search.brave.com/res/v1/images/search");
|
||||
url.searchParams.set("q", query);
|
||||
url.searchParams.set("count", "3");
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: { "X-Subscription-Token": BRAVE_KEY, Accept: "application/json" },
|
||||
});
|
||||
if (res.status === 429) {
|
||||
await new Promise((r) => setTimeout(r, 5000 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as {
|
||||
results?: Array<{ url: string; thumbnail?: { src?: string } }>;
|
||||
};
|
||||
const results = data?.results ?? [];
|
||||
if (results.length === 0) return null;
|
||||
|
||||
// Prefer non-stock direct-looking images
|
||||
for (const r of results) {
|
||||
const src = r.thumbnail?.src ?? r.url;
|
||||
if (src && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(src)) return src;
|
||||
}
|
||||
return results[0].thumbnail?.src ?? results[0].url;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("\n🔍 Brave Image Search — remaining disease images\n");
|
||||
|
||||
if (!BRAVE_KEY) {
|
||||
console.log("❌ No BRAVE_API_KEY in .env.local\n");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const diseases = load();
|
||||
const pending = diseases.filter((d) => !d.imageUrl);
|
||||
console.log(`📋 ${pending.length} diseases need images\n`);
|
||||
|
||||
let found = 0;
|
||||
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const d = pending[i];
|
||||
const plant = diseases.find((p) => p.id === d.plantId);
|
||||
const plantName = plant?.name ?? d.plantId;
|
||||
const query = `${d.name} ${plantName} plant disease symptom`;
|
||||
|
||||
process.stdout.write(` [${String(i + 1).padStart(2, " ")}/${pending.length}] ${d.name.padEnd(35)} `);
|
||||
|
||||
const url = await searchBraveImage(query);
|
||||
if (url) {
|
||||
d.imageUrl = url;
|
||||
found++;
|
||||
console.log(`✅`);
|
||||
} else {
|
||||
console.log(`❌`);
|
||||
}
|
||||
|
||||
// 1 req/sec rate limit
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
}
|
||||
|
||||
// Write updated JSON
|
||||
writeFileSync(DISEASES_JSON, JSON.stringify(diseases, null, 2) + "\n", "utf-8");
|
||||
console.log(`\n✅ diseases.json updated: ${found}/${pending.length} images found\n`);
|
||||
|
||||
// Update DB
|
||||
try {
|
||||
const dbUrl = process.env.DATABASE_URL;
|
||||
const dbToken = process.env.DATABASE_TOKEN;
|
||||
if (dbUrl && dbToken) {
|
||||
const raw = createClient({ url: dbUrl, authToken: dbToken });
|
||||
const updates = pending.filter((d) => d.imageUrl);
|
||||
for (let i = 0; i < updates.length; i += 50) {
|
||||
await raw.batch(
|
||||
updates.slice(i, i + 50).map((d) => ({
|
||||
sql: "UPDATE diseases SET image_url = ? WHERE id = ?",
|
||||
args: [d.imageUrl!, d.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
}
|
||||
raw.close();
|
||||
console.log(`✅ Turso DB updated: ${updates.length} rows`);
|
||||
} else {
|
||||
console.log("⏭️ Skipping DB — no DATABASE_URL/TOKEN");
|
||||
}
|
||||
} catch (err) {
|
||||
console.log(` ⚠️ DB: ${err instanceof Error ? err.message : err}`);
|
||||
}
|
||||
|
||||
// Summary
|
||||
const finalDiseases = JSON.parse(readFileSync(DISEASES_JSON, "utf-8")) as DiseaseSeed[];
|
||||
const stillMissing = finalDiseases.filter((d) => !d.imageUrl);
|
||||
console.log(`\n${"═".repeat(50)}`);
|
||||
console.log(`📊 FINAL: ${finalDiseases.length} total`);
|
||||
console.log(` With images: ${finalDiseases.length - stillMissing.length}`);
|
||||
console.log(` Still missing: ${stillMissing.length}`);
|
||||
if (stillMissing.length > 0) {
|
||||
console.log(`\nStill need human curation:`);
|
||||
for (const d of stillMissing) {
|
||||
console.log(` ❌ ${d.name} (${d.id})`);
|
||||
}
|
||||
}
|
||||
console.log(`${"═".repeat(50)}\n`);
|
||||
|
||||
closeDb();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
266
apps/web/scripts/fill-ddg-images.ts
Normal file
266
apps/web/scripts/fill-ddg-images.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fill-ddg-images.ts — DuckDuckGo Image Search for remaining disease images.
|
||||
*
|
||||
* No API key needed. Searches DuckDuckGo Images API for each disease
|
||||
* without an image and updates the Turso DB.
|
||||
*
|
||||
* Prioritizes by severity (critical → high → moderate → low).
|
||||
* Runs at 1 request/sec to be polite to DuckDuckGo.
|
||||
* Resumable via state file (scripts/.ddg-progress.json).
|
||||
*
|
||||
* Usage:
|
||||
* cd apps/web && npx tsx scripts/fill-ddg-images.ts
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Load .env.development for DB creds
|
||||
const envPath = resolve(__dirname, "../.env.development");
|
||||
try {
|
||||
const env = readFileSync(envPath, "utf-8");
|
||||
for (const line of env.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
import { getDb, closeDb } from "../src/lib/db/index";
|
||||
import { diseases } from "../src/lib/db/schema";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// DuckDuckGo
|
||||
import { imageSearch } from "@mudbill/duckduckgo-images-api";
|
||||
|
||||
interface DiseaseRow {
|
||||
id: string;
|
||||
name: string;
|
||||
scientificName: string;
|
||||
severity: string;
|
||||
plantId: string;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const POLITE_DELAY = 1100; // ms between calls
|
||||
const DB_FLUSH_BATCH = 50;
|
||||
const STATE_FILE = resolve(__dirname, ".ddg-progress.json");
|
||||
|
||||
interface RunState {
|
||||
processedIds: string[];
|
||||
totalFound: number;
|
||||
}
|
||||
|
||||
function loadState(): RunState | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(STATE_FILE, "utf-8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveState(processedIds: string[], totalFound: number) {
|
||||
writeFileSync(STATE_FILE, JSON.stringify({ processedIds, totalFound }, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
// ─── DuckDuckGo Search ───────────────────────────────────────────────────────
|
||||
|
||||
async function searchImage(query: string): Promise<string | null> {
|
||||
try {
|
||||
const results = await imageSearch({ query, safe: true, iterations: 1, retries: 2 });
|
||||
if (!results || results.length === 0) return null;
|
||||
|
||||
// Prefer non-stock images
|
||||
for (const r of results) {
|
||||
if (r.image && !/(dreamstime|shutterstock|alamy|istock|123rf)/i.test(r.image)) {
|
||||
return r.image;
|
||||
}
|
||||
}
|
||||
return results[0].image || results[0].thumbnail || null;
|
||||
} catch {
|
||||
// DuckDuckGo may block or timeout; silently skip
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.log("\n🦆 DuckDuckGo Disease Image Filler\n");
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Load state
|
||||
const state = loadState();
|
||||
const processedSet = new Set(state?.processedIds || []);
|
||||
const totalFoundPrev = state?.totalFound ?? 0;
|
||||
|
||||
// Get all diseases that still need images
|
||||
const allDiseases = (await db
|
||||
.select({
|
||||
id: diseases.id,
|
||||
name: diseases.name,
|
||||
scientificName: diseases.scientificName,
|
||||
severity: diseases.severity,
|
||||
plantId: diseases.plantId,
|
||||
})
|
||||
.from(diseases)
|
||||
.where(sql`(image_url IS NULL OR image_url = '')`)
|
||||
.all()) as DiseaseRow[];
|
||||
|
||||
console.log(`📋 ${allDiseases.length} diseases need images\n`);
|
||||
|
||||
if (allDiseases.length === 0) {
|
||||
console.log("✅ All diseases already have images!\n");
|
||||
closeDb();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by severity: critical > high > moderate > low
|
||||
const severityOrder: Record<string, number> = { critical: 0, high: 1, moderate: 2, low: 3 };
|
||||
allDiseases.sort((a, b) => (severityOrder[a.severity] ?? 99) - (severityOrder[b.severity] ?? 99));
|
||||
|
||||
// Filter out already-processed
|
||||
const pending = allDiseases.filter((d) => !processedSet.has(d.id));
|
||||
|
||||
console.log(
|
||||
`📊 Remaining: critical=${allDiseases.filter((d) => d.severity === "critical" && !processedSet.has(d.id)).length}, ` +
|
||||
`high=${allDiseases.filter((d) => d.severity === "high" && !processedSet.has(d.id)).length}, ` +
|
||||
`moderate=${allDiseases.filter((d) => d.severity === "moderate" && !processedSet.has(d.id)).length}, ` +
|
||||
`low=${allDiseases.filter((d) => d.severity === "low" && !processedSet.has(d.id)).length}\n`,
|
||||
);
|
||||
|
||||
if (pending.length === 0) {
|
||||
console.log("✅ All remaining diseases already attempted\n");
|
||||
closeDb();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = createClient({
|
||||
url: process.env.DATABASE_URL!,
|
||||
authToken: process.env.DATABASE_TOKEN!,
|
||||
});
|
||||
|
||||
const processedIds: string[] = state?.processedIds ?? [];
|
||||
let found = totalFoundPrev;
|
||||
let updates: Array<{ id: string; url: string }> = [];
|
||||
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
const d = pending[i];
|
||||
const sev = d.severity.padEnd(8);
|
||||
|
||||
// Build search query — "[disease] on [plant]" phrasing for better specificity
|
||||
const plantName = d.plantId.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
const query1 = `${d.name} on ${plantName} plant disease`;
|
||||
const query2 = `${d.scientificName || d.name} on ${plantName} disease`;
|
||||
const query3 = `${d.name} plant disease ${plantName}`;
|
||||
|
||||
process.stdout.write(
|
||||
` [${String(i + 1).padStart(4)}/${pending.length}] [${sev}] ${d.name.substring(0, 42).padEnd(44)} `,
|
||||
);
|
||||
|
||||
// Try queries in order until we get a result
|
||||
let url: string | null = null;
|
||||
for (const q of [query1, query2, query3]) {
|
||||
url = await searchImage(q);
|
||||
if (url) break;
|
||||
}
|
||||
|
||||
if (url) {
|
||||
updates.push({ id: d.id, url });
|
||||
found++;
|
||||
processedIds.push(d.id);
|
||||
console.log("✅");
|
||||
} else {
|
||||
processedIds.push(d.id);
|
||||
console.log("❌");
|
||||
}
|
||||
|
||||
// Flush to DB in batches
|
||||
if (updates.length >= DB_FLUSH_BATCH) {
|
||||
await raw.batch(
|
||||
updates.map((u) => ({
|
||||
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
|
||||
args: [u.url, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
console.log(` → Flushed ${updates.length} to DB`);
|
||||
updates = [];
|
||||
}
|
||||
|
||||
// Save state every 50
|
||||
if ((i + 1) % 50 === 0) {
|
||||
saveState(processedIds, found);
|
||||
}
|
||||
|
||||
// Be polite — 1 req/sec
|
||||
await new Promise((r) => setTimeout(r, POLITE_DELAY));
|
||||
}
|
||||
|
||||
// Final flush
|
||||
if (updates.length > 0) {
|
||||
await raw.batch(
|
||||
updates.map((u) => ({
|
||||
sql: "UPDATE diseases SET image_url = ?, updated_at = datetime() WHERE id = ?",
|
||||
args: [u.url, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
console.log(` → Flushed ${updates.length} to DB`);
|
||||
}
|
||||
|
||||
saveState(processedIds, found);
|
||||
raw.close();
|
||||
|
||||
// Final report
|
||||
const finalList = await db
|
||||
.select({ id: diseases.id, name: diseases.name, imageUrl: diseases.imageUrl })
|
||||
.from(diseases)
|
||||
.all();
|
||||
const w = finalList.filter((d) => d.imageUrl);
|
||||
const wo = finalList.filter((d) => !d.imageUrl);
|
||||
|
||||
console.log(`\n${"═".repeat(50)}`);
|
||||
console.log(`🦆 DUCKDUCKGO SEARCH COMPLETE`);
|
||||
console.log(`${"═".repeat(50)}`);
|
||||
console.log(` Processed: ${pending.length}`);
|
||||
console.log(` Found this run: ${found - totalFoundPrev}`);
|
||||
console.log(` Total with images: ${w.length}/${finalList.length}`);
|
||||
console.log(` Still missing: ${wo.length}`);
|
||||
|
||||
if (wo.length > 0) {
|
||||
const reportPath = resolve(__dirname, ".ddg-image-review-needed.md");
|
||||
let report = "# Disease Images - Still Missing (DDG)\n\n";
|
||||
report += `Generated: ${new Date().toISOString()}\n\n`;
|
||||
report += `## Summary\n\n`;
|
||||
report += `- Total: ${finalList.length}\n`;
|
||||
report += `- With images: ${w.length}\n`;
|
||||
report += `- Still missing: ${wo.length}\n\n`;
|
||||
report += `## Missing Diseases\n\n`;
|
||||
for (const d of wo) {
|
||||
report += `- ${d.name} (\`${d.id}\`)\n`;
|
||||
}
|
||||
writeFileSync(reportPath, report, "utf-8");
|
||||
console.log(`\n📝 Missing report: ${reportPath}`);
|
||||
} else {
|
||||
console.log("\n✅ ALL diseases now have images!");
|
||||
}
|
||||
|
||||
closeDb();
|
||||
console.log();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌ Fatal:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
301
apps/web/scripts/fill-plant-images-v2.ts
Normal file
301
apps/web/scripts/fill-plant-images-v2.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fill-plant-images-v2.ts — Batch Wikipedia image fetch for remaining plants.
|
||||
*
|
||||
* Phase 1: Query 50 scientific names at a time via pageimages.
|
||||
* Phase 2: Query 50 common names at a time.
|
||||
* Phase 3: Search individually for stragglers.
|
||||
*
|
||||
* Usage: cd apps/web && npx tsx scripts/fill-plant-images-v2.ts
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Load env
|
||||
const envPath = resolve(__dirname, "../.env.development");
|
||||
try {
|
||||
const env = readFileSync(envPath, "utf-8");
|
||||
for (const line of env.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
if (!process.env[key]) {
|
||||
process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
import { getDb, closeDb } from "../src/lib/db/index";
|
||||
import { plants } from "../src/lib/db/schema";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const API = "https://en.wikipedia.org/w/api.php";
|
||||
const UA = "PlantHealthKB/1.0";
|
||||
const BATCH = 50;
|
||||
|
||||
interface PlantRow {
|
||||
id: string;
|
||||
commonName: string;
|
||||
scientificName: string;
|
||||
}
|
||||
|
||||
function clean(s: string): string {
|
||||
return s
|
||||
.replace(/[xX]/g, "x")
|
||||
.replace(/\s*spp\.?\s*/gi, "")
|
||||
.replace(/[.\u00d7']/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
async function fetchThumbs(titles: string[]): Promise<Map<string, string>> {
|
||||
if (titles.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const p = new URLSearchParams({
|
||||
action: "query",
|
||||
titles: titles.join("|"),
|
||||
prop: "pageimages",
|
||||
pithumbsize: "400",
|
||||
redirects: "1",
|
||||
format: "json",
|
||||
});
|
||||
for (let a = 0; a < 3; a++) {
|
||||
try {
|
||||
const r = await fetch(API + "?" + p.toString(), {
|
||||
headers: { "User-Agent": UA },
|
||||
});
|
||||
if (r.status === 429) {
|
||||
await new Promise((rr) => setTimeout(rr, 5000 * Math.pow(2, a)));
|
||||
continue;
|
||||
}
|
||||
if (!r.ok) {
|
||||
return new Map();
|
||||
}
|
||||
const d = (await r.json()) as any;
|
||||
const pages = d?.query?.pages;
|
||||
if (!pages) {
|
||||
return new Map();
|
||||
}
|
||||
const m = new Map<string, string>();
|
||||
for (const [, pg] of Object.entries(pages)) {
|
||||
const p2 = pg as any;
|
||||
if (!p2.missing && p2.thumbnail?.source) {
|
||||
m.set(p2.title.toLowerCase(), p2.thumbnail.source);
|
||||
}
|
||||
}
|
||||
return m;
|
||||
} catch (e) {
|
||||
await new Promise((rr) => setTimeout(rr, 2000));
|
||||
}
|
||||
}
|
||||
return new Map();
|
||||
}
|
||||
|
||||
async function searchOne(query: string): Promise<string | null> {
|
||||
const p = new URLSearchParams({
|
||||
action: "query",
|
||||
generator: "search",
|
||||
gsrsearch: query,
|
||||
gsrlimit: "3",
|
||||
prop: "pageimages",
|
||||
pithumbsize: "400",
|
||||
format: "json",
|
||||
});
|
||||
for (let a = 0; a < 3; a++) {
|
||||
try {
|
||||
const r = await fetch(API + "?" + p.toString(), {
|
||||
headers: { "User-Agent": UA },
|
||||
});
|
||||
if (r.status === 429) {
|
||||
await new Promise((rr) => setTimeout(rr, 5000 * Math.pow(2, a)));
|
||||
continue;
|
||||
}
|
||||
if (!r.ok) {
|
||||
return null;
|
||||
}
|
||||
const d = (await r.json()) as any;
|
||||
const pages = d?.query?.pages;
|
||||
if (!pages) {
|
||||
return null;
|
||||
}
|
||||
for (const [, pg] of Object.entries(pages)) {
|
||||
const p2 = pg as any;
|
||||
if (p2.thumbnail?.source) {
|
||||
return p2.thumbnail.source;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
await new Promise((rr) => setTimeout(rr, 2000));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function batchPhase(
|
||||
plants: PlantRow[],
|
||||
titleFn: (p: PlantRow) => string,
|
||||
label: string,
|
||||
dbClient: any,
|
||||
): Promise<PlantRow[]> {
|
||||
const remaining: PlantRow[] = [];
|
||||
const updates: Array<{ id: string; url: string }> = [];
|
||||
|
||||
for (let i = 0; i < plants.length; i += BATCH) {
|
||||
const chunk = plants.slice(i, i + BATCH);
|
||||
const titles = chunk.map(titleFn).filter((t) => t.length > 2);
|
||||
console.log(
|
||||
" [" +
|
||||
label +
|
||||
"] " +
|
||||
(i + 1) +
|
||||
"-" +
|
||||
Math.min(i + BATCH, plants.length) +
|
||||
"/" +
|
||||
plants.length +
|
||||
" ",
|
||||
);
|
||||
const imageMap = await fetchThumbs(titles);
|
||||
let n = 0;
|
||||
for (const pl of chunk) {
|
||||
const t = titleFn(pl).toLowerCase();
|
||||
const img = imageMap.get(t);
|
||||
if (img) {
|
||||
updates.push({ id: pl.id, url: img });
|
||||
n++;
|
||||
} else {
|
||||
remaining.push(pl);
|
||||
}
|
||||
}
|
||||
console.log(" found: " + n);
|
||||
if (updates.length >= 100) {
|
||||
await dbClient.batch(
|
||||
updates.map((u) => ({
|
||||
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
args: [u.url, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
updates.length = 0;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
await dbClient.batch(
|
||||
updates.map((u) => ({
|
||||
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
args: [u.url, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
}
|
||||
|
||||
return remaining;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("\nPlant Image Filler v2\n");
|
||||
const db = getDb();
|
||||
const allPlants = (await db
|
||||
.select({
|
||||
id: plants.id,
|
||||
commonName: plants.commonName,
|
||||
scientificName: plants.scientificName,
|
||||
})
|
||||
.from(plants)
|
||||
.where(sql`(image_url IS NULL OR image_url = '')`)
|
||||
.all()) as PlantRow[];
|
||||
|
||||
console.log("Plants needing images: " + allPlants.length + "\n");
|
||||
if (allPlants.length === 0) {
|
||||
console.log("All plants have images!\n");
|
||||
closeDb();
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = createClient({
|
||||
url: process.env.DATABASE_URL!,
|
||||
authToken: process.env.DATABASE_TOKEN!,
|
||||
});
|
||||
let found = 0;
|
||||
|
||||
// Phase 1: Scientific name
|
||||
console.log("--- Phase 1: Scientific names ---\n");
|
||||
let remaining = await batchPhase(allPlants, (p) => clean(p.scientificName), "sci", raw);
|
||||
|
||||
// Phase 2: Common name
|
||||
if (remaining.length > 0) {
|
||||
console.log("\n--- Phase 2: Common names (" + remaining.length + ") ---\n");
|
||||
remaining = await batchPhase(remaining, (p) => p.commonName, "common", raw);
|
||||
}
|
||||
|
||||
// Phase 3: Search
|
||||
if (remaining.length > 0) {
|
||||
console.log("\n--- Phase 3: Search (" + remaining.length + ") ---\n");
|
||||
for (let i = 0; i < remaining.length; i++) {
|
||||
const pl = remaining[i];
|
||||
const q = clean(pl.scientificName) + " " + pl.commonName;
|
||||
console.log(" [" + (i + 1) + "/" + remaining.length + "] " + pl.commonName);
|
||||
const img = await searchOne(q);
|
||||
if (img) {
|
||||
await raw.execute({
|
||||
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
args: [img, pl.id],
|
||||
});
|
||||
found++;
|
||||
console.log(" OK");
|
||||
} else {
|
||||
console.log(" MISS");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 500));
|
||||
}
|
||||
}
|
||||
|
||||
raw.close();
|
||||
|
||||
// Report
|
||||
const finalList = await db
|
||||
.select({
|
||||
id: plants.id,
|
||||
commonName: plants.commonName,
|
||||
imageUrl: plants.imageUrl,
|
||||
})
|
||||
.from(plants)
|
||||
.all();
|
||||
const w = finalList.filter((p) => p.imageUrl);
|
||||
const wo = finalList.filter((p) => !p.imageUrl);
|
||||
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("FINAL: " + finalList.length + " plants");
|
||||
console.log(" With images: " + w.length);
|
||||
console.log(" Missing: " + wo.length);
|
||||
|
||||
if (wo.length > 0) {
|
||||
const rp = resolve(__dirname, ".plant-image-review-needed.md");
|
||||
let report = "# Plant Images - Still Missing\n\n";
|
||||
report += "Generated: " + new Date().toISOString() + "\n\n";
|
||||
report += "## Missing (" + wo.length + ")\n\n";
|
||||
for (const p of wo) {
|
||||
report += "- " + p.commonName + " (" + p.id + ")\n";
|
||||
}
|
||||
writeFileSync(rp, report, "utf-8");
|
||||
console.log("Report: " + rp);
|
||||
} else {
|
||||
console.log("\nALL PLANTS HAVE IMAGES!");
|
||||
}
|
||||
|
||||
closeDb();
|
||||
}
|
||||
|
||||
main().catch((err: any) => {
|
||||
console.error("Error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
308
apps/web/scripts/fill-plant-images.ts
Normal file
308
apps/web/scripts/fill-plant-images.ts
Normal file
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fill-plant-images.ts — Fetch plant images from Wikipedia for plants missing them.
|
||||
*
|
||||
* Uses the Wikipedia API to search for the plant's scientific name
|
||||
* and grab the page thumbnail.
|
||||
*
|
||||
* Usage: cd apps/web && npx tsx scripts/fill-plant-images.ts
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Load env
|
||||
const envPath = resolve(__dirname, "../.env.development");
|
||||
try {
|
||||
const env = readFileSync(envPath, "utf-8");
|
||||
for (const line of env.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
import { getDb, closeDb } from "../src/lib/db/index";
|
||||
import { plants } from "../src/lib/db/schema";
|
||||
import { createClient } from "@libsql/client";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
const WIKI_API = "https://en.wikipedia.org/w/api.php";
|
||||
const UA = "PlantHealthKB/1.0 (plant-images)";
|
||||
const DELAY_MS = 500;
|
||||
const BATCH_SIZE = 50;
|
||||
|
||||
/** Direct page lookup by title — more reliable for known scientific names. */
|
||||
async function directPageLookup(title: string): Promise<string | null> {
|
||||
const params = new URLSearchParams({
|
||||
action: "query",
|
||||
titles: title,
|
||||
prop: "pageimages",
|
||||
pithumbsize: "400",
|
||||
format: "json",
|
||||
origin: "*",
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${WIKI_API}?${params}`, {
|
||||
headers: { "User-Agent": UA },
|
||||
});
|
||||
if (res.status === 429) {
|
||||
await new Promise((r) => setTimeout(r, 3000 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as {
|
||||
query?: { pages?: Record<string, { thumbnail?: { source: string }; missing?: boolean }> };
|
||||
};
|
||||
const pages = data?.query?.pages;
|
||||
if (!pages) return null;
|
||||
for (const [, p] of Object.entries(pages)) {
|
||||
if (!p.missing && p.thumbnail?.source) return p.thumbnail.source;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("\n🌿 Fetching plant images from Wikipedia\n");
|
||||
|
||||
const db = getDb();
|
||||
const allPlants = await db
|
||||
.select({ id: plants.id, commonName: plants.commonName, scientificName: plants.scientificName })
|
||||
.from(plants)
|
||||
.where(sql`(image_url IS NULL OR image_url = '')`)
|
||||
.all();
|
||||
|
||||
console.log(`📋 ${allPlants.length} plants need images\n`);
|
||||
|
||||
if (allPlants.length === 0) {
|
||||
console.log("✅ All plants already have images!\n");
|
||||
closeDb();
|
||||
return;
|
||||
}
|
||||
|
||||
const rawClient = createClient({
|
||||
url: process.env.DATABASE_URL!,
|
||||
authToken: process.env.DATABASE_TOKEN!,
|
||||
});
|
||||
|
||||
let found = 0;
|
||||
const updates: { id: string; url: string }[] = [];
|
||||
|
||||
// Phase 1: Try direct page lookup by scientific name (most accurate)
|
||||
console.log("─── Phase 1: Direct page lookup ───\n");
|
||||
|
||||
for (let i = 0; i < allPlants.length; i++) {
|
||||
const plant = allPlants[i];
|
||||
const sciName = plant.scientificName
|
||||
.replace(/[×'"]/g, "")
|
||||
.replace(/\s*spp\.?\s*/i, "")
|
||||
.trim();
|
||||
|
||||
process.stdout.write(
|
||||
` [${String(i + 1).padStart(3)}/${allPlants.length}] ${plant.commonName.padEnd(30)} `,
|
||||
);
|
||||
|
||||
let url: string | null = null;
|
||||
|
||||
// Try scientific name first
|
||||
if (sciName && sciName !== "Unknown" && sciName !== "Various") {
|
||||
url = await directPageLookup(sciName);
|
||||
}
|
||||
|
||||
// Try common name if scientific name didn't work
|
||||
if (!url) {
|
||||
url = await directPageLookup(plant.commonName);
|
||||
}
|
||||
|
||||
// Try genus name
|
||||
if (!url && sciName) {
|
||||
const genus = sciName.split(/\s+/)[0];
|
||||
if (genus && genus.length > 3) {
|
||||
url = await directPageLookup(genus);
|
||||
}
|
||||
}
|
||||
|
||||
if (url) {
|
||||
updates.push({ id: plant.id, url });
|
||||
found++;
|
||||
process.stdout.write("✅\n");
|
||||
} else {
|
||||
process.stdout.write("⏭️\n");
|
||||
}
|
||||
|
||||
// Flush to DB in batches
|
||||
if (updates.length >= BATCH_SIZE) {
|
||||
await rawClient.batch(
|
||||
updates.map((u) => ({
|
||||
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
args: [u.url, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
console.log(` → Flushed ${updates.length} to DB`);
|
||||
updates.length = 0;
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, DELAY_MS));
|
||||
}
|
||||
|
||||
// Flush remaining
|
||||
if (updates.length > 0) {
|
||||
await rawClient.batch(
|
||||
updates.map((u) => ({
|
||||
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
args: [u.url, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
console.log(` → Flushed ${updates.length} to DB`);
|
||||
updates.length = 0;
|
||||
}
|
||||
|
||||
console.log(`\n✅ Phase 1 done: ${found}/${allPlants.length} plants got images\n`);
|
||||
|
||||
// Phase 2: Try remaining via search API
|
||||
const stillMissing = await db
|
||||
.select({ id: plants.id, commonName: plants.commonName, scientificName: plants.scientificName })
|
||||
.from(plants)
|
||||
.where(sql`(image_url IS NULL OR image_url = '')`)
|
||||
.all();
|
||||
|
||||
if (stillMissing.length > 0) {
|
||||
console.log(`─── Phase 2: Search API for ${stillMissing.length} remaining ───\n`);
|
||||
|
||||
for (let i = 0; i < stillMissing.length; i++) {
|
||||
const plant = stillMissing[i];
|
||||
const sciName = plant.scientificName.replace(/[×'"]/g, "").trim();
|
||||
|
||||
process.stdout.write(
|
||||
` [${String(i + 1).padStart(3)}/${stillMissing.length}] ${plant.commonName.padEnd(30)} `,
|
||||
);
|
||||
|
||||
// Search with scientific name
|
||||
const searchTerm = `${sciName} ${plant.commonName}`;
|
||||
const params = new URLSearchParams({
|
||||
action: "query",
|
||||
list: "search",
|
||||
srsearch: searchTerm,
|
||||
srlimit: "3",
|
||||
format: "json",
|
||||
origin: "*",
|
||||
});
|
||||
|
||||
let url: string | null = null;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch(`${WIKI_API}?${params}`, {
|
||||
headers: { "User-Agent": UA },
|
||||
});
|
||||
if (res.status === 429) {
|
||||
await new Promise((r) => setTimeout(r, 3000 * 2 ** attempt));
|
||||
continue;
|
||||
}
|
||||
if (!res.ok) break;
|
||||
const data = (await res.json()) as {
|
||||
query?: { search?: Array<{ title: string; pageid: number }> };
|
||||
};
|
||||
const hits = data?.query?.search ?? [];
|
||||
if (hits.length === 0) break;
|
||||
|
||||
// Get thumbnail for first result
|
||||
for (const hit of hits) {
|
||||
const pageParams = new URLSearchParams({
|
||||
action: "query",
|
||||
pageids: String(hit.pageid),
|
||||
prop: "pageimages",
|
||||
pithumbsize: "400",
|
||||
format: "json",
|
||||
origin: "*",
|
||||
});
|
||||
const pageRes = await fetch(`${WIKI_API}?${pageParams}`, {
|
||||
headers: { "User-Agent": UA },
|
||||
});
|
||||
if (!pageRes.ok) continue;
|
||||
const pageData = (await pageRes.json()) as {
|
||||
query?: { pages?: Record<string, { thumbnail?: { source: string } }> };
|
||||
};
|
||||
const pages = pageData?.query?.pages;
|
||||
if (!pages) continue;
|
||||
for (const [, p] of Object.entries(pages)) {
|
||||
if (p.thumbnail?.source) {
|
||||
url = p.thumbnail.source;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (url) break;
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
if (url) {
|
||||
await rawClient.execute({
|
||||
sql: "UPDATE plants SET image_url = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
args: [url, plant.id],
|
||||
});
|
||||
found++;
|
||||
process.stdout.write("✅\n");
|
||||
} else {
|
||||
process.stdout.write("❌\n");
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, DELAY_MS));
|
||||
}
|
||||
}
|
||||
|
||||
// Final count
|
||||
const final = await db
|
||||
.select({ id: plants.id, commonName: plants.commonName, imageUrl: plants.imageUrl })
|
||||
.from(plants)
|
||||
.all();
|
||||
const withImg = final.filter((p) => p.imageUrl);
|
||||
const withoutImg = final.filter((p) => !p.imageUrl);
|
||||
|
||||
console.log(`\n${"═".repeat(50)}`);
|
||||
console.log(`📊 FINAL: ${final.length} plants`);
|
||||
console.log(` With images: ${withImg.length}`);
|
||||
console.log(` Missing images: ${withoutImg.length}`);
|
||||
|
||||
if (withoutImg.length > 0) {
|
||||
console.log(`\n📝 Plants still needing images:`);
|
||||
withoutImg.forEach((p) => console.log(` ❌ ${p.id}: ${p.commonName}`));
|
||||
// Save to file for reference
|
||||
const reportPath = resolve(__dirname, ".plant-image-review-needed.md");
|
||||
let report = "# Plant Images — Still Missing\n\n";
|
||||
report += `Generated: ${new Date().toISOString()}\n\n`;
|
||||
report += `## 🚫 Plants without images (${withoutImg.length})\n\n`;
|
||||
for (const p of withoutImg) {
|
||||
report += `- **${p.commonName}** (\`${p.id}\`)\n`;
|
||||
}
|
||||
writeFileSync(reportPath, report, "utf-8");
|
||||
console.log(` 📝 Review report: ${reportPath}`);
|
||||
} else {
|
||||
console.log("\n✅ All plants now have images!");
|
||||
}
|
||||
|
||||
rawClient.close();
|
||||
closeDb();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌", err);
|
||||
process.exit(1);
|
||||
});
|
||||
212
apps/web/scripts/fix-classifications.ts
Normal file
212
apps/web/scripts/fix-classifications.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* fix-classifications.ts — Fix misclassified diseases in the DB.
|
||||
*
|
||||
* Fixes:
|
||||
* 1. Diseases named with viral indicators (mosaic, mottle, ringspot, virus, etc.)
|
||||
* that are incorrectly tagged as "fungal"
|
||||
* 2. Other suspicious patterns
|
||||
*
|
||||
* Usage: cd apps/web && npx tsx scripts/fix-classifications.ts
|
||||
*/
|
||||
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
|
||||
// Manually load .env.development
|
||||
const envPath = resolve(__dirname, "../.env.development");
|
||||
try {
|
||||
const env = readFileSync(envPath, "utf-8");
|
||||
for (const line of env.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && !trimmed.startsWith("#")) {
|
||||
const eqIdx = trimmed.indexOf("=");
|
||||
if (eqIdx > 0) {
|
||||
const key = trimmed.slice(0, eqIdx).trim();
|
||||
const val = trimmed.slice(eqIdx + 1).trim();
|
||||
if (!process.env[key]) process.env[key] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
import { getDb, closeDb } from "../src/lib/db/index";
|
||||
import { diseases } from "../src/lib/db/schema";
|
||||
import { createClient } from "@libsql/client";
|
||||
|
||||
type AgentType = "fungal" | "bacterial" | "viral" | "environmental";
|
||||
|
||||
interface FixRule {
|
||||
test: (name: string) => boolean;
|
||||
correctAgent: AgentType;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const FIX_RULES: FixRule[] = [
|
||||
// Diseases explicitly named as "virus" or "viral"
|
||||
{
|
||||
test: (name) => /\b(virus|viral|viroid)\b/i.test(name),
|
||||
correctAgent: "viral",
|
||||
reason: "Name explicitly indicates viral disease",
|
||||
},
|
||||
// Potexvirus, carlavirus, etc.
|
||||
{
|
||||
test: (name) =>
|
||||
/\b(virus\b|potex|carla|tobamo|poty|cucumo|ilar|nepo|tymovirus|geminivir|tom bushy stunt)\b/i.test(
|
||||
name,
|
||||
),
|
||||
correctAgent: "viral",
|
||||
reason: "Recognized virus genus in name",
|
||||
},
|
||||
// "Mosaic" diseases (typically viral)
|
||||
{
|
||||
test: (name) => /\bmosaic\b/i.test(name),
|
||||
correctAgent: "viral",
|
||||
reason: "Mosaic symptoms are typically caused by viruses",
|
||||
},
|
||||
// "Mottle" diseases (typically viral)
|
||||
{
|
||||
test: (name) => /\bmottle\b/i.test(name),
|
||||
correctAgent: "viral",
|
||||
reason: "Mottle symptoms are typically caused by viruses",
|
||||
},
|
||||
// "Ringspot" diseases (typically viral)
|
||||
{
|
||||
test: (name) => /\bringspot\b/i.test(name),
|
||||
correctAgent: "viral",
|
||||
reason: "Ringspot symptoms are typically caused by viruses",
|
||||
},
|
||||
// "Leaf curl" (many are viral)
|
||||
{
|
||||
test: (name) => /\bleaf curl\b|\bleafroll\b|\bleaf-roll\b/i.test(name),
|
||||
correctAgent: "viral",
|
||||
reason: "Leaf curl/roll diseases are often viral",
|
||||
},
|
||||
// "Rosette" (often viral or phytoplasma)
|
||||
{
|
||||
test: (name) => /\brosette\b/i.test(name),
|
||||
correctAgent: "viral",
|
||||
reason: "Rosette diseases are typically viral or phytoplasma",
|
||||
},
|
||||
// "Yellows" (often phytoplasma/viral)
|
||||
{
|
||||
test: (name) => /\byellows\b/i.test(name) && !/\bpeach\b/i.test(name),
|
||||
correctAgent: "viral",
|
||||
reason: "Yellows diseases are typically phytoplasma or viral",
|
||||
},
|
||||
// "Stunt" / "Dwarf" (often viral)
|
||||
{
|
||||
test: (name) => /\b(stunt|dwarf(ism)?)\b/i.test(name),
|
||||
correctAgent: "viral",
|
||||
reason: "Stunting/dwarfing diseases are often viral",
|
||||
},
|
||||
// Explicit bacterial in name
|
||||
{
|
||||
test: (name) =>
|
||||
/\bbacterial\b|\bbacterium\b|\berwinia\b|\bpseudomonas\b|\bxanthomonas\b|\bralstonia\b|\bclavibacter\b|\bstreptomyces\b|\bagrobacterium\b/i.test(
|
||||
name,
|
||||
),
|
||||
correctAgent: "bacterial",
|
||||
reason: "Name indicates bacterial disease",
|
||||
},
|
||||
// Environmental/abiotic indicators
|
||||
{
|
||||
test: (name) =>
|
||||
/\b(deficiency|abiotic|environmental|injury|damage|stress|sunscald|sunburn|chilling|freeze|frost|wind|hail|nutrient|toxicity|snow\s+(mold|scald)|winter\s+(injury|rot|kill))\b/i.test(
|
||||
name,
|
||||
),
|
||||
correctAgent: "environmental",
|
||||
reason: "Name indicates abiotic/environmental cause",
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
console.log("🔍 Fixing disease classifications\n");
|
||||
const db = getDb();
|
||||
const allDiseases = await db
|
||||
.select({ id: diseases.id, name: diseases.name, causalAgentType: diseases.causalAgentType })
|
||||
.from(diseases)
|
||||
.all();
|
||||
console.log(`📋 ${allDiseases.length} total diseases\n`);
|
||||
|
||||
const rawClient = createClient({
|
||||
url: process.env.DATABASE_URL!,
|
||||
authToken: process.env.DATABASE_TOKEN!,
|
||||
});
|
||||
|
||||
const updates: { id: string; newAgent: AgentType; rule: FixRule; oldAgent: string }[] = [];
|
||||
|
||||
for (const d of allDiseases) {
|
||||
for (const rule of FIX_RULES) {
|
||||
if (rule.test(d.name)) {
|
||||
if (d.causalAgentType !== rule.correctAgent) {
|
||||
updates.push({
|
||||
id: d.id,
|
||||
newAgent: rule.correctAgent,
|
||||
rule,
|
||||
oldAgent: d.causalAgentType,
|
||||
});
|
||||
}
|
||||
break; // First matching rule wins
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Found ${updates.length} diseases needing reclassification:\n`);
|
||||
|
||||
// Group by correction type
|
||||
const grouped: Record<string, { from: string; to: string; items: string[] }> = {};
|
||||
for (const u of updates) {
|
||||
const key = `${u.oldAgent}→${u.newAgent}`;
|
||||
if (!grouped[key]) grouped[key] = { from: u.oldAgent, to: u.newAgent, items: [] };
|
||||
grouped[key].items.push(` ${u.id}`);
|
||||
}
|
||||
|
||||
for (const [, g] of Object.entries(grouped)) {
|
||||
console.log(`${g.from} → ${g.to} (${g.items.length} diseases):`);
|
||||
g.items.slice(0, 10).forEach((l) => console.log(l));
|
||||
if (g.items.length > 10) console.log(` ... and ${g.items.length - 10} more`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if (updates.length === 0) {
|
||||
console.log("✅ No corrections needed");
|
||||
} else {
|
||||
console.log(`Applying ${updates.length} corrections...\n`);
|
||||
|
||||
// Batch update in groups of 50
|
||||
for (let i = 0; i < updates.length; i += 50) {
|
||||
const batch = updates.slice(i, i + 50);
|
||||
await rawClient.batch(
|
||||
batch.map((u) => ({
|
||||
sql: "UPDATE diseases SET causal_agent_type = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
args: [u.newAgent, u.id],
|
||||
})),
|
||||
"write",
|
||||
);
|
||||
process.stdout.write(` ${Math.min(i + 50, updates.length)}/${updates.length}\n`);
|
||||
}
|
||||
|
||||
console.log(`\n✅ ${updates.length} diseases reclassified`);
|
||||
}
|
||||
|
||||
// Print summary stats
|
||||
const after = await db.select({ causalAgentType: diseases.causalAgentType }).from(diseases).all();
|
||||
const counts: Record<string, number> = {};
|
||||
after.forEach((d) => {
|
||||
counts[d.causalAgentType] = (counts[d.causalAgentType] || 0) + 1;
|
||||
});
|
||||
console.log("\n📊 Updated distribution:");
|
||||
for (const [type, count] of Object.entries(counts).sort()) {
|
||||
console.log(` ${type}: ${count}`);
|
||||
}
|
||||
|
||||
rawClient.close();
|
||||
closeDb();
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("\n❌", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -20,7 +20,7 @@ import { getDb, closeDb } from "../src/lib/db/index";
|
||||
import { diseases, plants } from "../src/lib/db/schema";
|
||||
import PLANTS from "./plant-list";
|
||||
import { GENERIC_TEMPLATES, getTemplatesForFamily, slugify } from "./disease-templates";
|
||||
import type { Disease, CausalAgentType, Severity } from "../src/lib/types";
|
||||
import type { CausalAgentType, Prevalence, Severity } from "../src/lib/types";
|
||||
|
||||
interface DiseaseEntry {
|
||||
id: string;
|
||||
@@ -35,6 +35,7 @@ interface DiseaseEntry {
|
||||
prevention: string[];
|
||||
lookalikeIds: string[];
|
||||
severity: Severity;
|
||||
prevalence: Prevalence;
|
||||
sourceUrl: string;
|
||||
}
|
||||
|
||||
@@ -92,7 +93,6 @@ async function main() {
|
||||
|
||||
// Determine how many diseases we need for this plant
|
||||
const targetMin = 15; // minimum diseases per plant
|
||||
const targetMax = 45; // maximum diseases per plant
|
||||
|
||||
// Get family-specific templates
|
||||
const familyTemplates = getTemplatesForFamily(plant.fam);
|
||||
@@ -128,6 +128,7 @@ async function main() {
|
||||
prevention: tmpl.prevention,
|
||||
lookalikeIds: [],
|
||||
severity: tmpl.severity,
|
||||
prevalence: tmpl.severity === "critical" ? "uncommon" : "common",
|
||||
sourceUrl: "https://pddc.wisc.edu/ (UW-Madison PDDC extension factsheets)",
|
||||
});
|
||||
}
|
||||
@@ -202,7 +203,7 @@ async function main() {
|
||||
for (let i = 0; i < toInsert.length; i += BATCH) {
|
||||
const chunk = toInsert.slice(i, i + BATCH);
|
||||
const stmts = chunk.map((d) => ({
|
||||
sql: `INSERT OR IGNORE INTO diseases (id, plant_id, name, scientific_name, causal_agent_type, description, symptoms, causes, treatment, prevention, lookalike_ids, severity, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
sql: `INSERT OR IGNORE INTO diseases (id, plant_id, name, scientific_name, causal_agent_type, description, symptoms, causes, treatment, prevention, lookalike_ids, severity, prevalence, source_url) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
d.id,
|
||||
d.plantId,
|
||||
@@ -216,6 +217,7 @@ async function main() {
|
||||
JSON.stringify(d.prevention),
|
||||
JSON.stringify(d.lookalikeIds),
|
||||
d.severity,
|
||||
d.prevalence ?? "uncommon",
|
||||
d.sourceUrl,
|
||||
],
|
||||
}));
|
||||
|
||||
@@ -68,6 +68,7 @@ async function main() {
|
||||
prevention: d.prevention,
|
||||
lookalikeIds: d.lookalikeDiseaseIds,
|
||||
severity: d.severity,
|
||||
prevalence: d.prevalence ?? "uncommon",
|
||||
sourceUrl: "",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
Reference in New Issue
Block a user