sophhub 0.4.19 → 0.4.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -187
- package/agents/ai-cs-admin/.config.json +51 -51
- package/agents/ai-cs-admin/AGENTS.md +293 -293
- package/agents/ai-cs-admin/HEARTBEAT.md +18 -18
- package/agents/ai-cs-qa/.config.json +47 -47
- package/agents/ai-cs-qa/BOOTSTRAP.md +22 -22
- package/agents/ai-cs-qa/scripts/setup_links.sh +39 -39
- package/agents/beauty/.config.json +17 -17
- package/agents/beauty/AGENTS.md +234 -234
- package/agents/beauty/BOOTSTRAP.md +55 -55
- package/agents/beauty/HEARTBEAT.md +5 -5
- package/agents/beauty/IDENTITY.md +5 -5
- package/agents/beauty/MEMORY.md +44 -44
- package/agents/beauty/SOUL.md +64 -64
- package/agents/beauty/TOOLS.md +160 -160
- package/agents/beauty/USER.md +114 -114
- package/agents/intern-admin/.config.json +60 -60
- package/agents/intern-admin/AGENTS.md +267 -267
- package/agents/intern-admin/BOOTSTRAP.md +21 -21
- package/agents/intern-admin/HEARTBEAT.md +3 -3
- package/agents/intern-admin/IDENTITY.md +6 -6
- package/agents/intern-admin/MEMORY.md +21 -21
- package/agents/intern-admin/SOUL.md +23 -23
- package/agents/intern-admin/TOOLS.md +93 -93
- package/agents/intern-admin/USER.md +16 -16
- package/agents/intern-admin/scripts/init_workspace.sh +27 -27
- package/agents/intern-qa/.config.json +46 -46
- package/agents/intern-qa/AGENTS.md +303 -303
- package/agents/intern-qa/BOOTSTRAP.md +16 -16
- package/agents/intern-qa/HEARTBEAT.md +3 -3
- package/agents/intern-qa/IDENTITY.md +6 -6
- package/agents/intern-qa/MEMORY.md +22 -22
- package/agents/intern-qa/SOUL.md +24 -24
- package/agents/intern-qa/TOOLS.md +24 -24
- package/agents/intern-qa/USER.md +27 -27
- package/agents/intern-qa/scripts/setup_links.sh +54 -54
- package/agents/parent-toddler/.config.json +37 -37
- package/agents/parent-toddler/AGENTS.md +51 -51
- package/agents/parent-toddler/BOOTSTRAP.md +55 -55
- package/agents/parent-toddler/HEARTBEAT.md +5 -5
- package/agents/parent-toddler/IDENTITY.md +5 -5
- package/agents/parent-toddler/MEMORY.md +22 -22
- package/agents/parent-toddler/SOUL.md +35 -35
- package/agents/parent-toddler/TOOLS.md +31 -31
- package/agents/parent-toddler/USER.md +44 -44
- package/agents/vip-admin/.config.json +51 -51
- package/agents/vip-admin/AGENTS.md +314 -314
- package/agents/vip-admin/BOOTSTRAP.md +21 -21
- package/agents/vip-admin/HEARTBEAT.md +19 -19
- package/agents/vip-admin/IDENTITY.md +6 -6
- package/agents/vip-admin/MEMORY.md +30 -30
- package/agents/vip-admin/SOUL.md +25 -25
- package/agents/vip-admin/TOOLS.md +108 -108
- package/agents/vip-admin/USER.md +31 -31
- package/agents/vip-qa/.config.json +58 -58
- package/agents/vip-qa/AGENTS.md +319 -319
- package/agents/vip-qa/BOOTSTRAP.md +73 -73
- package/agents/vip-qa/HEARTBEAT.md +23 -23
- package/agents/vip-qa/IDENTITY.md +7 -7
- package/agents/vip-qa/MEMORY.md +23 -23
- package/agents/vip-qa/SOUL.md +34 -34
- package/agents/vip-qa/TOOLS.md +41 -41
- package/agents/vip-qa/USER.md +16 -16
- package/agents/vip-qa/scripts/setup_links.sh +39 -39
- package/bin/sophhub.js +25 -25
- package/package.json +35 -33
- package/skills/agent-install/skill.json +34 -34
- package/skills/agent-install/src/SKILL.md +240 -240
- package/skills/agent-install/src/pyproject.toml +6 -6
- package/skills/agent-install/src/scripts/backup_agent.py +120 -120
- package/skills/agent-install/src/scripts/check_installed.py +479 -479
- package/skills/agent-install/src/scripts/common.py +568 -568
- package/skills/agent-install/src/scripts/copy_agent_files.py +59 -59
- package/skills/agent-install/src/scripts/list_agents.py +285 -285
- package/skills/agent-install/src/scripts/resolve_install_params.py +90 -90
- package/skills/agent-install/src/scripts/update_agent_md.py +76 -76
- package/skills/agent-install/src/scripts/update_openclaw.py +193 -193
- package/skills/agent-install/src/scripts/verify_download.py +148 -148
- package/skills/aippt/skill.json +20 -20
- package/skills/aippt/src/SKILL.md +235 -235
- package/skills/aippt/src/pyproject.toml +8 -8
- package/skills/aippt/src/scripts/auth.py +122 -122
- package/skills/aippt/src/scripts/ppt.py +361 -361
- package/skills/aippt/src/scripts/provider_docmee.py +299 -299
- package/skills/beauty-salon-inventory/skill.json +16 -16
- package/skills/beauty-salon-inventory/src/SKILL.md +69 -69
- package/skills/beauty-salon-inventory/src/scripts/init_salon_inventory.py +39 -39
- package/skills/beauty-salon-inventory/src/scripts/init_salon_inventory.sh +4 -4
- package/skills/beauty-salon-inventory/src/scripts/salon_inventory_cli.py +244 -244
- package/skills/beauty-salon-marketing/skill.json +10 -10
- package/skills/beauty-salon-marketing/src/SKILL.md +36 -36
- package/skills/beauty-salon-marketing/src/playbooks/beauty-salon-festival.md +19 -19
- package/skills/beauty-salon-marketing/src/playbooks/beauty-salon-segment.md +18 -18
- package/skills/beauty-salon-marketing/src/scripts/beauty_marketing_cli.py +99 -99
- package/skills/beauty-salon-marketing/src/scripts/member_segment.py +114 -114
- package/skills/beauty-salon-member-appointment/skill.json +10 -10
- package/skills/beauty-salon-member-appointment/src/SKILL.md +36 -36
- package/skills/beauty-salon-member-appointment/src/pyproject.toml +9 -9
- package/skills/beauty-salon-member-appointment/src/scripts/run_e2e_smoke.py +160 -160
- package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/__init__.py +1 -1
- package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/__main__.py +4 -4
- package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/cli.py +921 -921
- package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/db.py +30 -30
- package/skills/beauty-salon-membership/skill.json +20 -20
- package/skills/beauty-salon-membership/src/SKILL.md +67 -67
- package/skills/beauty-salon-product-service/skill.json +12 -12
- package/skills/beauty-salon-product-service/src/SKILL.md +42 -42
- package/skills/beauty-salon-product-service/src/pyproject.toml +9 -9
- package/skills/beauty-salon-product-service/src/src/product_service_cli/__init__.py +1 -1
- package/skills/beauty-salon-product-service/src/src/product_service_cli/__main__.py +4 -4
- package/skills/beauty-salon-product-service/src/src/product_service_cli/cli.py +329 -329
- package/skills/beauty-salon-product-service/src/src/product_service_cli/db.py +29 -29
- package/skills/beauty-salon-staff/skill.json +10 -10
- package/skills/beauty-salon-staff/src/SKILL.md +37 -37
- package/skills/beauty-salon-staff/src/pyproject.toml +9 -9
- package/skills/beauty-salon-staff/src/src/staff_cli/__init__.py +1 -1
- package/skills/beauty-salon-staff/src/src/staff_cli/__main__.py +4 -4
- package/skills/beauty-salon-staff/src/src/staff_cli/cli.py +479 -479
- package/skills/beauty-salon-staff/src/src/staff_cli/db.py +28 -28
- package/skills/beauty-salon-suite/skill.json +13 -13
- package/skills/beauty-salon-suite/src/SKILL.md +18 -18
- package/skills/beauty-salon-suite/src/beauty_db/__init__.py +2 -2
- package/skills/beauty-salon-suite/src/beauty_db/db.py +249 -249
- package/skills/beauty-salon-traffic/skill.json +20 -20
- package/skills/beauty-salon-traffic/src/SKILL.md +84 -84
- package/skills/bing-image-search/skill.json +20 -20
- package/skills/bing-image-search/src/SKILL.md +105 -105
- package/skills/bot-api-status/skill.json +44 -44
- package/skills/bot-api-status/src/SKILL.md +99 -99
- package/skills/bot-api-status/src/pyproject.toml +5 -5
- package/skills/bot-api-status/src/scripts/secret.py +496 -496
- package/skills/bot-secret/skill.json +35 -35
- package/skills/bot-secret/src/SKILL.md +51 -51
- package/skills/bot-secret/src/pyproject.toml +5 -5
- package/skills/bot-secret/src/scripts/secret.py +120 -120
- package/skills/cake-flower-holiday-campaign/skill.json +20 -20
- package/skills/cake-flower-holiday-campaign/src/SKILL.md +68 -68
- package/skills/cake-flower-order-sop/skill.json +20 -20
- package/skills/cake-flower-order-sop/src/SKILL.md +65 -65
- package/skills/claw-agent-get-send/skill.json +32 -32
- package/skills/claw-agent-get-send/src/SKILL.md +43 -43
- package/skills/claw-agent-get-send/src/pyproject.toml +5 -5
- package/skills/claw-agent-get-send/src/scripts/appia_claw.py +379 -379
- package/skills/compact-context/skill.json +20 -20
- package/skills/compact-context/src/SKILL.md +133 -133
- package/skills/compact-context/src/scripts/check.sh +381 -381
- package/skills/compact-context/src/scripts/set-keep-recent.mjs +1337 -1337
- package/skills/compact-context/src/scripts/setup.sh +96 -96
- package/skills/consensus/skill.json +20 -20
- package/skills/consensus/src/SKILL.md +93 -93
- package/skills/deepwiki/skill.json +20 -20
- package/skills/deepwiki/src/SKILL.md +45 -45
- package/skills/deepwiki/src/_meta.json +5 -5
- package/skills/deepwiki/src/scripts/deepwiki.js +135 -135
- package/skills/didi-ride/skill.json +20 -20
- package/skills/didi-ride/src/SKILL.md +309 -309
- package/skills/didi-ride/src/_meta.json +5 -5
- package/skills/didi-ride/src/assets/PREFERENCE.md +58 -58
- package/skills/didi-ride/src/package.json +15 -15
- package/skills/didi-ride/src/references/api_references.md +171 -171
- package/skills/didi-ride/src/references/error_handling.md +68 -68
- package/skills/didi-ride/src/references/setup.md +73 -73
- package/skills/didi-ride/src/references/workflow.md +150 -150
- package/skills/feishu-bitable/skill.json +20 -20
- package/skills/feishu-bitable/src/CHECKLIST.md +149 -149
- package/skills/feishu-bitable/src/README.md +177 -177
- package/skills/feishu-bitable/src/SKILL.md +113 -113
- package/skills/feishu-bitable/src/_meta.json +5 -5
- package/skills/feishu-bitable/src/api.js +380 -380
- package/skills/feishu-bitable/src/bin/cli.js +283 -283
- package/skills/feishu-bitable/src/description.md +142 -142
- package/skills/feishu-bitable/src/examples/create-records.json +51 -51
- package/skills/feishu-bitable/src/examples/create-table.json +63 -63
- package/skills/feishu-bitable/src/package-lock.json +324 -324
- package/skills/feishu-bitable/src/package.json +32 -32
- package/skills/feishu-bitable/src/publish-config.json +13 -13
- package/skills/feishu-bitable/src/test-simple.js +60 -60
- package/skills/feishu-bitable/src/utils.js +260 -260
- package/skills/feishu-notes-assistant-universal/skill.json +20 -20
- package/skills/feishu-notes-assistant-universal/src/README.md +55 -55
- package/skills/feishu-notes-assistant-universal/src/SKILL.md +159 -159
- package/skills/feishu-notes-assistant-universal/src/scripts/_resolve_lark_cli.py +58 -58
- package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_meeting_minutes.py +462 -462
- package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud.py +547 -547
- package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud_test.py +181 -181
- package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.py +80 -80
- package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.sh +5 -5
- package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.py +32 -32
- package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.sh +5 -5
- package/skills/flight-booking/skill.json +36 -36
- package/skills/flight-booking/src/SKILL.md +288 -288
- package/skills/flight-booking/src/scripts/flight_booking.py +1237 -1237
- package/skills/flyai/skill.json +20 -20
- package/skills/flyai/src/SKILL.md +119 -119
- package/skills/flyai/src/references/fliggy-fast-search.md +53 -53
- package/skills/flyai/src/references/search-flight.md +89 -89
- package/skills/flyai/src/references/search-hotels.md +57 -57
- package/skills/flyai/src/references/search-poi.md +48 -48
- package/skills/google-maps/skill.json +20 -20
- package/skills/google-maps/src/SKILL.md +237 -237
- package/skills/google-maps/src/_meta.json +5 -5
- package/skills/google-maps/src/lib/map_helper.py +912 -912
- package/skills/image-classify/skill.json +42 -42
- package/skills/image-classify/src/SKILL.md +368 -368
- package/skills/image-classify/src/references/config.json +4 -4
- package/skills/image-classify/src/scripts/face_search.py +1276 -1276
- package/skills/image-description/skill.json +34 -34
- package/skills/image-description/src/SKILL.md +33 -33
- package/skills/image-description/src/pyproject.toml +8 -8
- package/skills/image-description/src/scripts/ana_image.py +112 -112
- package/skills/image-identify-world/skill.json +20 -20
- package/skills/image-identify-world/src/SKILL.md +40 -40
- package/skills/image-identify-world/src/pyproject.toml +8 -8
- package/skills/image-identify-world/src/scripts/identify_world.py +115 -115
- package/skills/insurance-policy-review/skill.json +27 -27
- package/skills/insurance-policy-review/src/SKILL.md +75 -75
- package/skills/insurance-sales-playbook/skill.json +20 -20
- package/skills/insurance-sales-playbook/src/SKILL.md +58 -58
- package/skills/inventory-management/skill.json +20 -20
- package/skills/inventory-management/src/SKILL.md +241 -241
- package/skills/inventory-management/src/scripts/inventory.py +1844 -1844
- package/skills/large-task-router/skill.json +20 -20
- package/skills/large-task-router/src/SKILL.md +79 -79
- package/skills/large-task-router/src/templates/plan.md +74 -74
- package/skills/lawding-contract-review/skill.json +20 -20
- package/skills/lawding-contract-review/src/SKILL.md +284 -284
- package/skills/lawding-contract-review/src/references/legal-language-library.md +1385 -1385
- package/skills/lawding-contract-review/src/scripts/build_reminders.py +471 -471
- package/skills/lawding-contract-review/src/scripts/register_contract_cron.py +457 -457
- package/skills/md2pdf-converter/skill.json +20 -20
- package/skills/md2pdf-converter/src/SKILL.md +244 -244
- package/skills/md2pdf-converter/src/_meta.json +5 -5
- package/skills/md2pdf-converter/src/scripts/generate_emoji_mapping.py +74 -74
- package/skills/md2pdf-converter/src/scripts/md2pdf-local.sh +291 -291
- package/skills/notes-hub-assistant/skill.json +20 -20
- package/skills/notes-hub-assistant/src/SKILL.md +233 -233
- package/skills/notes-hub-assistant/src/scripts/_resolve_lark_cli.py +48 -48
- package/skills/notes-hub-assistant/src/scripts/openclaw_meeting_minutes.py +473 -473
- package/skills/notes-hub-assistant/src/scripts/openclaw_notes_crud.py +596 -596
- package/skills/notes-hub-assistant/src/scripts/openclaw_wolai_notes_crud.py +364 -364
- package/skills/notes-hub-assistant/src/scripts/run_meeting_minutes.py +79 -79
- package/skills/notes-hub-assistant/src/scripts/run_note_crud.py +37 -37
- package/skills/notes-hub-assistant/src/scripts/run_notionbot.py +36 -36
- package/skills/notes-hub-assistant/src/scripts/run_wolai_note_crud.py +27 -27
- package/skills/schedule-reminder/skill.json +20 -20
- package/skills/schedule-reminder/src/SKILL.md +619 -619
- package/skills/schedule-reminder/src/schedule_template.md +68 -68
- package/skills/schedule-reminder/src/scripts/append_event.py +204 -204
- package/skills/schedule-reminder/src/scripts/create_reminders.sh +163 -163
- package/skills/schedule-reminder/src/scripts/daily_activate.sh +175 -175
- package/skills/schedule-reminder/src/scripts/parse_schedule.py +704 -704
- package/skills/schedule-reminder/src/scripts/setup.sh +242 -242
- package/skills/schedule-reminder/src//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -311
- package/skills/sessions-analysis/skill.json +34 -34
- package/skills/sessions-analysis/src/SKILL.md +81 -81
- package/skills/sessions-analysis/src/pyproject.toml +5 -5
- package/skills/sessions-analysis/src/scripts/ana_logs.py +205 -205
- package/skills/share-skill/skill.json +20 -20
- package/skills/share-skill/src/SKILL.md +261 -261
- package/skills/share-skill/src/scripts/share_skill_to_friend.py +1031 -1031
- package/skills/skill-creator/skill.json +20 -20
- package/skills/skill-creator/src/SKILL.md +370 -370
- package/skills/skill-creator/src/license.txt +202 -202
- package/skills/skill-creator/src/scripts/init_skill.py +378 -378
- package/skills/skill-creator/src/scripts/package_skill.py +111 -111
- package/skills/skill-creator/src/scripts/quick_validate.py +101 -101
- package/skills/skillhub/skill.json +27 -27
- package/skills/skillhub/src/SKILL.md +121 -121
- package/skills/sophnet-age-appearance/skill.json +20 -20
- package/skills/sophnet-age-appearance/src/SKILL.md +83 -83
- package/skills/sophnet-age-appearance/src/pyproject.toml +10 -10
- package/skills/sophnet-age-appearance/src/scripts/age_appearance.py +395 -395
- package/skills/sophnet-age-appearance/src/scripts/age_face_crop.py +313 -313
- package/skills/sophnet-bot-client/skill.json +20 -20
- package/skills/sophnet-bot-client/src/SKILL.md +255 -255
- package/skills/sophnet-bot-client/src/pyproject.toml +13 -13
- package/skills/sophnet-bot-client/src/scripts/bot_client_proxy.py +165 -165
- package/skills/sophnet-bot-client/src/scripts/bot_client_safe.sh +29 -29
- package/skills/sophnet-bot-client/src/scripts/bot_client_setup.py +502 -502
- package/skills/sophnet-bot-client/src/tests/test_bot_client_proxy.py +255 -255
- package/skills/sophnet-bot-client/src/tests/test_bot_client_setup.py +679 -679
- package/skills/sophnet-bot-client/src/uv.lock +8 -8
- package/skills/sophnet-customer-management/skill.json +20 -20
- package/skills/sophnet-customer-management/src/SKILL.md +270 -270
- package/skills/sophnet-customer-management/src/pyproject.toml +15 -15
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/__init__.py +2 -2
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/__main__.py +5 -5
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/cli.py +67 -67
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/__init__.py +2 -2
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/customer.py +60 -60
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/export_file.py +18 -18
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/import_file.py +15 -15
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/reminder.py +26 -26
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/schema.py +28 -28
- package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/config.py +54 -54
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/__init__.py +2 -2
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/exporter.py +85 -85
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/models.py +84 -84
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/normalizer.py +144 -144
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/parser.py +241 -241
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/query.py +109 -109
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/reminder.py +121 -121
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/repository.py +397 -397
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/schema.py +106 -106
- package/skills/sophnet-customer-management/src/src/customer_mgmt_core/service.py +565 -565
- package/skills/sophnet-customer-management/src/uv.lock +48 -48
- package/skills/sophnet-customized-marketing/skill.json +28 -28
- package/skills/sophnet-customized-marketing/src/SKILL.md +144 -144
- package/skills/sophnet-customized-marketing/src/playbooks/campaign-planning.md +187 -187
- package/skills/sophnet-customized-marketing/src/playbooks/content-generation.md +124 -124
- package/skills/sophnet-customized-marketing/src/playbooks/marketing-calendar.md +59 -59
- package/skills/sophnet-customized-marketing/src/playbooks/multi-channel-bundle.md +94 -94
- package/skills/sophnet-customized-marketing/src/playbooks/poster-generation.md +182 -182
- package/skills/sophnet-customized-marketing/src/playbooks/style-profile-workflow.md +103 -103
- package/skills/sophnet-customized-marketing/src/pyproject.toml +8 -8
- package/skills/sophnet-customized-marketing/src/references/campaign-mechanics.md +168 -168
- package/skills/sophnet-customized-marketing/src/references/content-safety.md +26 -26
- package/skills/sophnet-customized-marketing/src/references/marketing-date-checklist.md +99 -99
- package/skills/sophnet-customized-marketing/src/references/platform-writing-guidelines.md +88 -88
- package/skills/sophnet-customized-marketing/src/references/quality-checklist.md +44 -44
- package/skills/sophnet-customized-marketing/src/scripts/generate_poster.py +572 -572
- package/skills/sophnet-customized-marketing/src/scripts/style_profile.py +215 -215
- package/skills/sophnet-dailynews/skill.json +20 -20
- package/skills/sophnet-dailynews/src/SKILL.md +179 -179
- package/skills/sophnet-dailynews/src/cache.json +150 -150
- package/skills/sophnet-dailynews/src/sources.json +230 -230
- package/skills/sophnet-docx/skill.json +20 -20
- package/skills/sophnet-docx/src/SKILL.md +463 -463
- package/skills/sophnet-docx/src/package-lock.json +208 -208
- package/skills/sophnet-docx/src/package.json +16 -16
- package/skills/sophnet-docx/src/pyproject.toml +11 -11
- package/skills/sophnet-docx/src/scripts/__init__.py +1 -1
- package/skills/sophnet-docx/src/scripts/accept_changes.py +135 -135
- package/skills/sophnet-docx/src/scripts/comment.py +318 -318
- package/skills/sophnet-docx/src/scripts/ensure_uv_env.sh +68 -68
- package/skills/sophnet-docx/src/scripts/office/helpers/merge_runs.py +199 -199
- package/skills/sophnet-docx/src/scripts/office/helpers/simplify_redlines.py +197 -197
- package/skills/sophnet-docx/src/scripts/office/pack.py +159 -159
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -146
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -11
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -23
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -185
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -28
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -144
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -25
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -18
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -59
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -56
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -195
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -25
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -509
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -12
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -108
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -96
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -3646
- package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
- package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
- package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
- package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
- package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
- package/skills/sophnet-docx/src/scripts/office/schemas/mce/mc.xsd +75 -75
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -560
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -67
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -14
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -20
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -13
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -4
- package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -8
- package/skills/sophnet-docx/src/scripts/office/soffice.py +183 -183
- package/skills/sophnet-docx/src/scripts/office/unpack.py +132 -132
- package/skills/sophnet-docx/src/scripts/office/validate.py +111 -111
- package/skills/sophnet-docx/src/scripts/office/validators/__init__.py +15 -15
- package/skills/sophnet-docx/src/scripts/office/validators/base.py +847 -847
- package/skills/sophnet-docx/src/scripts/office/validators/docx.py +446 -446
- package/skills/sophnet-docx/src/scripts/office/validators/pptx.py +275 -275
- package/skills/sophnet-docx/src/scripts/office/validators/redlining.py +247 -247
- package/skills/sophnet-docx/src/scripts/templates/comments.xml +3 -3
- package/skills/sophnet-docx/src/scripts/templates/commentsExtended.xml +3 -3
- package/skills/sophnet-docx/src/scripts/templates/commentsExtensible.xml +3 -3
- package/skills/sophnet-docx/src/scripts/templates/commentsIds.xml +3 -3
- package/skills/sophnet-docx/src/scripts/templates/people.xml +3 -3
- package/skills/sophnet-docx/src/scripts/upload_file.sh +96 -96
- package/skills/sophnet-docx/src/uv.lock +320 -320
- package/skills/sophnet-face-search/skill.json +20 -20
- package/skills/sophnet-face-search/src/SKILL.md +115 -115
- package/skills/sophnet-face-search/src/pyproject.toml +11 -11
- package/skills/sophnet-face-search/src/scripts/face_search.py +335 -335
- package/skills/sophnet-face-search/src/uv.lock +508 -508
- package/skills/sophnet-id-photo/skill.json +20 -20
- package/skills/sophnet-id-photo/src/SKILL.md +107 -107
- package/skills/sophnet-id-photo/src/pyproject.toml +10 -10
- package/skills/sophnet-id-photo/src/scripts/id_photo.py +540 -540
- package/skills/sophnet-id-photo/src/scripts/id_photo_compliance.py +215 -215
- package/skills/sophnet-id-photo/src/scripts/id_photo_face_crop.py +313 -313
- package/skills/sophnet-image-edit/skill.json +20 -20
- package/skills/sophnet-image-edit/src/SKILL.md +140 -140
- package/skills/sophnet-image-edit/src/pyproject.toml +9 -9
- package/skills/sophnet-image-edit/src/scripts/edit_and_preview.sh +68 -68
- package/skills/sophnet-image-edit/src/scripts/edit_image.py +279 -279
- package/skills/sophnet-image-edit/src/uv.lock +234 -234
- package/skills/sophnet-image-generate/skill.json +20 -20
- package/skills/sophnet-image-generate/src/SKILL.md +62 -62
- package/skills/sophnet-image-generate/src/pyproject.toml +9 -9
- package/skills/sophnet-image-generate/src/scripts/generate_image.py +156 -156
- package/skills/sophnet-image-generate/src/uv.lock +234 -234
- package/skills/sophnet-image-ocr/skill.json +20 -20
- package/skills/sophnet-image-ocr/src/SKILL.md +167 -167
- package/skills/sophnet-image-ocr/src/pyproject.toml +13 -13
- package/skills/sophnet-image-ocr/src/scripts/ocr.py +225 -225
- package/skills/sophnet-image-ocr/src/uv.lock +234 -234
- package/skills/sophnet-infinite-talk/skill.json +20 -20
- package/skills/sophnet-infinite-talk/src/SKILL.md +140 -140
- package/skills/sophnet-infinite-talk/src/pyproject.toml +9 -9
- package/skills/sophnet-infinite-talk/src/scripts/gen.py +172 -172
- package/skills/sophnet-oss/skill.json +27 -27
- package/skills/sophnet-oss/src/SKILL.md +118 -118
- package/skills/sophnet-oss/src/pyproject.toml +8 -8
- package/skills/sophnet-oss/src/scripts/upload_file.py +43 -43
- package/skills/sophnet-pdf/skill.json +20 -20
- package/skills/sophnet-pdf/src/SKILL.md +413 -413
- package/skills/sophnet-pdf/src/forms.md +297 -297
- package/skills/sophnet-pdf/src/pyproject.toml +14 -14
- package/skills/sophnet-pdf/src/reference.md +611 -611
- package/skills/sophnet-pdf/src/scripts/check_bounding_boxes.py +65 -65
- package/skills/sophnet-pdf/src/scripts/check_fillable_fields.py +11 -11
- package/skills/sophnet-pdf/src/scripts/convert_pdf_to_images.py +33 -33
- package/skills/sophnet-pdf/src/scripts/create_validation_image.py +37 -37
- package/skills/sophnet-pdf/src/scripts/enhance_tutorial.py +557 -557
- package/skills/sophnet-pdf/src/scripts/ensure_uv_env.sh +68 -68
- package/skills/sophnet-pdf/src/scripts/extract_form_field_info.py +122 -122
- package/skills/sophnet-pdf/src/scripts/extract_form_structure.py +115 -115
- package/skills/sophnet-pdf/src/scripts/extract_pdf_content.py +34 -34
- package/skills/sophnet-pdf/src/scripts/fill_fillable_fields.py +98 -98
- package/skills/sophnet-pdf/src/scripts/fill_pdf_form_with_annotations.py +107 -107
- package/skills/sophnet-pdf/src/scripts/upload_file.sh +88 -88
- package/skills/sophnet-pdf/src/uv.lock +537 -537
- package/skills/sophnet-qa-install/skill.json +27 -27
- package/skills/sophnet-qa-install/src/SKILL.md +210 -210
- package/skills/sophnet-qa-install/src/pyproject.toml +6 -6
- package/skills/sophnet-qa-install/src/scripts/backup_md.py +35 -35
- package/skills/sophnet-qa-install/src/scripts/check_installed.py +143 -143
- package/skills/sophnet-qa-install/src/scripts/update_config.py +142 -142
- package/skills/sophnet-qa-install/src/scripts/update_md.py +73 -73
- package/skills/sophnet-schedule/skill.json +20 -20
- package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -321
- package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -145
- package/skills/sophnet-schedule/src/SKILL.md +1050 -1050
- package/skills/sophnet-schedule/src/_meta.json +6 -6
- package/skills/sophnet-schedule/src/api/models.py +245 -245
- package/skills/sophnet-schedule/src/apps/add_event.py +237 -237
- package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -112
- package/skills/sophnet-schedule/src/apps/check_roc.py +246 -246
- package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -342
- package/skills/sophnet-schedule/src/apps/import_events.py +216 -216
- package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -140
- package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -169
- package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -174
- package/skills/sophnet-schedule/src/compat.py +66 -66
- package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -96
- package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -44
- package/skills/sophnet-schedule/src/config/settings.py +133 -133
- package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -92
- package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -437
- package/skills/sophnet-schedule/src/gcal/client.py +374 -374
- package/skills/sophnet-schedule/src/gcal/models.py +91 -91
- package/skills/sophnet-schedule/src/requirements.txt +6 -6
- package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -85
- package/skills/sophnet-schedule/src/server.py +669 -669
- package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -139
- package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -96
- package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -117
- package/skills/sophnet-schedule/src/services/event_classifier.py +100 -100
- package/skills/sophnet-schedule/src/services/event_diff.py +160 -160
- package/skills/sophnet-schedule/src/services/google_integration.py +500 -500
- package/skills/sophnet-schedule/src/services/job_store.py +100 -100
- package/skills/sophnet-schedule/src/services/local_event_store.py +266 -266
- package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -116
- package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -31
- package/skills/sophnet-schedule/src/services/table_parser.py +286 -286
- package/skills/sophnet-schedule/src/services/task_builder.py +167 -167
- package/skills/sophnet-schedule/src/services/time_window.py +72 -72
- package/skills/sophnet-sticker-edit/skill.json +27 -27
- package/skills/sophnet-sticker-edit/src/SKILL.md +80 -80
- package/skills/sophnet-sticker-edit/src/pyproject.toml +9 -9
- package/skills/sophnet-sticker-edit/src/scripts/edit_sticker_image.py +403 -403
- package/skills/sophnet-stock/skill.json +20 -20
- package/skills/sophnet-stock/src/App-Plan.md +442 -442
- package/skills/sophnet-stock/src/README.md +214 -214
- package/skills/sophnet-stock/src/SKILL.md +236 -236
- package/skills/sophnet-stock/src/TODO.md +394 -394
- package/skills/sophnet-stock/src/_meta.json +5 -5
- package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -408
- package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -233
- package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -288
- package/skills/sophnet-stock/src/docs/README.md +95 -95
- package/skills/sophnet-stock/src/docs/USAGE.md +465 -465
- package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -2565
- package/skills/sophnet-stock/src/scripts/dividends.py +365 -365
- package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -582
- package/skills/sophnet-stock/src/scripts/portfolio.py +548 -548
- package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -342
- package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -409
- package/skills/sophnet-stock/src/scripts/watchlist.py +336 -336
- package/skills/sophnet-training-install/skill.json +27 -27
- package/skills/sophnet-training-install/src/SKILL.md +211 -211
- package/skills/sophnet-training-install/src/pyproject.toml +6 -6
- package/skills/sophnet-training-install/src/scripts/backup_md.py +35 -35
- package/skills/sophnet-training-install/src/scripts/check_installed.py +144 -144
- package/skills/sophnet-training-install/src/scripts/update_config.py +142 -142
- package/skills/sophnet-training-install/src/scripts/update_md.py +73 -73
- package/skills/sophnet-tts/skill.json +20 -20
- package/skills/sophnet-tts/src/SKILL.md +79 -79
- package/skills/sophnet-tts/src/pyproject.toml +9 -9
- package/skills/sophnet-tts/src/scripts/gen_tts.py +130 -130
- package/skills/sophnet-video-generate/skill.json +37 -37
- package/skills/sophnet-video-generate/src/SKILL.md +117 -117
- package/skills/sophnet-video-generate/src/scripts/gen_video.py +321 -321
- package/skills/sophnet-xlsx/skill.json +20 -20
- package/skills/sophnet-xlsx/src/SKILL.md +399 -399
- package/skills/sophnet-xlsx/src/pyproject.toml +11 -11
- package/skills/sophnet-xlsx/src/scripts/ensure_uv_env.sh +68 -68
- package/skills/sophnet-xlsx/src/scripts/office/helpers/merge_runs.py +199 -199
- package/skills/sophnet-xlsx/src/scripts/office/helpers/simplify_redlines.py +197 -197
- package/skills/sophnet-xlsx/src/scripts/office/pack.py +159 -159
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -146
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -11
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -23
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -185
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -28
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -144
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -25
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -18
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -59
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -56
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -195
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -25
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -509
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -12
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -108
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -96
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -3646
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
- package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
- package/skills/sophnet-xlsx/src/scripts/office/schemas/mce/mc.xsd +75 -75
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -560
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -67
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -14
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -20
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -13
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -4
- package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -8
- package/skills/sophnet-xlsx/src/scripts/office/soffice.py +183 -183
- package/skills/sophnet-xlsx/src/scripts/office/unpack.py +132 -132
- package/skills/sophnet-xlsx/src/scripts/office/validate.py +111 -111
- package/skills/sophnet-xlsx/src/scripts/office/validators/__init__.py +15 -15
- package/skills/sophnet-xlsx/src/scripts/office/validators/base.py +847 -847
- package/skills/sophnet-xlsx/src/scripts/office/validators/docx.py +446 -446
- package/skills/sophnet-xlsx/src/scripts/office/validators/pptx.py +275 -275
- package/skills/sophnet-xlsx/src/scripts/office/validators/redlining.py +247 -247
- package/skills/sophnet-xlsx/src/scripts/recalc.py +184 -184
- package/skills/sophnet-xlsx/src/scripts/upload_file.sh +96 -96
- package/skills/sophnet-xlsx/src/uv.lock +319 -319
- package/skills/ui-ux-pro-max/skill.json +20 -20
- package/skills/ui-ux-pro-max/src/SKILL.md +377 -377
- package/skills/ui-ux-pro-max/src/data/icons.csv +101 -101
- package/skills/ui-ux-pro-max/src/data/react-performance.csv +45 -45
- package/skills/ui-ux-pro-max/src/data/stacks/astro.csv +54 -54
- package/skills/ui-ux-pro-max/src/data/stacks/jetpack-compose.csv +53 -53
- package/skills/ui-ux-pro-max/src/data/stacks/nuxt-ui.csv +51 -51
- package/skills/ui-ux-pro-max/src/data/stacks/nuxtjs.csv +59 -59
- package/skills/ui-ux-pro-max/src/data/stacks/shadcn.csv +61 -61
- package/skills/ui-ux-pro-max/src/data/typography.csv +57 -57
- package/skills/ui-ux-pro-max/src/data/ui-reasoning.csv +101 -101
- package/skills/ui-ux-pro-max/src/data/web-interface.csv +31 -31
- package/skills/ui-ux-pro-max/src/scripts/core.py +253 -253
- package/skills/ui-ux-pro-max/src/scripts/design_system.py +1067 -1067
- package/skills/video-understand/skill.json +20 -20
- package/skills/video-understand/src/SKILL.md +79 -79
- package/skills/video-understand/src/scripts/video_understand.py +204 -204
- package/skills/weather/skill.json +19 -19
- package/skills/weather/src/SKILL.md +112 -112
- package/skills/web-scraper/skill.json +20 -20
- package/skills/web-scraper/src/SKILL.md +101 -101
- package/skills/web-scraper/src/scripts/scrape.py +270 -270
- package/skills/website-builder/skill.json +20 -20
- package/skills/website-builder/src/SKILL.md +266 -266
- package/skills/website-builder/src/scripts/deploy_site.sh +46 -46
- package/skills/wechat-article-publisher/skill.json +20 -20
- package/skills/wechat-article-publisher/src/SKILL.md +60 -60
- package/skills/wechat-article-publisher/src/config.json +6 -6
- package/skills/wechat-article-publisher/src/pyproject.toml +12 -12
- package/skills/wechat-article-publisher/src/scripts/publish_wechat.py +825 -825
- package/skills/xiaohongshu/skill.json +20 -20
- package/skills/xiaohongshu/src/SKILL.md +91 -91
- package/skills/xiaohongshu/src/_meta.json +5 -5
- package/skills/xiaohongshu/src/assets/card.html +216 -216
- package/skills/xiaohongshu/src/assets/cover.html +82 -82
- package/skills/xiaohongshu/src/assets/example.md +84 -84
- package/skills/xiaohongshu/src/assets/styles.css +318 -318
- package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -737
- package/skills/xiaohongshu/src/scripts/sign_server.py +158 -158
- package/skills/xiaohongshu/src/scripts/stealth.min.js +6 -6
- package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -186
- package/skills/xiaohongshu/src/workflow.py +185 -185
- package/src/commands/agent.js +112 -112
- package/src/commands/download.js +101 -101
- package/src/commands/info.js +58 -58
- package/src/commands/list.js +71 -71
- package/src/utils/agents.js +36 -36
- package/src/utils/config.js +22 -22
- package/src/utils/paths.js +31 -31
- package/src/utils/versions.js +57 -57
|
@@ -1,1276 +1,1276 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
照片分类
|
|
4
|
-
"""
|
|
5
|
-
import requests
|
|
6
|
-
import os
|
|
7
|
-
import sys
|
|
8
|
-
import json
|
|
9
|
-
import argparse
|
|
10
|
-
import shutil
|
|
11
|
-
import subprocess
|
|
12
|
-
import zipfile
|
|
13
|
-
from datetime import datetime
|
|
14
|
-
import cv2
|
|
15
|
-
import numpy as np
|
|
16
|
-
import sophnet_tools
|
|
17
|
-
|
|
18
|
-
# API配置
|
|
19
|
-
FACE_API_URL = "https://www.sophnet.com/api/open-apis/projects/detect_and_embed"
|
|
20
|
-
|
|
21
|
-
def convert_to_url(image_path, timeout=10):
|
|
22
|
-
return sophnet_tools.upload_oss(image_path, timeout)
|
|
23
|
-
|
|
24
|
-
def get_images_path():
|
|
25
|
-
current_file_dir = os.path.dirname(os.path.abspath(__file__))
|
|
26
|
-
return os.path.abspath(os.path.join(current_file_dir, "..", "..", "..", "images"))
|
|
27
|
-
|
|
28
|
-
def list_date_folders():
|
|
29
|
-
"""列出待搜索目录 images 下所有 YYYYMMDD 子目录,按日期从新到旧排序。
|
|
30
|
-
返回 [{"name": "20260410", "path": "/abs/path/20260410"}, ...]"""
|
|
31
|
-
images_path = get_images_path()
|
|
32
|
-
if not os.path.exists(images_path):
|
|
33
|
-
print(f"[提示] images_path 不存在: {images_path}")
|
|
34
|
-
return []
|
|
35
|
-
|
|
36
|
-
if not os.path.isdir(images_path):
|
|
37
|
-
print(f"[提示] images_path 不是目录: {images_path}")
|
|
38
|
-
return []
|
|
39
|
-
|
|
40
|
-
valid_dates = []
|
|
41
|
-
invalid_entries = []
|
|
42
|
-
for name in os.listdir(images_path):
|
|
43
|
-
full_path = os.path.join(images_path, name)
|
|
44
|
-
if not os.path.isdir(full_path):
|
|
45
|
-
continue
|
|
46
|
-
try:
|
|
47
|
-
dt = datetime.strptime(name, "%Y%m%d")
|
|
48
|
-
valid_dates.append((dt, name, os.path.abspath(full_path)))
|
|
49
|
-
except ValueError:
|
|
50
|
-
invalid_entries.append(name)
|
|
51
|
-
|
|
52
|
-
if invalid_entries:
|
|
53
|
-
print(f"[提示] 以下目录名不是 YYYYMMDD,将忽略: {', '.join(sorted(invalid_entries))}")
|
|
54
|
-
|
|
55
|
-
if not valid_dates:
|
|
56
|
-
print(f"[提示] 未找到符合 YYYYMMDD 的日期目录: {images_path}")
|
|
57
|
-
return []
|
|
58
|
-
|
|
59
|
-
valid_dates.sort(key=lambda x: x[0], reverse=True)
|
|
60
|
-
return [{"name": name, "path": path} for _, name, path in valid_dates]
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def get_last_date_path(count=3):
|
|
64
|
-
folders = list_date_folders()
|
|
65
|
-
return [f["name"] for f in folders[:count]]
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
def create_today_folder():
|
|
69
|
-
"""在待搜索目录 images_path 下创建当天日期目录(YYYYMMDD)。返回当天目录路径,失败返回 None。"""
|
|
70
|
-
images_path = get_images_path()
|
|
71
|
-
if not os.path.exists(images_path):
|
|
72
|
-
os.makedirs(images_path, exist_ok=True)
|
|
73
|
-
elif not os.path.isdir(images_path):
|
|
74
|
-
return None
|
|
75
|
-
|
|
76
|
-
today = datetime.now().strftime("%Y%m%d")
|
|
77
|
-
today_path = os.path.join(images_path, today)
|
|
78
|
-
if os.path.exists(today_path):
|
|
79
|
-
if os.path.isdir(today_path):
|
|
80
|
-
return today_path
|
|
81
|
-
return None
|
|
82
|
-
|
|
83
|
-
os.makedirs(today_path, exist_ok=False)
|
|
84
|
-
return today_path
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
def move_image_to_today_folder(file_path, today_folder=None):
|
|
88
|
-
"""将文件剪切到待搜索目录的当天日期目录。成功返回目标路径,失败返回 None。"""
|
|
89
|
-
if not file_path:
|
|
90
|
-
return None
|
|
91
|
-
|
|
92
|
-
src = os.path.realpath(file_path)
|
|
93
|
-
if not os.path.isfile(src):
|
|
94
|
-
return None
|
|
95
|
-
|
|
96
|
-
today_folder = today_folder or create_today_folder()
|
|
97
|
-
if not today_folder:
|
|
98
|
-
return None
|
|
99
|
-
|
|
100
|
-
base = os.path.basename(src)
|
|
101
|
-
stem, suffix = os.path.splitext(base)
|
|
102
|
-
target = os.path.join(today_folder, base)
|
|
103
|
-
if os.path.exists(target):
|
|
104
|
-
counter = 1
|
|
105
|
-
while os.path.exists(target):
|
|
106
|
-
target = os.path.join(today_folder, f"{stem}_{counter}{suffix}")
|
|
107
|
-
counter += 1
|
|
108
|
-
|
|
109
|
-
moved_path = shutil.move(src, target)
|
|
110
|
-
return os.path.realpath(moved_path)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def move_images_to_today_folder(file_paths):
|
|
114
|
-
"""串行将多张图片剪切到待搜索目录的当天日期目录。"""
|
|
115
|
-
if not file_paths:
|
|
116
|
-
return {"success": False, "folder_path": None, "uploaded": [], "failed": []}
|
|
117
|
-
|
|
118
|
-
today_folder = create_today_folder()
|
|
119
|
-
if not today_folder:
|
|
120
|
-
return {"success": False, "folder_path": None, "uploaded": [], "failed": list(file_paths)}
|
|
121
|
-
|
|
122
|
-
uploaded = []
|
|
123
|
-
failed = []
|
|
124
|
-
total = len(file_paths)
|
|
125
|
-
next_progress = 10
|
|
126
|
-
|
|
127
|
-
for index, file_path in enumerate(file_paths, start=1):
|
|
128
|
-
moved_path = move_image_to_today_folder(file_path, today_folder=today_folder)
|
|
129
|
-
if moved_path:
|
|
130
|
-
uploaded.append(moved_path)
|
|
131
|
-
else:
|
|
132
|
-
failed.append(file_path)
|
|
133
|
-
|
|
134
|
-
progress = (index * 100) // total
|
|
135
|
-
while progress >= next_progress:
|
|
136
|
-
print(f"[进度] {next_progress}% ({index}/{total})", file=sys.stderr)
|
|
137
|
-
next_progress += 10
|
|
138
|
-
|
|
139
|
-
if next_progress == 10:
|
|
140
|
-
print("[进度] 100% (0/0)", file=sys.stderr)
|
|
141
|
-
|
|
142
|
-
return {
|
|
143
|
-
"success": not failed,
|
|
144
|
-
"folder_path": os.path.realpath(today_folder),
|
|
145
|
-
"uploaded": uploaded,
|
|
146
|
-
"failed": failed,
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
def list_images_recursive(folder_path):
|
|
150
|
-
"""使用 os.path 递归列出所有图片文件"""
|
|
151
|
-
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg'}
|
|
152
|
-
folder = os.path.realpath(os.path.abspath(folder_path))
|
|
153
|
-
image_files = []
|
|
154
|
-
|
|
155
|
-
try:
|
|
156
|
-
for root, _dirs, files in os.walk(folder):
|
|
157
|
-
for fname in files:
|
|
158
|
-
full = os.path.join(root, fname)
|
|
159
|
-
try:
|
|
160
|
-
ext = os.path.splitext(fname)[1].lower()
|
|
161
|
-
if ext in image_extensions and os.path.isfile(full):
|
|
162
|
-
image_files.append(os.path.abspath(full))
|
|
163
|
-
except PermissionError:
|
|
164
|
-
print(f"[警告] 无权限访问:{full}")
|
|
165
|
-
continue
|
|
166
|
-
except PermissionError:
|
|
167
|
-
print(f"[错误] 无权限访问根目录:{folder}")
|
|
168
|
-
|
|
169
|
-
return image_files
|
|
170
|
-
|
|
171
|
-
def detect_faces(image_path, soph_api_key):
|
|
172
|
-
"""调用API检测人脸"""
|
|
173
|
-
if not os.path.exists(image_path):
|
|
174
|
-
raise FileNotFoundError(f"图片文件不存在: {image_path}")
|
|
175
|
-
|
|
176
|
-
ost_img = cv2.imread(image_path)
|
|
177
|
-
if ost_img is None:
|
|
178
|
-
raise ValueError(f"无法读取图片文件: {image_path}")
|
|
179
|
-
|
|
180
|
-
# 确定图片的 MIME 类型
|
|
181
|
-
ext = os.path.splitext(image_path)[1].lower()
|
|
182
|
-
mime_types = {
|
|
183
|
-
'.jpg': 'image/jpeg',
|
|
184
|
-
'.jpeg': 'image/jpeg',
|
|
185
|
-
'.png': 'image/png',
|
|
186
|
-
'.webp': 'image/webp',
|
|
187
|
-
'.bmp': 'image/bmp',
|
|
188
|
-
'.gif': 'image/gif'
|
|
189
|
-
}
|
|
190
|
-
mime_type = mime_types.get(ext, 'image/jpeg')
|
|
191
|
-
|
|
192
|
-
try:
|
|
193
|
-
with open(image_path, 'rb') as f:
|
|
194
|
-
files = {'file': (os.path.basename(image_path), f, mime_type)}
|
|
195
|
-
headers = {"Authorization": f"Bearer {soph_api_key}"}
|
|
196
|
-
# 设置较短的超时时间,避免Node.js环境中的超时溢出
|
|
197
|
-
response = requests.post(FACE_API_URL, files=files, headers=headers, timeout=10)
|
|
198
|
-
except requests.exceptions.Timeout:
|
|
199
|
-
raise RuntimeError(f"API请求超时: 图片 {image_path} 处理时间过长")
|
|
200
|
-
except requests.exceptions.RequestException as e:
|
|
201
|
-
raise RuntimeError(f"API请求异常: {str(e)}")
|
|
202
|
-
|
|
203
|
-
if response.status_code != 200:
|
|
204
|
-
raise RuntimeError(f"API请求失败: {response.status_code} - {response.text[:200]}")
|
|
205
|
-
|
|
206
|
-
return response.json().get('result', {})
|
|
207
|
-
|
|
208
|
-
def get_largest_face(faces):
|
|
209
|
-
"""从检测到的人脸中找出尺寸最大的人脸"""
|
|
210
|
-
if not faces:
|
|
211
|
-
return None
|
|
212
|
-
|
|
213
|
-
largest_face = None
|
|
214
|
-
largest_area = 0
|
|
215
|
-
|
|
216
|
-
for face in faces:
|
|
217
|
-
box = face.get('box', [])
|
|
218
|
-
if len(box) >= 4:
|
|
219
|
-
x1, y1, x2, y2 = box[0], box[1], box[2], box[3]
|
|
220
|
-
area = (x2 - x1) * (y2 - y1)
|
|
221
|
-
if area > largest_area:
|
|
222
|
-
largest_area = area
|
|
223
|
-
largest_face = face
|
|
224
|
-
|
|
225
|
-
return largest_face
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def get_baseface_embedding(image_path, det_thr=0.7):
|
|
229
|
-
"""获取查询图片的最大人脸embedding"""
|
|
230
|
-
try:
|
|
231
|
-
result = detect_faces(image_path, sophnet_tools.get_api_key())
|
|
232
|
-
faces_count = result.get("faces_count", 0)
|
|
233
|
-
faces = result.get("output", [])
|
|
234
|
-
faces = [face for face in faces if face.get('det_score', 0) >= det_thr]
|
|
235
|
-
faces_count = len(faces)
|
|
236
|
-
|
|
237
|
-
if faces_count > 0:
|
|
238
|
-
largest_face = get_largest_face(faces)
|
|
239
|
-
return largest_face.get('embedding', [])
|
|
240
|
-
else:
|
|
241
|
-
return None
|
|
242
|
-
|
|
243
|
-
except Exception as e:
|
|
244
|
-
print(f"错误: {e}", file=sys.stderr)
|
|
245
|
-
return None
|
|
246
|
-
|
|
247
|
-
def get_config(config_path='references/config.json'):
|
|
248
|
-
"""从配置文件中读取阈值"""
|
|
249
|
-
try:
|
|
250
|
-
with open(config_path, 'r', encoding='utf-8') as f:
|
|
251
|
-
config = json.load(f)
|
|
252
|
-
return config
|
|
253
|
-
except FileNotFoundError:
|
|
254
|
-
return {
|
|
255
|
-
"query_threshold": 0.7,
|
|
256
|
-
"search_similarity_threshold": 0.3
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
def _user_friend_id(user):
|
|
261
|
-
"""用户条目中的好友 ID(虾友号)。新字段 `friendId`,兼容旧字段 `xia_you_hao`。"""
|
|
262
|
-
if not isinstance(user, dict):
|
|
263
|
-
return None
|
|
264
|
-
v = user.get("friendId")
|
|
265
|
-
if v is not None:
|
|
266
|
-
return v
|
|
267
|
-
return user.get("xia_you_hao")
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
def _user_friend_label(user):
|
|
271
|
-
"""DM 展示名。新字段 `friendLabel`,兼容旧字段 `xia_you_label`。"""
|
|
272
|
-
if not isinstance(user, dict):
|
|
273
|
-
return None
|
|
274
|
-
v = user.get("friendLabel")
|
|
275
|
-
if v is not None:
|
|
276
|
-
return v
|
|
277
|
-
return user.get("xia_you_label")
|
|
278
|
-
|
|
279
|
-
def name_in_config(name, config_path='references/config.json'):
|
|
280
|
-
"""检查名字是否在配置文件的用户列表中"""
|
|
281
|
-
basic_config = get_config(config_path)
|
|
282
|
-
user_list = basic_config.get("users", [])
|
|
283
|
-
for user in user_list:
|
|
284
|
-
if user.get("name") == name:
|
|
285
|
-
return True
|
|
286
|
-
return False
|
|
287
|
-
|
|
288
|
-
def replace_name_config(ost_name, new_name, config_path='references/config.json'):
|
|
289
|
-
"""替换配置文件中用户列表中的名字"""
|
|
290
|
-
basic_config = get_config(config_path)
|
|
291
|
-
user_list = basic_config.get("users", [])
|
|
292
|
-
for user in user_list:
|
|
293
|
-
if user.get("name") == ost_name:
|
|
294
|
-
user["name"] = new_name
|
|
295
|
-
break
|
|
296
|
-
basic_config["users"] = user_list
|
|
297
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
298
|
-
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
def delete_user_config(ost_name, config_path='references/config.json'):
|
|
302
|
-
"""删除配置文件中用户列表中的用户信息"""
|
|
303
|
-
basic_config = get_config(config_path)
|
|
304
|
-
user_list = basic_config.get("users", [])
|
|
305
|
-
for user in user_list:
|
|
306
|
-
if user.get("name") == ost_name:
|
|
307
|
-
user_list.remove(user)
|
|
308
|
-
break
|
|
309
|
-
basic_config["users"] = user_list
|
|
310
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
311
|
-
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
312
|
-
|
|
313
|
-
def add_user_config(
|
|
314
|
-
name,
|
|
315
|
-
image_path,
|
|
316
|
-
config_path="references/config.json",
|
|
317
|
-
friend_id=None,
|
|
318
|
-
friend_label=None,
|
|
319
|
-
):
|
|
320
|
-
"""添加用户到 config。friendId 为可选好友 userId(正整数);friendLabel 为可选展示名。"""
|
|
321
|
-
basic_config = get_config(config_path)
|
|
322
|
-
user_list = basic_config.get("users", [])
|
|
323
|
-
face_embedding = get_baseface_embedding(image_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
324
|
-
if face_embedding is None:
|
|
325
|
-
return False, "未检测到有效的人脸信息"
|
|
326
|
-
|
|
327
|
-
abs_image_path = os.path.realpath(image_path)
|
|
328
|
-
entry = {"name": name, "info": [{"file_path": abs_image_path, "embedding": face_embedding}]}
|
|
329
|
-
if friend_id is not None:
|
|
330
|
-
entry["friendId"] = int(friend_id)
|
|
331
|
-
if friend_label is not None and str(friend_label).strip():
|
|
332
|
-
entry["friendLabel"] = str(friend_label).strip()
|
|
333
|
-
user_list.append(entry)
|
|
334
|
-
basic_config["users"] = user_list
|
|
335
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
336
|
-
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
337
|
-
|
|
338
|
-
return True, "添加成功"
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
def replace_user_embeding_config(name, new_image_path, config_path='references/config.json'):
|
|
342
|
-
"""根据用户名替换其所有照片和embedding信息,并删除原始照片"""
|
|
343
|
-
basic_config = get_config(config_path)
|
|
344
|
-
user_list = basic_config.get("users", [])
|
|
345
|
-
face_embedding = get_baseface_embedding(new_image_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
346
|
-
if face_embedding is None:
|
|
347
|
-
return False, "未检测到有效的人脸信息"
|
|
348
|
-
|
|
349
|
-
abs_new_path = os.path.realpath(new_image_path)
|
|
350
|
-
for user in user_list:
|
|
351
|
-
if user.get("name") == name:
|
|
352
|
-
for info in user.get("info", []):
|
|
353
|
-
old_path = info.get("file_path", "")
|
|
354
|
-
if old_path and old_path != abs_new_path and os.path.exists(old_path):
|
|
355
|
-
os.remove(old_path)
|
|
356
|
-
user["info"] = [{"file_path": abs_new_path, "embedding": face_embedding}]
|
|
357
|
-
break
|
|
358
|
-
basic_config["users"] = user_list
|
|
359
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
360
|
-
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
361
|
-
|
|
362
|
-
return True, "替换成功"
|
|
363
|
-
|
|
364
|
-
def append_user_embeding_config(name, image_path, config_path='references/config.json'):
|
|
365
|
-
"""新增用户embedding信息"""
|
|
366
|
-
basic_config = get_config(config_path)
|
|
367
|
-
user_list = basic_config.get("users", [])
|
|
368
|
-
face_embedding = get_baseface_embedding(image_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
369
|
-
if face_embedding is None:
|
|
370
|
-
return False, "未检测到有效的人脸信息"
|
|
371
|
-
|
|
372
|
-
for user in user_list:
|
|
373
|
-
if user.get("name") == name:
|
|
374
|
-
abs_image_path = os.path.realpath(image_path)
|
|
375
|
-
user.get("info", []).append({"file_path": abs_image_path, "embedding": face_embedding})
|
|
376
|
-
break
|
|
377
|
-
basic_config["users"] = user_list
|
|
378
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
379
|
-
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
380
|
-
|
|
381
|
-
return True, "添加成功"
|
|
382
|
-
|
|
383
|
-
def save_embeddings(image_path, faces, json_path):
|
|
384
|
-
"""保存多个人脸embedding到json文件"""
|
|
385
|
-
data = {
|
|
386
|
-
"image_path": str(image_path),
|
|
387
|
-
"embeddings": [face.get('embedding', []) for face in faces],
|
|
388
|
-
"boxes": [face.get('box', []) for face in faces],
|
|
389
|
-
"det_scores": [face.get('det_score', 0) for face in faces]
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
with open(json_path, 'w', encoding='utf-8') as f:
|
|
393
|
-
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
394
|
-
|
|
395
|
-
return json_path
|
|
396
|
-
|
|
397
|
-
def embeddings_from_images(image_list, det_thr=0.5):
|
|
398
|
-
"""获取图片列表的所有人脸embedding信息,并保存到对应的json文件中"""
|
|
399
|
-
json_list = []
|
|
400
|
-
|
|
401
|
-
for image_path in image_list:
|
|
402
|
-
try:
|
|
403
|
-
abs_img = os.path.abspath(image_path)
|
|
404
|
-
dir_name = os.path.dirname(abs_img)
|
|
405
|
-
stem = os.path.splitext(os.path.basename(abs_img))[0]
|
|
406
|
-
|
|
407
|
-
save_path = os.path.join(dir_name, ".embedding")
|
|
408
|
-
os.makedirs(save_path, exist_ok=True)
|
|
409
|
-
json_path = os.path.join(save_path, f"{stem}.json")
|
|
410
|
-
|
|
411
|
-
if os.path.exists(json_path):
|
|
412
|
-
print(f"跳过已存在的embedding文件: {json_path}")
|
|
413
|
-
json_list.append(str(json_path))
|
|
414
|
-
continue
|
|
415
|
-
|
|
416
|
-
print(f"开始处理图片: {image_path}")
|
|
417
|
-
result = detect_faces(image_path, sophnet_tools.get_api_key())
|
|
418
|
-
faces = result.get("output", [])
|
|
419
|
-
faces = [face for face in faces if face.get('det_score', 0) >= det_thr]
|
|
420
|
-
|
|
421
|
-
save_embeddings(image_path, faces, json_path)
|
|
422
|
-
json_list.append(str(json_path))
|
|
423
|
-
|
|
424
|
-
except Exception as e:
|
|
425
|
-
print(f"处理 {image_path} 时出错: {e}", file=sys.stderr)
|
|
426
|
-
|
|
427
|
-
return json_list
|
|
428
|
-
|
|
429
|
-
def embeddings_from_imagepath(image_path, det_thr=0.5):
|
|
430
|
-
image_list = list_images_recursive(image_path)
|
|
431
|
-
return embeddings_from_images(image_list, det_thr)
|
|
432
|
-
|
|
433
|
-
def get_searchface_embeddings_from_json(json_paths):
|
|
434
|
-
"""从json文件列表加载搜索人脸embeddings"""
|
|
435
|
-
all_embeddings = {}
|
|
436
|
-
for json_path in json_paths:
|
|
437
|
-
with open(json_path, 'r', encoding='utf-8') as f:
|
|
438
|
-
data = json.load(f)
|
|
439
|
-
embeddings = data.get("embeddings", [])
|
|
440
|
-
image_name = data.get("image_path", "")
|
|
441
|
-
all_embeddings[image_name] = np.array(embeddings).astype(np.float32)
|
|
442
|
-
return all_embeddings
|
|
443
|
-
|
|
444
|
-
def cosine_similarity(vec1, vec2):
|
|
445
|
-
"""计算余弦相似度"""
|
|
446
|
-
dot_product = np.dot(vec1, vec2)
|
|
447
|
-
norm1 = np.linalg.norm(vec1)
|
|
448
|
-
norm2 = np.linalg.norm(vec2)
|
|
449
|
-
if norm1 == 0 or norm2 == 0:
|
|
450
|
-
return 0
|
|
451
|
-
return dot_product / (norm1 * norm2)
|
|
452
|
-
|
|
453
|
-
def search_similar_faces(name, search_path, config_path='references/config.json'):
|
|
454
|
-
"""搜索相似人脸"""
|
|
455
|
-
basic_config = get_config(config_path)
|
|
456
|
-
user_list = basic_config.get("users", [])
|
|
457
|
-
|
|
458
|
-
embedding_list = []
|
|
459
|
-
for user in user_list:
|
|
460
|
-
if user.get("name") == name:
|
|
461
|
-
for info in user.get("info", []):
|
|
462
|
-
embedding = info.get("embedding")
|
|
463
|
-
if embedding:
|
|
464
|
-
embedding_list.append(embedding)
|
|
465
|
-
|
|
466
|
-
json_list = embeddings_from_imagepath(search_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
467
|
-
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
results = []
|
|
471
|
-
for face_embedding in embedding_list:
|
|
472
|
-
for image_name, embeddings in search_embeddings.items():
|
|
473
|
-
for idx, embedding in enumerate(embeddings):
|
|
474
|
-
similarity = cosine_similarity(face_embedding, embedding)
|
|
475
|
-
if similarity >= basic_config.get("search_similarity_threshold", 0.3):
|
|
476
|
-
existing = None
|
|
477
|
-
for result in results:
|
|
478
|
-
if result.get("image_path") == image_name:
|
|
479
|
-
existing = result
|
|
480
|
-
break
|
|
481
|
-
if existing is None:
|
|
482
|
-
results.append({
|
|
483
|
-
"image_path": image_name,
|
|
484
|
-
"face_index": idx,
|
|
485
|
-
"similarity": float(similarity)
|
|
486
|
-
})
|
|
487
|
-
elif existing["similarity"] < float(similarity):
|
|
488
|
-
existing["face_index"] = idx
|
|
489
|
-
existing["similarity"] = float(similarity)
|
|
490
|
-
results.sort(key=lambda x: x["similarity"], reverse=True)
|
|
491
|
-
for user in user_list:
|
|
492
|
-
if user.get("name") == name:
|
|
493
|
-
user["search_result"] = results
|
|
494
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
495
|
-
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
496
|
-
return results
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
def quick_search(image_path, search_path, config_path='references/config.json'):
|
|
500
|
-
"""直接用一张照片搜索相似人脸,结果缓存到 config.json 的 quick_search_result 字段,
|
|
501
|
-
供后续 copy/pack 命令使用。
|
|
502
|
-
"""
|
|
503
|
-
basic_config = get_config(config_path)
|
|
504
|
-
det_thr = basic_config.get("query_threshold", 0.7)
|
|
505
|
-
search_threshold = basic_config.get("search_similarity_threshold", 0.3)
|
|
506
|
-
|
|
507
|
-
face_embedding = get_baseface_embedding(image_path, det_thr=det_thr)
|
|
508
|
-
if face_embedding is None:
|
|
509
|
-
return None, "照片中未发现有效的人脸信息"
|
|
510
|
-
|
|
511
|
-
json_list = embeddings_from_imagepath(search_path, det_thr=det_thr)
|
|
512
|
-
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
513
|
-
|
|
514
|
-
results = []
|
|
515
|
-
for img_name, embeddings in search_embeddings.items():
|
|
516
|
-
for idx, embedding in enumerate(embeddings):
|
|
517
|
-
similarity = cosine_similarity(face_embedding, embedding)
|
|
518
|
-
if similarity >= search_threshold:
|
|
519
|
-
existing = None
|
|
520
|
-
for result in results:
|
|
521
|
-
if result.get("image_path") == img_name:
|
|
522
|
-
existing = result
|
|
523
|
-
break
|
|
524
|
-
if existing is None:
|
|
525
|
-
results.append({
|
|
526
|
-
"image_path": img_name,
|
|
527
|
-
"face_index": idx,
|
|
528
|
-
"similarity": float(similarity)
|
|
529
|
-
})
|
|
530
|
-
elif existing["similarity"] < float(similarity):
|
|
531
|
-
existing["face_index"] = idx
|
|
532
|
-
existing["similarity"] = float(similarity)
|
|
533
|
-
|
|
534
|
-
results.sort(key=lambda x: x["similarity"], reverse=True)
|
|
535
|
-
|
|
536
|
-
basic_config["quick_search_result"] = results
|
|
537
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
538
|
-
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
539
|
-
|
|
540
|
-
return results, "搜索完成"
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def classify_all_users(search_path, config_path='references/config.json'):
|
|
544
|
-
"""一键分类:遍历所有注册用户,搜索所有图片并返回每个用户的匹配结果"""
|
|
545
|
-
basic_config = get_config(config_path)
|
|
546
|
-
user_list = basic_config.get("users", [])
|
|
547
|
-
|
|
548
|
-
if not user_list:
|
|
549
|
-
return {}
|
|
550
|
-
|
|
551
|
-
json_list = embeddings_from_imagepath(search_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
552
|
-
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
553
|
-
search_threshold = basic_config.get("search_similarity_threshold", 0.3)
|
|
554
|
-
|
|
555
|
-
all_results = {}
|
|
556
|
-
for user in user_list:
|
|
557
|
-
name = user.get("name", "")
|
|
558
|
-
embedding_list = []
|
|
559
|
-
for info in user.get("info", []):
|
|
560
|
-
embedding = info.get("embedding")
|
|
561
|
-
if embedding:
|
|
562
|
-
embedding_list.append(embedding)
|
|
563
|
-
|
|
564
|
-
if not embedding_list:
|
|
565
|
-
continue
|
|
566
|
-
|
|
567
|
-
results = []
|
|
568
|
-
for face_embedding in embedding_list:
|
|
569
|
-
for image_name, embeddings in search_embeddings.items():
|
|
570
|
-
for idx, embedding in enumerate(embeddings):
|
|
571
|
-
similarity = cosine_similarity(face_embedding, embedding)
|
|
572
|
-
if similarity >= search_threshold:
|
|
573
|
-
existing = None
|
|
574
|
-
for result in results:
|
|
575
|
-
if result.get("image_path") == image_name:
|
|
576
|
-
existing = result
|
|
577
|
-
break
|
|
578
|
-
if existing is None:
|
|
579
|
-
results.append({
|
|
580
|
-
"image_path": image_name,
|
|
581
|
-
"face_index": idx,
|
|
582
|
-
"similarity": float(similarity)
|
|
583
|
-
})
|
|
584
|
-
elif existing["similarity"] < float(similarity):
|
|
585
|
-
existing["face_index"] = idx
|
|
586
|
-
existing["similarity"] = float(similarity)
|
|
587
|
-
|
|
588
|
-
results.sort(key=lambda x: x["similarity"], reverse=True)
|
|
589
|
-
user["search_result"] = results
|
|
590
|
-
all_results[name] = results
|
|
591
|
-
|
|
592
|
-
with open(config_path, 'w', encoding='utf-8') as f:
|
|
593
|
-
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
594
|
-
|
|
595
|
-
return all_results
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
def _format_classify_dm_message(name: str, items: list, max_lines: int = 30, max_chars: int = 3500) -> str:
|
|
599
|
-
"""将单用户分类结果格式化为私信正文(控制长度)。"""
|
|
600
|
-
n = len(items)
|
|
601
|
-
lines = [f"【照片分类结果】👤 {name}", f"匹配共 {n} 张。"]
|
|
602
|
-
if n == 0:
|
|
603
|
-
lines.append("本轮目录中暂无匹配照片。")
|
|
604
|
-
return "\n".join(lines)
|
|
605
|
-
for i, item in enumerate(items[:max_lines]):
|
|
606
|
-
path = item.get("image_path", "")
|
|
607
|
-
sim = item.get("similarity", 0)
|
|
608
|
-
try:
|
|
609
|
-
sim_s = f"{float(sim):.4f}"
|
|
610
|
-
except (TypeError, ValueError):
|
|
611
|
-
sim_s = str(sim)
|
|
612
|
-
lines.append(f"- {path} · 相似度 {sim_s}")
|
|
613
|
-
if n > max_lines:
|
|
614
|
-
lines.append(f"… 另有 {n - max_lines} 张未列出,请在本机打包下载查看完整列表。")
|
|
615
|
-
text = "\n".join(lines)
|
|
616
|
-
if len(text) > max_chars:
|
|
617
|
-
text = text[: max_chars - 20] + "\n…(正文过长已截断)"
|
|
618
|
-
return text
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
def _format_pack_link_dm_message(name: str, url: str) -> str:
|
|
622
|
-
"""pack 成功后发给 friendId 的私信正文(含下载链接)。"""
|
|
623
|
-
return (
|
|
624
|
-
f"✨「{name}」的照片已为您送达\n\n"
|
|
625
|
-
f"{url}\n\n"
|
|
626
|
-
f"请在 24 小时内下载保存;逾期链接可能失效,请及时转存。"
|
|
627
|
-
)
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
def _find_user_by_name(config_path: str, name: str):
|
|
631
|
-
if not name:
|
|
632
|
-
return None
|
|
633
|
-
for u in get_config(config_path).get("users", []):
|
|
634
|
-
if u.get("name") == name:
|
|
635
|
-
return u
|
|
636
|
-
return None
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
def _send_classify_dm(friend_id: int, message: str, timeout: int = 90) -> tuple:
|
|
640
|
-
"""调用同目录 send_dm_message.py 发送私信(--user-id 即 friendId)。返回 (success: bool, detail: str)。"""
|
|
641
|
-
script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "send_dm_message.py")
|
|
642
|
-
if not os.path.isfile(script):
|
|
643
|
-
return False, f"未找到 DM 脚本: {script}"
|
|
644
|
-
try:
|
|
645
|
-
proc = subprocess.run(
|
|
646
|
-
[sys.executable, script, "--user-id", str(int(friend_id)), "-m", message],
|
|
647
|
-
capture_output=True,
|
|
648
|
-
text=True,
|
|
649
|
-
timeout=timeout,
|
|
650
|
-
)
|
|
651
|
-
if proc.returncode == 0:
|
|
652
|
-
return True, (proc.stdout or "").strip() or "ok"
|
|
653
|
-
err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
|
|
654
|
-
return False, err[:2000]
|
|
655
|
-
except subprocess.TimeoutExpired:
|
|
656
|
-
return False, "发送私信超时"
|
|
657
|
-
except Exception as e:
|
|
658
|
-
return False, str(e)
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
def notify_classify_dm(config_path: str, all_results: dict) -> dict:
|
|
662
|
-
"""对配置了 friendId 的用户推送 DM。返回 {用户名: {success, friendId, friendLabel?, detail?}}。"""
|
|
663
|
-
basic_config = get_config(config_path)
|
|
664
|
-
out = {}
|
|
665
|
-
for user in basic_config.get("users", []):
|
|
666
|
-
xid = _user_friend_id(user)
|
|
667
|
-
name = user.get("name") or ""
|
|
668
|
-
if xid is None:
|
|
669
|
-
continue
|
|
670
|
-
try:
|
|
671
|
-
xid_int = int(xid)
|
|
672
|
-
except (TypeError, ValueError):
|
|
673
|
-
bad = {"success": False, "detail": f"无效的 friendId: {xid!r}"}
|
|
674
|
-
lab_bad = _user_friend_label(user)
|
|
675
|
-
if isinstance(lab_bad, str) and lab_bad.strip():
|
|
676
|
-
bad["friendLabel"] = lab_bad.strip()
|
|
677
|
-
out[name if name else f"invalid_friend_id:{xid}"] = bad
|
|
678
|
-
continue
|
|
679
|
-
if xid_int <= 0:
|
|
680
|
-
fail_e = {"success": False, "detail": "friendId 须为正整数", "friendId": xid_int}
|
|
681
|
-
lab0 = _user_friend_label(user)
|
|
682
|
-
if isinstance(lab0, str) and lab0.strip():
|
|
683
|
-
fail_e["friendLabel"] = lab0.strip()
|
|
684
|
-
out[name] = fail_e
|
|
685
|
-
continue
|
|
686
|
-
lab = _user_friend_label(user)
|
|
687
|
-
if isinstance(lab, str) and lab.strip():
|
|
688
|
-
label_s = lab.strip()
|
|
689
|
-
else:
|
|
690
|
-
label_s = None
|
|
691
|
-
items = all_results.get(name, [])
|
|
692
|
-
msg = _format_classify_dm_message(name, items)
|
|
693
|
-
ok, detail = _send_classify_dm(xid_int, msg)
|
|
694
|
-
entry = {"success": ok, "friendId": xid_int}
|
|
695
|
-
if label_s:
|
|
696
|
-
entry["friendLabel"] = label_s
|
|
697
|
-
if not ok:
|
|
698
|
-
entry["detail"] = detail
|
|
699
|
-
out[name] = entry
|
|
700
|
-
return out
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
def notify_pack_link_dm(config_path: str, name_to_url: dict) -> dict:
|
|
704
|
-
"""pack 成功后,向配置了 friendId 的用户私信下载链接。name_to_url: {本地用户名: url}。"""
|
|
705
|
-
out = {}
|
|
706
|
-
for name, url in (name_to_url or {}).items():
|
|
707
|
-
if not name or name == "quick_search":
|
|
708
|
-
continue
|
|
709
|
-
if not url:
|
|
710
|
-
continue
|
|
711
|
-
user = _find_user_by_name(config_path, name)
|
|
712
|
-
if not user:
|
|
713
|
-
continue
|
|
714
|
-
xid = _user_friend_id(user)
|
|
715
|
-
if xid is None:
|
|
716
|
-
continue
|
|
717
|
-
try:
|
|
718
|
-
xid_int = int(xid)
|
|
719
|
-
except (TypeError, ValueError):
|
|
720
|
-
bad = {"success": False, "detail": f"无效的 friendId: {xid!r}", "url": url}
|
|
721
|
-
lab_bad = _user_friend_label(user)
|
|
722
|
-
if isinstance(lab_bad, str) and lab_bad.strip():
|
|
723
|
-
bad["friendLabel"] = lab_bad.strip()
|
|
724
|
-
out[name] = bad
|
|
725
|
-
continue
|
|
726
|
-
if xid_int <= 0:
|
|
727
|
-
fail_e = {"success": False, "detail": "friendId 须为正整数", "friendId": xid_int, "url": url}
|
|
728
|
-
lab0 = _user_friend_label(user)
|
|
729
|
-
if isinstance(lab0, str) and lab0.strip():
|
|
730
|
-
fail_e["friendLabel"] = lab0.strip()
|
|
731
|
-
out[name] = fail_e
|
|
732
|
-
continue
|
|
733
|
-
lab = _user_friend_label(user)
|
|
734
|
-
label_s = lab.strip() if isinstance(lab, str) and lab.strip() else None
|
|
735
|
-
msg = _format_pack_link_dm_message(name, url)
|
|
736
|
-
ok, detail = _send_classify_dm(xid_int, msg)
|
|
737
|
-
entry = {"success": ok, "friendId": xid_int, "url": url}
|
|
738
|
-
if label_s:
|
|
739
|
-
entry["friendLabel"] = label_s
|
|
740
|
-
if not ok:
|
|
741
|
-
entry["detail"] = detail
|
|
742
|
-
out[name] = entry
|
|
743
|
-
return out
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
def _fmt_dm_line_ok(disp: str) -> str:
|
|
747
|
-
"""dm_lines:私信发送成功(展示虾友昵称或 friendId)。"""
|
|
748
|
-
return f"✅ 💌 已向虾友「{disp}」发送成功"
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
def _fmt_dm_line_fail(disp: str, detail: str) -> str:
|
|
752
|
-
"""dm_lines:私信发送失败。"""
|
|
753
|
-
return f"❌ 💢 虾友「{disp}」发送失败:{detail}"
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
def _fmt_dm_line_no_friend(name: str) -> str:
|
|
757
|
-
"""dm_lines:未配置 friendId。"""
|
|
758
|
-
return f"📭 「{name}」暂未绑定虾友,跳过私信"
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
def _fmt_pack_line_fail(name: str, err: str) -> str:
|
|
762
|
-
"""dm_lines:pack 打包失败。"""
|
|
763
|
-
return f"📦❌ 「{name}」下载包生成失败:{err}"
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
def build_pack_dm_status_lines(
|
|
767
|
-
config_path: str,
|
|
768
|
-
results_dict: dict,
|
|
769
|
-
user_urls: dict,
|
|
770
|
-
errors: dict | None,
|
|
771
|
-
pack_dm_out: dict,
|
|
772
|
-
) -> list:
|
|
773
|
-
"""pack 后的虾友发送状态短句(与 classify 的 dm_lines 同一套 emoji 模板)。"""
|
|
774
|
-
lines = []
|
|
775
|
-
errors = errors or {}
|
|
776
|
-
pack_dm_out = pack_dm_out or {}
|
|
777
|
-
for name in results_dict.keys():
|
|
778
|
-
if name == "quick_search":
|
|
779
|
-
continue
|
|
780
|
-
if name in errors and name not in user_urls:
|
|
781
|
-
lines.append(_fmt_pack_line_fail(name, str(errors[name])))
|
|
782
|
-
continue
|
|
783
|
-
if name not in user_urls:
|
|
784
|
-
continue
|
|
785
|
-
u = _find_user_by_name(config_path, name) or {}
|
|
786
|
-
xid = _user_friend_id(u)
|
|
787
|
-
if xid is None:
|
|
788
|
-
lines.append(_fmt_dm_line_no_friend(name))
|
|
789
|
-
continue
|
|
790
|
-
|
|
791
|
-
def _disp(uu) -> str:
|
|
792
|
-
lab = _user_friend_label(uu)
|
|
793
|
-
if isinstance(lab, str) and lab.strip():
|
|
794
|
-
return lab.strip()
|
|
795
|
-
try:
|
|
796
|
-
return str(int(_user_friend_id(uu)))
|
|
797
|
-
except (TypeError, ValueError):
|
|
798
|
-
return str(_user_friend_id(uu))
|
|
799
|
-
|
|
800
|
-
try:
|
|
801
|
-
xid_int = int(xid)
|
|
802
|
-
except (TypeError, ValueError):
|
|
803
|
-
disp = _disp(u)
|
|
804
|
-
info = pack_dm_out.get(name)
|
|
805
|
-
if info and not info.get("success"):
|
|
806
|
-
det = (info.get("detail") or "未知错误").strip()
|
|
807
|
-
lines.append(_fmt_dm_line_fail(disp, det))
|
|
808
|
-
else:
|
|
809
|
-
lines.append(_fmt_dm_line_fail(disp, "friendId 无效"))
|
|
810
|
-
continue
|
|
811
|
-
|
|
812
|
-
if xid_int <= 0:
|
|
813
|
-
disp = _disp(u)
|
|
814
|
-
info = pack_dm_out.get(name)
|
|
815
|
-
if info and not info.get("success"):
|
|
816
|
-
det = (info.get("detail") or "未知错误").strip()
|
|
817
|
-
lines.append(_fmt_dm_line_fail(disp, det))
|
|
818
|
-
else:
|
|
819
|
-
lines.append(_fmt_dm_line_fail(disp, "friendId 须为正整数"))
|
|
820
|
-
continue
|
|
821
|
-
|
|
822
|
-
disp = _disp(u)
|
|
823
|
-
info = pack_dm_out.get(name)
|
|
824
|
-
if info and info.get("success"):
|
|
825
|
-
lines.append(_fmt_dm_line_ok(disp))
|
|
826
|
-
elif info:
|
|
827
|
-
det = (info.get("detail") or "未知错误").strip()
|
|
828
|
-
lines.append(_fmt_dm_line_fail(disp, det))
|
|
829
|
-
else:
|
|
830
|
-
lines.append(_fmt_dm_line_fail(disp, "未返回状态"))
|
|
831
|
-
return lines
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
def build_dm_status_lines(config_path: str, all_results: dict, dm_out: dict) -> list:
|
|
835
|
-
"""按本轮参与分类的用户顺序,生成最终提示用短句(含 emoji,见 _fmt_dm_line_*)。"""
|
|
836
|
-
basic_config = get_config(config_path)
|
|
837
|
-
name_to_user = {u.get("name"): u for u in basic_config.get("users", []) if u.get("name")}
|
|
838
|
-
lines = []
|
|
839
|
-
for name in all_results.keys():
|
|
840
|
-
u = name_to_user.get(name, {})
|
|
841
|
-
xid = _user_friend_id(u)
|
|
842
|
-
if xid is None:
|
|
843
|
-
lines.append(_fmt_dm_line_no_friend(name))
|
|
844
|
-
continue
|
|
845
|
-
|
|
846
|
-
def _disp_for_xid(raw, fallback_int=None) -> str:
|
|
847
|
-
lab = _user_friend_label(u)
|
|
848
|
-
if isinstance(lab, str) and lab.strip():
|
|
849
|
-
return lab.strip()
|
|
850
|
-
if fallback_int is not None:
|
|
851
|
-
return str(fallback_int)
|
|
852
|
-
return str(raw)
|
|
853
|
-
|
|
854
|
-
try:
|
|
855
|
-
xid_int = int(xid)
|
|
856
|
-
except (TypeError, ValueError):
|
|
857
|
-
disp = _disp_for_xid(xid, None)
|
|
858
|
-
info = dm_out.get(name) if dm_out else None
|
|
859
|
-
if info and not info.get("success"):
|
|
860
|
-
det = (info.get("detail") or "未知错误").strip()
|
|
861
|
-
lines.append(_fmt_dm_line_fail(disp, det))
|
|
862
|
-
else:
|
|
863
|
-
lines.append(_fmt_dm_line_fail(disp, "friendId 无效"))
|
|
864
|
-
continue
|
|
865
|
-
|
|
866
|
-
if xid_int <= 0:
|
|
867
|
-
disp = _disp_for_xid(xid, xid_int)
|
|
868
|
-
info = dm_out.get(name) if dm_out else None
|
|
869
|
-
if info and not info.get("success"):
|
|
870
|
-
det = (info.get("detail") or "未知错误").strip()
|
|
871
|
-
lines.append(_fmt_dm_line_fail(disp, det))
|
|
872
|
-
else:
|
|
873
|
-
lines.append(_fmt_dm_line_fail(disp, "friendId 须为正整数"))
|
|
874
|
-
continue
|
|
875
|
-
|
|
876
|
-
disp = _disp_for_xid(xid, xid_int)
|
|
877
|
-
info = dm_out.get(name) if dm_out else None
|
|
878
|
-
if info and info.get("success"):
|
|
879
|
-
lines.append(_fmt_dm_line_ok(disp))
|
|
880
|
-
elif info:
|
|
881
|
-
det = (info.get("detail") or "未知错误").strip()
|
|
882
|
-
lines.append(_fmt_dm_line_fail(disp, det))
|
|
883
|
-
else:
|
|
884
|
-
lines.append(_fmt_dm_line_fail(disp, "未返回状态"))
|
|
885
|
-
return lines
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
def copy_results_to_folder(results, target_folder, user_name=None):
|
|
889
|
-
"""将搜索结果中的照片复制到指定文件夹。
|
|
890
|
-
如果指定了 user_name,会在 target_folder 下创建以用户名命名的子目录。
|
|
891
|
-
results 可以是列表(单用户)或字典(多用户一键分类结果)。
|
|
892
|
-
返回 (成功数, 失败数, 目标目录路径)。
|
|
893
|
-
"""
|
|
894
|
-
if isinstance(results, dict):
|
|
895
|
-
total_ok, total_fail = 0, 0
|
|
896
|
-
base = os.path.realpath(target_folder)
|
|
897
|
-
os.makedirs(base, exist_ok=True)
|
|
898
|
-
for name, items in results.items():
|
|
899
|
-
ok, fail, _ = copy_results_to_folder(items, os.path.join(base, name))
|
|
900
|
-
total_ok += ok
|
|
901
|
-
total_fail += fail
|
|
902
|
-
return total_ok, total_fail, base
|
|
903
|
-
|
|
904
|
-
dest = os.path.realpath(target_folder)
|
|
905
|
-
if user_name:
|
|
906
|
-
dest = os.path.join(dest, user_name)
|
|
907
|
-
os.makedirs(dest, exist_ok=True)
|
|
908
|
-
|
|
909
|
-
ok_count, fail_count = 0, 0
|
|
910
|
-
for item in results:
|
|
911
|
-
src = item.get("image_path", "")
|
|
912
|
-
if not src or not os.path.exists(src):
|
|
913
|
-
fail_count += 1
|
|
914
|
-
continue
|
|
915
|
-
base_name = os.path.basename(src)
|
|
916
|
-
dst = os.path.join(dest, base_name)
|
|
917
|
-
if os.path.exists(dst):
|
|
918
|
-
stem, ext = os.path.splitext(base_name)
|
|
919
|
-
counter = 1
|
|
920
|
-
while os.path.exists(dst):
|
|
921
|
-
dst = os.path.join(dest, f"{stem}_{counter}{ext}")
|
|
922
|
-
counter += 1
|
|
923
|
-
try:
|
|
924
|
-
shutil.copy2(src, dst)
|
|
925
|
-
ok_count += 1
|
|
926
|
-
except Exception as e:
|
|
927
|
-
print(f"复制失败 {src}: {e}", file=sys.stderr)
|
|
928
|
-
fail_count += 1
|
|
929
|
-
|
|
930
|
-
return ok_count, fail_count, str(dest)
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
def pack_results_to_url(results, archive_name="search_results", upload_timeout=120):
|
|
934
|
-
"""将搜索结果中的照片压缩为 zip 并上传获取下载链接。
|
|
935
|
-
results 可以是列表(单用户)或字典(多用户一键分类结果)。
|
|
936
|
-
upload_timeout 用于控制上传超时时间(秒),压缩文件较大时应适当增大。
|
|
937
|
-
返回 (下载链接, zip文件路径) 或 (None, 错误信息)。
|
|
938
|
-
"""
|
|
939
|
-
import tempfile
|
|
940
|
-
tmp_dir = tempfile.mkdtemp()
|
|
941
|
-
zip_path = os.path.join(tmp_dir, f"{archive_name}.zip")
|
|
942
|
-
|
|
943
|
-
try:
|
|
944
|
-
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
945
|
-
file_count = 0
|
|
946
|
-
if isinstance(results, dict):
|
|
947
|
-
for name, items in results.items():
|
|
948
|
-
for item in items:
|
|
949
|
-
src = item.get("image_path", "")
|
|
950
|
-
if not src or not os.path.exists(src):
|
|
951
|
-
continue
|
|
952
|
-
arcname = f"{name}/{os.path.basename(src)}"
|
|
953
|
-
zf.write(src, arcname)
|
|
954
|
-
file_count += 1
|
|
955
|
-
else:
|
|
956
|
-
for item in results:
|
|
957
|
-
src = item.get("image_path", "")
|
|
958
|
-
if not src or not os.path.exists(src):
|
|
959
|
-
continue
|
|
960
|
-
zf.write(src, os.path.basename(src))
|
|
961
|
-
file_count += 1
|
|
962
|
-
|
|
963
|
-
if file_count == 0:
|
|
964
|
-
return None, "没有可打包的文件"
|
|
965
|
-
|
|
966
|
-
url = convert_to_url(zip_path, timeout=upload_timeout)
|
|
967
|
-
return url, zip_path
|
|
968
|
-
|
|
969
|
-
except Exception as e:
|
|
970
|
-
return None, str(e)
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
def _json_output(data):
|
|
974
|
-
"""统一 JSON 输出到 stdout"""
|
|
975
|
-
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
def main():
|
|
979
|
-
parser = argparse.ArgumentParser(description="📸 照片分类器 CLI")
|
|
980
|
-
parser.add_argument("-c", "--config", default="references/config.json",
|
|
981
|
-
help="配置文件路径 (默认: references/config.json)")
|
|
982
|
-
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
983
|
-
|
|
984
|
-
# check — 检查用户名是否存在
|
|
985
|
-
sp = subparsers.add_parser("check", help="检查用户名是否已注册")
|
|
986
|
-
sp.add_argument("name", help="用户名")
|
|
987
|
-
|
|
988
|
-
# add — 注册新用户
|
|
989
|
-
sp = subparsers.add_parser("add", help="注册新用户")
|
|
990
|
-
sp.add_argument("name", help="用户名")
|
|
991
|
-
sp.add_argument("image", help="照片路径")
|
|
992
|
-
sp.add_argument(
|
|
993
|
-
"--friend-id",
|
|
994
|
-
type=int,
|
|
995
|
-
default=None,
|
|
996
|
-
dest="friend_id",
|
|
997
|
-
metavar="ID",
|
|
998
|
-
help="可选:好友 userId(friendId)。设置后,一键 classify 会将该用户分类结果自动 DM 到此账号",
|
|
999
|
-
)
|
|
1000
|
-
sp.add_argument(
|
|
1001
|
-
"--friend-label",
|
|
1002
|
-
default=None,
|
|
1003
|
-
dest="friend_label",
|
|
1004
|
-
metavar="NAME",
|
|
1005
|
-
help="可选:DM 展示名(与自然语言「虾友:xxx」对应,用于最终提示括号内文案)",
|
|
1006
|
-
)
|
|
1007
|
-
|
|
1008
|
-
# replace — 替换用户照片
|
|
1009
|
-
sp = subparsers.add_parser("replace", help="替换用户照片(删除旧照片)")
|
|
1010
|
-
sp.add_argument("name", help="用户名")
|
|
1011
|
-
sp.add_argument("image", help="新照片路径")
|
|
1012
|
-
|
|
1013
|
-
# append — 追加照片
|
|
1014
|
-
sp = subparsers.add_parser("append", help="为已有用户追加一张照片")
|
|
1015
|
-
sp.add_argument("name", help="用户名")
|
|
1016
|
-
sp.add_argument("image", help="照片路径")
|
|
1017
|
-
|
|
1018
|
-
# rename — 修改用户名
|
|
1019
|
-
sp = subparsers.add_parser("rename", help="修改用户名")
|
|
1020
|
-
sp.add_argument("old_name", help="原用户名")
|
|
1021
|
-
sp.add_argument("new_name", help="新用户名")
|
|
1022
|
-
|
|
1023
|
-
# delete — 删除用户
|
|
1024
|
-
sp = subparsers.add_parser("delete", help="删除用户")
|
|
1025
|
-
sp.add_argument("name", help="用户名")
|
|
1026
|
-
|
|
1027
|
-
# search — 搜索某用户的相似人脸
|
|
1028
|
-
sp = subparsers.add_parser("search", help="在指定目录搜索某用户的相似人脸")
|
|
1029
|
-
sp.add_argument("name", help="用户名")
|
|
1030
|
-
sp.add_argument("search_path", help="搜索目录路径")
|
|
1031
|
-
|
|
1032
|
-
# quick-search — 直接用照片搜索,不写入 config
|
|
1033
|
-
sp = subparsers.add_parser("quick-search", help="直接用一张照片搜索相似人脸(不注册)")
|
|
1034
|
-
sp.add_argument("image", help="查询照片路径")
|
|
1035
|
-
sp.add_argument("search_path", help="搜索目录路径")
|
|
1036
|
-
|
|
1037
|
-
# classify — 一键分类
|
|
1038
|
-
sp = subparsers.add_parser("classify", help="遍历所有注册用户,搜索所有照片")
|
|
1039
|
-
sp.add_argument("search_path", help="搜索目录路径")
|
|
1040
|
-
|
|
1041
|
-
# copy — 将上次搜索结果复制到文件夹
|
|
1042
|
-
sp = subparsers.add_parser("copy", help="将搜索结果中的照片复制到指定文件夹")
|
|
1043
|
-
sp.add_argument("target_folder", help="目标文件夹路径")
|
|
1044
|
-
sp.add_argument("--name", default=None, help="指定用户名(仅复制该用户的结果);不指定则复制所有用户")
|
|
1045
|
-
|
|
1046
|
-
# pack — 打包搜索结果并上传
|
|
1047
|
-
sp = subparsers.add_parser("pack", help="将搜索结果压缩为 zip 并上传获取下载链接")
|
|
1048
|
-
sp.add_argument("--name", default=None, help="指定用户名;不指定则打包所有用户")
|
|
1049
|
-
sp.add_argument("--archive-name", default="search_results", help="压缩包名称 (默认: search_results)")
|
|
1050
|
-
sp.add_argument("--timeout", type=int, default=120, help="上传超时秒数 (默认: 120)")
|
|
1051
|
-
|
|
1052
|
-
# upload — 上传文件获取 URL
|
|
1053
|
-
sp = subparsers.add_parser("upload", help="上传本地文件获取 URL")
|
|
1054
|
-
sp.add_argument("file_path", help="文件路径")
|
|
1055
|
-
sp.add_argument("--timeout", type=int, default=10, help="上传超时秒数 (默认: 10)")
|
|
1056
|
-
|
|
1057
|
-
# init-today-folder — 在待搜索目录下按当前日期创建子目录
|
|
1058
|
-
subparsers.add_parser("init-today-folder", help="在待搜索目录 images 下创建当天日期文件夹(YYYYMMDD)")
|
|
1059
|
-
|
|
1060
|
-
# list-date-folders — 列出待搜索目录下日期子目录(供未指定搜索目录时选择)
|
|
1061
|
-
subparsers.add_parser(
|
|
1062
|
-
"list-date-folders",
|
|
1063
|
-
help="列出 images 下 YYYYMMDD 日期文件夹(从新到旧),JSON 含 folders 与 preview_n",
|
|
1064
|
-
)
|
|
1065
|
-
|
|
1066
|
-
# upload-image — 将图片剪切到待搜索目录的当天日期目录
|
|
1067
|
-
sp = subparsers.add_parser("upload-image", help="将图片串行剪切到待搜索目录 images/当天日期目录")
|
|
1068
|
-
sp.add_argument("file_paths", nargs="+", help="一个或多个图片文件路径")
|
|
1069
|
-
|
|
1070
|
-
args = parser.parse_args()
|
|
1071
|
-
cfg = args.config
|
|
1072
|
-
|
|
1073
|
-
if args.command == "check":
|
|
1074
|
-
exists = name_in_config(args.name, cfg)
|
|
1075
|
-
_json_output({"exists": exists, "name": args.name})
|
|
1076
|
-
|
|
1077
|
-
elif args.command == "add":
|
|
1078
|
-
friend_id = getattr(args, "friend_id", None)
|
|
1079
|
-
raw_label = getattr(args, "friend_label", None)
|
|
1080
|
-
friend_label = str(raw_label).strip() if raw_label is not None and str(raw_label).strip() else None
|
|
1081
|
-
if friend_id is not None and friend_id <= 0:
|
|
1082
|
-
_json_output(
|
|
1083
|
-
{
|
|
1084
|
-
"success": False,
|
|
1085
|
-
"message": "friendId 须为正整数",
|
|
1086
|
-
"name": args.name,
|
|
1087
|
-
}
|
|
1088
|
-
)
|
|
1089
|
-
else:
|
|
1090
|
-
ok, msg = add_user_config(
|
|
1091
|
-
args.name,
|
|
1092
|
-
args.image,
|
|
1093
|
-
cfg,
|
|
1094
|
-
friend_id=friend_id,
|
|
1095
|
-
friend_label=friend_label,
|
|
1096
|
-
)
|
|
1097
|
-
result = {"success": ok, "message": msg, "name": args.name}
|
|
1098
|
-
if friend_id is not None:
|
|
1099
|
-
result["friendId"] = friend_id
|
|
1100
|
-
if friend_label:
|
|
1101
|
-
result["friendLabel"] = friend_label
|
|
1102
|
-
if ok:
|
|
1103
|
-
try:
|
|
1104
|
-
result["image_url"] = convert_to_url(args.image, timeout=30)
|
|
1105
|
-
except Exception:
|
|
1106
|
-
result["image_url"] = None
|
|
1107
|
-
_json_output(result)
|
|
1108
|
-
|
|
1109
|
-
elif args.command == "replace":
|
|
1110
|
-
ok, msg = replace_user_embeding_config(args.name, args.image, cfg)
|
|
1111
|
-
result = {"success": ok, "message": msg, "name": args.name}
|
|
1112
|
-
if ok:
|
|
1113
|
-
try:
|
|
1114
|
-
result["image_url"] = convert_to_url(args.image, timeout=30)
|
|
1115
|
-
except Exception:
|
|
1116
|
-
result["image_url"] = None
|
|
1117
|
-
_json_output(result)
|
|
1118
|
-
|
|
1119
|
-
elif args.command == "append":
|
|
1120
|
-
ok, msg = append_user_embeding_config(args.name, args.image, cfg)
|
|
1121
|
-
result = {"success": ok, "message": msg, "name": args.name}
|
|
1122
|
-
if ok:
|
|
1123
|
-
try:
|
|
1124
|
-
result["image_url"] = convert_to_url(args.image, timeout=30)
|
|
1125
|
-
except Exception:
|
|
1126
|
-
result["image_url"] = None
|
|
1127
|
-
_json_output(result)
|
|
1128
|
-
|
|
1129
|
-
elif args.command == "rename":
|
|
1130
|
-
replace_name_config(args.old_name, args.new_name, cfg)
|
|
1131
|
-
_json_output({"success": True, "old_name": args.old_name, "new_name": args.new_name})
|
|
1132
|
-
|
|
1133
|
-
elif args.command == "delete":
|
|
1134
|
-
delete_user_config(args.name, cfg)
|
|
1135
|
-
_json_output({"success": True, "name": args.name})
|
|
1136
|
-
|
|
1137
|
-
elif args.command == "search":
|
|
1138
|
-
results = search_similar_faces(args.name, args.search_path, cfg)
|
|
1139
|
-
_json_output({"name": args.name, "count": len(results), "results": results})
|
|
1140
|
-
|
|
1141
|
-
elif args.command == "quick-search":
|
|
1142
|
-
results, msg = quick_search(args.image, args.search_path, cfg)
|
|
1143
|
-
if results is None:
|
|
1144
|
-
_json_output({"success": False, "message": msg})
|
|
1145
|
-
else:
|
|
1146
|
-
_json_output({"success": True, "count": len(results), "results": results})
|
|
1147
|
-
|
|
1148
|
-
elif args.command == "classify":
|
|
1149
|
-
all_results = classify_all_users(args.search_path, cfg)
|
|
1150
|
-
summary = {}
|
|
1151
|
-
for name, items in all_results.items():
|
|
1152
|
-
summary[name] = {"count": len(items), "results": items}
|
|
1153
|
-
payload = {"user_count": len(all_results), "users": summary}
|
|
1154
|
-
dm_status = notify_classify_dm(cfg, all_results)
|
|
1155
|
-
if dm_status:
|
|
1156
|
-
payload["dm"] = dm_status
|
|
1157
|
-
if all_results:
|
|
1158
|
-
payload["dm_lines"] = build_dm_status_lines(cfg, all_results, dm_status or {})
|
|
1159
|
-
_json_output(payload)
|
|
1160
|
-
|
|
1161
|
-
elif args.command == "copy":
|
|
1162
|
-
results = _load_search_results(cfg, args.name)
|
|
1163
|
-
if results is None:
|
|
1164
|
-
_json_output({"success": False, "message": "未找到搜索结果,请先执行 search 或 classify"})
|
|
1165
|
-
return
|
|
1166
|
-
ok, fail, dest = copy_results_to_folder(results, args.target_folder)
|
|
1167
|
-
_json_output({"success": True, "copied": ok, "failed": fail, "target": dest})
|
|
1168
|
-
|
|
1169
|
-
elif args.command == "pack":
|
|
1170
|
-
results = _load_search_results(cfg, args.name)
|
|
1171
|
-
if results is None:
|
|
1172
|
-
_json_output({"success": False, "message": "未找到搜索结果,请先执行 search 或 classify"})
|
|
1173
|
-
return
|
|
1174
|
-
if isinstance(results, dict) and not args.name:
|
|
1175
|
-
user_urls = {}
|
|
1176
|
-
errors = {}
|
|
1177
|
-
for user_name, user_results in results.items():
|
|
1178
|
-
url, info = pack_results_to_url(user_results, f"search_results_{user_name}", args.timeout)
|
|
1179
|
-
if url:
|
|
1180
|
-
user_urls[user_name] = url
|
|
1181
|
-
else:
|
|
1182
|
-
errors[user_name] = info
|
|
1183
|
-
pack_dm = notify_pack_link_dm(cfg, user_urls)
|
|
1184
|
-
dm_lines = build_pack_dm_status_lines(cfg, results, user_urls, errors, pack_dm)
|
|
1185
|
-
payload = {
|
|
1186
|
-
"success": bool(user_urls),
|
|
1187
|
-
"user_urls": user_urls,
|
|
1188
|
-
"errors": errors if errors else None,
|
|
1189
|
-
}
|
|
1190
|
-
if pack_dm:
|
|
1191
|
-
payload["dm"] = pack_dm
|
|
1192
|
-
if dm_lines:
|
|
1193
|
-
payload["dm_lines"] = dm_lines
|
|
1194
|
-
_json_output(payload)
|
|
1195
|
-
else:
|
|
1196
|
-
url, info = pack_results_to_url(results, args.archive_name, args.timeout)
|
|
1197
|
-
if url:
|
|
1198
|
-
payload = {"success": True, "url": url, "zip_path": info}
|
|
1199
|
-
if args.name:
|
|
1200
|
-
pack_dm = notify_pack_link_dm(cfg, {args.name: url})
|
|
1201
|
-
dm_lines = build_pack_dm_status_lines(
|
|
1202
|
-
cfg, {args.name: results}, {args.name: url}, None, pack_dm
|
|
1203
|
-
)
|
|
1204
|
-
if pack_dm:
|
|
1205
|
-
payload["dm"] = pack_dm
|
|
1206
|
-
if dm_lines:
|
|
1207
|
-
payload["dm_lines"] = dm_lines
|
|
1208
|
-
_json_output(payload)
|
|
1209
|
-
else:
|
|
1210
|
-
_json_output({"success": False, "message": info})
|
|
1211
|
-
|
|
1212
|
-
elif args.command == "upload":
|
|
1213
|
-
url = convert_to_url(args.file_path, timeout=args.timeout)
|
|
1214
|
-
_json_output({"success": True, "url": url})
|
|
1215
|
-
|
|
1216
|
-
elif args.command == "init-today-folder":
|
|
1217
|
-
_json_output({"folder_path": create_today_folder()})
|
|
1218
|
-
|
|
1219
|
-
elif args.command == "list-date-folders":
|
|
1220
|
-
folders = list_date_folders()
|
|
1221
|
-
preview_n = 3
|
|
1222
|
-
_json_output({
|
|
1223
|
-
"images_base": get_images_path(),
|
|
1224
|
-
"folders": folders,
|
|
1225
|
-
"total": len(folders),
|
|
1226
|
-
"preview_n": preview_n,
|
|
1227
|
-
"preview": folders[:preview_n],
|
|
1228
|
-
})
|
|
1229
|
-
|
|
1230
|
-
elif args.command == "upload-image":
|
|
1231
|
-
result = move_images_to_today_folder(args.file_paths)
|
|
1232
|
-
if len(args.file_paths) == 1:
|
|
1233
|
-
_json_output({
|
|
1234
|
-
"success": result["success"],
|
|
1235
|
-
"folder_path": result["folder_path"],
|
|
1236
|
-
"file_path": result["uploaded"][0] if result["uploaded"] else None,
|
|
1237
|
-
"failed": result["failed"],
|
|
1238
|
-
})
|
|
1239
|
-
else:
|
|
1240
|
-
_json_output(result)
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
def _load_search_results(config_path, name=None):
|
|
1244
|
-
"""从 config.json 加载上次的搜索结果。
|
|
1245
|
-
name=None 时返回所有用户的结果(字典)或 quick-search 的结果(列表)。
|
|
1246
|
-
指定 name 时返回该用户的结果(列表)。
|
|
1247
|
-
"""
|
|
1248
|
-
basic_config = get_config(config_path)
|
|
1249
|
-
user_list = basic_config.get("users", [])
|
|
1250
|
-
quick_results = basic_config.get("quick_search_result")
|
|
1251
|
-
|
|
1252
|
-
if name:
|
|
1253
|
-
for user in user_list:
|
|
1254
|
-
if user.get("name") == name:
|
|
1255
|
-
sr = user.get("search_result")
|
|
1256
|
-
return sr if sr else None
|
|
1257
|
-
return None
|
|
1258
|
-
|
|
1259
|
-
all_results = {}
|
|
1260
|
-
for user in user_list:
|
|
1261
|
-
sr = user.get("search_result")
|
|
1262
|
-
if sr:
|
|
1263
|
-
all_results[user.get("name", "")] = sr
|
|
1264
|
-
|
|
1265
|
-
if all_results and quick_results:
|
|
1266
|
-
all_results["quick_search"] = quick_results
|
|
1267
|
-
return all_results
|
|
1268
|
-
if all_results:
|
|
1269
|
-
return all_results
|
|
1270
|
-
if quick_results:
|
|
1271
|
-
return quick_results
|
|
1272
|
-
return None
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
if __name__ == "__main__":
|
|
1276
|
-
main()
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
照片分类
|
|
4
|
+
"""
|
|
5
|
+
import requests
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
import argparse
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import zipfile
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
import cv2
|
|
15
|
+
import numpy as np
|
|
16
|
+
import sophnet_tools
|
|
17
|
+
|
|
18
|
+
# API配置
|
|
19
|
+
FACE_API_URL = "https://www.sophnet.com/api/open-apis/projects/detect_and_embed"
|
|
20
|
+
|
|
21
|
+
def convert_to_url(image_path, timeout=10):
|
|
22
|
+
return sophnet_tools.upload_oss(image_path, timeout)
|
|
23
|
+
|
|
24
|
+
def get_images_path():
|
|
25
|
+
current_file_dir = os.path.dirname(os.path.abspath(__file__))
|
|
26
|
+
return os.path.abspath(os.path.join(current_file_dir, "..", "..", "..", "images"))
|
|
27
|
+
|
|
28
|
+
def list_date_folders():
|
|
29
|
+
"""列出待搜索目录 images 下所有 YYYYMMDD 子目录,按日期从新到旧排序。
|
|
30
|
+
返回 [{"name": "20260410", "path": "/abs/path/20260410"}, ...]"""
|
|
31
|
+
images_path = get_images_path()
|
|
32
|
+
if not os.path.exists(images_path):
|
|
33
|
+
print(f"[提示] images_path 不存在: {images_path}")
|
|
34
|
+
return []
|
|
35
|
+
|
|
36
|
+
if not os.path.isdir(images_path):
|
|
37
|
+
print(f"[提示] images_path 不是目录: {images_path}")
|
|
38
|
+
return []
|
|
39
|
+
|
|
40
|
+
valid_dates = []
|
|
41
|
+
invalid_entries = []
|
|
42
|
+
for name in os.listdir(images_path):
|
|
43
|
+
full_path = os.path.join(images_path, name)
|
|
44
|
+
if not os.path.isdir(full_path):
|
|
45
|
+
continue
|
|
46
|
+
try:
|
|
47
|
+
dt = datetime.strptime(name, "%Y%m%d")
|
|
48
|
+
valid_dates.append((dt, name, os.path.abspath(full_path)))
|
|
49
|
+
except ValueError:
|
|
50
|
+
invalid_entries.append(name)
|
|
51
|
+
|
|
52
|
+
if invalid_entries:
|
|
53
|
+
print(f"[提示] 以下目录名不是 YYYYMMDD,将忽略: {', '.join(sorted(invalid_entries))}")
|
|
54
|
+
|
|
55
|
+
if not valid_dates:
|
|
56
|
+
print(f"[提示] 未找到符合 YYYYMMDD 的日期目录: {images_path}")
|
|
57
|
+
return []
|
|
58
|
+
|
|
59
|
+
valid_dates.sort(key=lambda x: x[0], reverse=True)
|
|
60
|
+
return [{"name": name, "path": path} for _, name, path in valid_dates]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_last_date_path(count=3):
|
|
64
|
+
folders = list_date_folders()
|
|
65
|
+
return [f["name"] for f in folders[:count]]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def create_today_folder():
|
|
69
|
+
"""在待搜索目录 images_path 下创建当天日期目录(YYYYMMDD)。返回当天目录路径,失败返回 None。"""
|
|
70
|
+
images_path = get_images_path()
|
|
71
|
+
if not os.path.exists(images_path):
|
|
72
|
+
os.makedirs(images_path, exist_ok=True)
|
|
73
|
+
elif not os.path.isdir(images_path):
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
today = datetime.now().strftime("%Y%m%d")
|
|
77
|
+
today_path = os.path.join(images_path, today)
|
|
78
|
+
if os.path.exists(today_path):
|
|
79
|
+
if os.path.isdir(today_path):
|
|
80
|
+
return today_path
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
os.makedirs(today_path, exist_ok=False)
|
|
84
|
+
return today_path
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def move_image_to_today_folder(file_path, today_folder=None):
|
|
88
|
+
"""将文件剪切到待搜索目录的当天日期目录。成功返回目标路径,失败返回 None。"""
|
|
89
|
+
if not file_path:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
src = os.path.realpath(file_path)
|
|
93
|
+
if not os.path.isfile(src):
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
today_folder = today_folder or create_today_folder()
|
|
97
|
+
if not today_folder:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
base = os.path.basename(src)
|
|
101
|
+
stem, suffix = os.path.splitext(base)
|
|
102
|
+
target = os.path.join(today_folder, base)
|
|
103
|
+
if os.path.exists(target):
|
|
104
|
+
counter = 1
|
|
105
|
+
while os.path.exists(target):
|
|
106
|
+
target = os.path.join(today_folder, f"{stem}_{counter}{suffix}")
|
|
107
|
+
counter += 1
|
|
108
|
+
|
|
109
|
+
moved_path = shutil.move(src, target)
|
|
110
|
+
return os.path.realpath(moved_path)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def move_images_to_today_folder(file_paths):
|
|
114
|
+
"""串行将多张图片剪切到待搜索目录的当天日期目录。"""
|
|
115
|
+
if not file_paths:
|
|
116
|
+
return {"success": False, "folder_path": None, "uploaded": [], "failed": []}
|
|
117
|
+
|
|
118
|
+
today_folder = create_today_folder()
|
|
119
|
+
if not today_folder:
|
|
120
|
+
return {"success": False, "folder_path": None, "uploaded": [], "failed": list(file_paths)}
|
|
121
|
+
|
|
122
|
+
uploaded = []
|
|
123
|
+
failed = []
|
|
124
|
+
total = len(file_paths)
|
|
125
|
+
next_progress = 10
|
|
126
|
+
|
|
127
|
+
for index, file_path in enumerate(file_paths, start=1):
|
|
128
|
+
moved_path = move_image_to_today_folder(file_path, today_folder=today_folder)
|
|
129
|
+
if moved_path:
|
|
130
|
+
uploaded.append(moved_path)
|
|
131
|
+
else:
|
|
132
|
+
failed.append(file_path)
|
|
133
|
+
|
|
134
|
+
progress = (index * 100) // total
|
|
135
|
+
while progress >= next_progress:
|
|
136
|
+
print(f"[进度] {next_progress}% ({index}/{total})", file=sys.stderr)
|
|
137
|
+
next_progress += 10
|
|
138
|
+
|
|
139
|
+
if next_progress == 10:
|
|
140
|
+
print("[进度] 100% (0/0)", file=sys.stderr)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"success": not failed,
|
|
144
|
+
"folder_path": os.path.realpath(today_folder),
|
|
145
|
+
"uploaded": uploaded,
|
|
146
|
+
"failed": failed,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def list_images_recursive(folder_path):
|
|
150
|
+
"""使用 os.path 递归列出所有图片文件"""
|
|
151
|
+
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.tiff', '.tif', '.ico', '.svg'}
|
|
152
|
+
folder = os.path.realpath(os.path.abspath(folder_path))
|
|
153
|
+
image_files = []
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
for root, _dirs, files in os.walk(folder):
|
|
157
|
+
for fname in files:
|
|
158
|
+
full = os.path.join(root, fname)
|
|
159
|
+
try:
|
|
160
|
+
ext = os.path.splitext(fname)[1].lower()
|
|
161
|
+
if ext in image_extensions and os.path.isfile(full):
|
|
162
|
+
image_files.append(os.path.abspath(full))
|
|
163
|
+
except PermissionError:
|
|
164
|
+
print(f"[警告] 无权限访问:{full}")
|
|
165
|
+
continue
|
|
166
|
+
except PermissionError:
|
|
167
|
+
print(f"[错误] 无权限访问根目录:{folder}")
|
|
168
|
+
|
|
169
|
+
return image_files
|
|
170
|
+
|
|
171
|
+
def detect_faces(image_path, soph_api_key):
|
|
172
|
+
"""调用API检测人脸"""
|
|
173
|
+
if not os.path.exists(image_path):
|
|
174
|
+
raise FileNotFoundError(f"图片文件不存在: {image_path}")
|
|
175
|
+
|
|
176
|
+
ost_img = cv2.imread(image_path)
|
|
177
|
+
if ost_img is None:
|
|
178
|
+
raise ValueError(f"无法读取图片文件: {image_path}")
|
|
179
|
+
|
|
180
|
+
# 确定图片的 MIME 类型
|
|
181
|
+
ext = os.path.splitext(image_path)[1].lower()
|
|
182
|
+
mime_types = {
|
|
183
|
+
'.jpg': 'image/jpeg',
|
|
184
|
+
'.jpeg': 'image/jpeg',
|
|
185
|
+
'.png': 'image/png',
|
|
186
|
+
'.webp': 'image/webp',
|
|
187
|
+
'.bmp': 'image/bmp',
|
|
188
|
+
'.gif': 'image/gif'
|
|
189
|
+
}
|
|
190
|
+
mime_type = mime_types.get(ext, 'image/jpeg')
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
with open(image_path, 'rb') as f:
|
|
194
|
+
files = {'file': (os.path.basename(image_path), f, mime_type)}
|
|
195
|
+
headers = {"Authorization": f"Bearer {soph_api_key}"}
|
|
196
|
+
# 设置较短的超时时间,避免Node.js环境中的超时溢出
|
|
197
|
+
response = requests.post(FACE_API_URL, files=files, headers=headers, timeout=10)
|
|
198
|
+
except requests.exceptions.Timeout:
|
|
199
|
+
raise RuntimeError(f"API请求超时: 图片 {image_path} 处理时间过长")
|
|
200
|
+
except requests.exceptions.RequestException as e:
|
|
201
|
+
raise RuntimeError(f"API请求异常: {str(e)}")
|
|
202
|
+
|
|
203
|
+
if response.status_code != 200:
|
|
204
|
+
raise RuntimeError(f"API请求失败: {response.status_code} - {response.text[:200]}")
|
|
205
|
+
|
|
206
|
+
return response.json().get('result', {})
|
|
207
|
+
|
|
208
|
+
def get_largest_face(faces):
|
|
209
|
+
"""从检测到的人脸中找出尺寸最大的人脸"""
|
|
210
|
+
if not faces:
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
largest_face = None
|
|
214
|
+
largest_area = 0
|
|
215
|
+
|
|
216
|
+
for face in faces:
|
|
217
|
+
box = face.get('box', [])
|
|
218
|
+
if len(box) >= 4:
|
|
219
|
+
x1, y1, x2, y2 = box[0], box[1], box[2], box[3]
|
|
220
|
+
area = (x2 - x1) * (y2 - y1)
|
|
221
|
+
if area > largest_area:
|
|
222
|
+
largest_area = area
|
|
223
|
+
largest_face = face
|
|
224
|
+
|
|
225
|
+
return largest_face
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def get_baseface_embedding(image_path, det_thr=0.7):
|
|
229
|
+
"""获取查询图片的最大人脸embedding"""
|
|
230
|
+
try:
|
|
231
|
+
result = detect_faces(image_path, sophnet_tools.get_api_key())
|
|
232
|
+
faces_count = result.get("faces_count", 0)
|
|
233
|
+
faces = result.get("output", [])
|
|
234
|
+
faces = [face for face in faces if face.get('det_score', 0) >= det_thr]
|
|
235
|
+
faces_count = len(faces)
|
|
236
|
+
|
|
237
|
+
if faces_count > 0:
|
|
238
|
+
largest_face = get_largest_face(faces)
|
|
239
|
+
return largest_face.get('embedding', [])
|
|
240
|
+
else:
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
except Exception as e:
|
|
244
|
+
print(f"错误: {e}", file=sys.stderr)
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
def get_config(config_path='references/config.json'):
|
|
248
|
+
"""从配置文件中读取阈值"""
|
|
249
|
+
try:
|
|
250
|
+
with open(config_path, 'r', encoding='utf-8') as f:
|
|
251
|
+
config = json.load(f)
|
|
252
|
+
return config
|
|
253
|
+
except FileNotFoundError:
|
|
254
|
+
return {
|
|
255
|
+
"query_threshold": 0.7,
|
|
256
|
+
"search_similarity_threshold": 0.3
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _user_friend_id(user):
|
|
261
|
+
"""用户条目中的好友 ID(虾友号)。新字段 `friendId`,兼容旧字段 `xia_you_hao`。"""
|
|
262
|
+
if not isinstance(user, dict):
|
|
263
|
+
return None
|
|
264
|
+
v = user.get("friendId")
|
|
265
|
+
if v is not None:
|
|
266
|
+
return v
|
|
267
|
+
return user.get("xia_you_hao")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _user_friend_label(user):
|
|
271
|
+
"""DM 展示名。新字段 `friendLabel`,兼容旧字段 `xia_you_label`。"""
|
|
272
|
+
if not isinstance(user, dict):
|
|
273
|
+
return None
|
|
274
|
+
v = user.get("friendLabel")
|
|
275
|
+
if v is not None:
|
|
276
|
+
return v
|
|
277
|
+
return user.get("xia_you_label")
|
|
278
|
+
|
|
279
|
+
def name_in_config(name, config_path='references/config.json'):
|
|
280
|
+
"""检查名字是否在配置文件的用户列表中"""
|
|
281
|
+
basic_config = get_config(config_path)
|
|
282
|
+
user_list = basic_config.get("users", [])
|
|
283
|
+
for user in user_list:
|
|
284
|
+
if user.get("name") == name:
|
|
285
|
+
return True
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
def replace_name_config(ost_name, new_name, config_path='references/config.json'):
|
|
289
|
+
"""替换配置文件中用户列表中的名字"""
|
|
290
|
+
basic_config = get_config(config_path)
|
|
291
|
+
user_list = basic_config.get("users", [])
|
|
292
|
+
for user in user_list:
|
|
293
|
+
if user.get("name") == ost_name:
|
|
294
|
+
user["name"] = new_name
|
|
295
|
+
break
|
|
296
|
+
basic_config["users"] = user_list
|
|
297
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
298
|
+
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def delete_user_config(ost_name, config_path='references/config.json'):
|
|
302
|
+
"""删除配置文件中用户列表中的用户信息"""
|
|
303
|
+
basic_config = get_config(config_path)
|
|
304
|
+
user_list = basic_config.get("users", [])
|
|
305
|
+
for user in user_list:
|
|
306
|
+
if user.get("name") == ost_name:
|
|
307
|
+
user_list.remove(user)
|
|
308
|
+
break
|
|
309
|
+
basic_config["users"] = user_list
|
|
310
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
311
|
+
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
312
|
+
|
|
313
|
+
def add_user_config(
|
|
314
|
+
name,
|
|
315
|
+
image_path,
|
|
316
|
+
config_path="references/config.json",
|
|
317
|
+
friend_id=None,
|
|
318
|
+
friend_label=None,
|
|
319
|
+
):
|
|
320
|
+
"""添加用户到 config。friendId 为可选好友 userId(正整数);friendLabel 为可选展示名。"""
|
|
321
|
+
basic_config = get_config(config_path)
|
|
322
|
+
user_list = basic_config.get("users", [])
|
|
323
|
+
face_embedding = get_baseface_embedding(image_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
324
|
+
if face_embedding is None:
|
|
325
|
+
return False, "未检测到有效的人脸信息"
|
|
326
|
+
|
|
327
|
+
abs_image_path = os.path.realpath(image_path)
|
|
328
|
+
entry = {"name": name, "info": [{"file_path": abs_image_path, "embedding": face_embedding}]}
|
|
329
|
+
if friend_id is not None:
|
|
330
|
+
entry["friendId"] = int(friend_id)
|
|
331
|
+
if friend_label is not None and str(friend_label).strip():
|
|
332
|
+
entry["friendLabel"] = str(friend_label).strip()
|
|
333
|
+
user_list.append(entry)
|
|
334
|
+
basic_config["users"] = user_list
|
|
335
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
336
|
+
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
337
|
+
|
|
338
|
+
return True, "添加成功"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def replace_user_embeding_config(name, new_image_path, config_path='references/config.json'):
|
|
342
|
+
"""根据用户名替换其所有照片和embedding信息,并删除原始照片"""
|
|
343
|
+
basic_config = get_config(config_path)
|
|
344
|
+
user_list = basic_config.get("users", [])
|
|
345
|
+
face_embedding = get_baseface_embedding(new_image_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
346
|
+
if face_embedding is None:
|
|
347
|
+
return False, "未检测到有效的人脸信息"
|
|
348
|
+
|
|
349
|
+
abs_new_path = os.path.realpath(new_image_path)
|
|
350
|
+
for user in user_list:
|
|
351
|
+
if user.get("name") == name:
|
|
352
|
+
for info in user.get("info", []):
|
|
353
|
+
old_path = info.get("file_path", "")
|
|
354
|
+
if old_path and old_path != abs_new_path and os.path.exists(old_path):
|
|
355
|
+
os.remove(old_path)
|
|
356
|
+
user["info"] = [{"file_path": abs_new_path, "embedding": face_embedding}]
|
|
357
|
+
break
|
|
358
|
+
basic_config["users"] = user_list
|
|
359
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
360
|
+
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
361
|
+
|
|
362
|
+
return True, "替换成功"
|
|
363
|
+
|
|
364
|
+
def append_user_embeding_config(name, image_path, config_path='references/config.json'):
|
|
365
|
+
"""新增用户embedding信息"""
|
|
366
|
+
basic_config = get_config(config_path)
|
|
367
|
+
user_list = basic_config.get("users", [])
|
|
368
|
+
face_embedding = get_baseface_embedding(image_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
369
|
+
if face_embedding is None:
|
|
370
|
+
return False, "未检测到有效的人脸信息"
|
|
371
|
+
|
|
372
|
+
for user in user_list:
|
|
373
|
+
if user.get("name") == name:
|
|
374
|
+
abs_image_path = os.path.realpath(image_path)
|
|
375
|
+
user.get("info", []).append({"file_path": abs_image_path, "embedding": face_embedding})
|
|
376
|
+
break
|
|
377
|
+
basic_config["users"] = user_list
|
|
378
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
379
|
+
json.dump(basic_config, f, indent=4, ensure_ascii=False)
|
|
380
|
+
|
|
381
|
+
return True, "添加成功"
|
|
382
|
+
|
|
383
|
+
def save_embeddings(image_path, faces, json_path):
|
|
384
|
+
"""保存多个人脸embedding到json文件"""
|
|
385
|
+
data = {
|
|
386
|
+
"image_path": str(image_path),
|
|
387
|
+
"embeddings": [face.get('embedding', []) for face in faces],
|
|
388
|
+
"boxes": [face.get('box', []) for face in faces],
|
|
389
|
+
"det_scores": [face.get('det_score', 0) for face in faces]
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
with open(json_path, 'w', encoding='utf-8') as f:
|
|
393
|
+
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
394
|
+
|
|
395
|
+
return json_path
|
|
396
|
+
|
|
397
|
+
def embeddings_from_images(image_list, det_thr=0.5):
|
|
398
|
+
"""获取图片列表的所有人脸embedding信息,并保存到对应的json文件中"""
|
|
399
|
+
json_list = []
|
|
400
|
+
|
|
401
|
+
for image_path in image_list:
|
|
402
|
+
try:
|
|
403
|
+
abs_img = os.path.abspath(image_path)
|
|
404
|
+
dir_name = os.path.dirname(abs_img)
|
|
405
|
+
stem = os.path.splitext(os.path.basename(abs_img))[0]
|
|
406
|
+
|
|
407
|
+
save_path = os.path.join(dir_name, ".embedding")
|
|
408
|
+
os.makedirs(save_path, exist_ok=True)
|
|
409
|
+
json_path = os.path.join(save_path, f"{stem}.json")
|
|
410
|
+
|
|
411
|
+
if os.path.exists(json_path):
|
|
412
|
+
print(f"跳过已存在的embedding文件: {json_path}")
|
|
413
|
+
json_list.append(str(json_path))
|
|
414
|
+
continue
|
|
415
|
+
|
|
416
|
+
print(f"开始处理图片: {image_path}")
|
|
417
|
+
result = detect_faces(image_path, sophnet_tools.get_api_key())
|
|
418
|
+
faces = result.get("output", [])
|
|
419
|
+
faces = [face for face in faces if face.get('det_score', 0) >= det_thr]
|
|
420
|
+
|
|
421
|
+
save_embeddings(image_path, faces, json_path)
|
|
422
|
+
json_list.append(str(json_path))
|
|
423
|
+
|
|
424
|
+
except Exception as e:
|
|
425
|
+
print(f"处理 {image_path} 时出错: {e}", file=sys.stderr)
|
|
426
|
+
|
|
427
|
+
return json_list
|
|
428
|
+
|
|
429
|
+
def embeddings_from_imagepath(image_path, det_thr=0.5):
|
|
430
|
+
image_list = list_images_recursive(image_path)
|
|
431
|
+
return embeddings_from_images(image_list, det_thr)
|
|
432
|
+
|
|
433
|
+
def get_searchface_embeddings_from_json(json_paths):
|
|
434
|
+
"""从json文件列表加载搜索人脸embeddings"""
|
|
435
|
+
all_embeddings = {}
|
|
436
|
+
for json_path in json_paths:
|
|
437
|
+
with open(json_path, 'r', encoding='utf-8') as f:
|
|
438
|
+
data = json.load(f)
|
|
439
|
+
embeddings = data.get("embeddings", [])
|
|
440
|
+
image_name = data.get("image_path", "")
|
|
441
|
+
all_embeddings[image_name] = np.array(embeddings).astype(np.float32)
|
|
442
|
+
return all_embeddings
|
|
443
|
+
|
|
444
|
+
def cosine_similarity(vec1, vec2):
|
|
445
|
+
"""计算余弦相似度"""
|
|
446
|
+
dot_product = np.dot(vec1, vec2)
|
|
447
|
+
norm1 = np.linalg.norm(vec1)
|
|
448
|
+
norm2 = np.linalg.norm(vec2)
|
|
449
|
+
if norm1 == 0 or norm2 == 0:
|
|
450
|
+
return 0
|
|
451
|
+
return dot_product / (norm1 * norm2)
|
|
452
|
+
|
|
453
|
+
def search_similar_faces(name, search_path, config_path='references/config.json'):
|
|
454
|
+
"""搜索相似人脸"""
|
|
455
|
+
basic_config = get_config(config_path)
|
|
456
|
+
user_list = basic_config.get("users", [])
|
|
457
|
+
|
|
458
|
+
embedding_list = []
|
|
459
|
+
for user in user_list:
|
|
460
|
+
if user.get("name") == name:
|
|
461
|
+
for info in user.get("info", []):
|
|
462
|
+
embedding = info.get("embedding")
|
|
463
|
+
if embedding:
|
|
464
|
+
embedding_list.append(embedding)
|
|
465
|
+
|
|
466
|
+
json_list = embeddings_from_imagepath(search_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
467
|
+
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
results = []
|
|
471
|
+
for face_embedding in embedding_list:
|
|
472
|
+
for image_name, embeddings in search_embeddings.items():
|
|
473
|
+
for idx, embedding in enumerate(embeddings):
|
|
474
|
+
similarity = cosine_similarity(face_embedding, embedding)
|
|
475
|
+
if similarity >= basic_config.get("search_similarity_threshold", 0.3):
|
|
476
|
+
existing = None
|
|
477
|
+
for result in results:
|
|
478
|
+
if result.get("image_path") == image_name:
|
|
479
|
+
existing = result
|
|
480
|
+
break
|
|
481
|
+
if existing is None:
|
|
482
|
+
results.append({
|
|
483
|
+
"image_path": image_name,
|
|
484
|
+
"face_index": idx,
|
|
485
|
+
"similarity": float(similarity)
|
|
486
|
+
})
|
|
487
|
+
elif existing["similarity"] < float(similarity):
|
|
488
|
+
existing["face_index"] = idx
|
|
489
|
+
existing["similarity"] = float(similarity)
|
|
490
|
+
results.sort(key=lambda x: x["similarity"], reverse=True)
|
|
491
|
+
for user in user_list:
|
|
492
|
+
if user.get("name") == name:
|
|
493
|
+
user["search_result"] = results
|
|
494
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
495
|
+
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
496
|
+
return results
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def quick_search(image_path, search_path, config_path='references/config.json'):
|
|
500
|
+
"""直接用一张照片搜索相似人脸,结果缓存到 config.json 的 quick_search_result 字段,
|
|
501
|
+
供后续 copy/pack 命令使用。
|
|
502
|
+
"""
|
|
503
|
+
basic_config = get_config(config_path)
|
|
504
|
+
det_thr = basic_config.get("query_threshold", 0.7)
|
|
505
|
+
search_threshold = basic_config.get("search_similarity_threshold", 0.3)
|
|
506
|
+
|
|
507
|
+
face_embedding = get_baseface_embedding(image_path, det_thr=det_thr)
|
|
508
|
+
if face_embedding is None:
|
|
509
|
+
return None, "照片中未发现有效的人脸信息"
|
|
510
|
+
|
|
511
|
+
json_list = embeddings_from_imagepath(search_path, det_thr=det_thr)
|
|
512
|
+
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
513
|
+
|
|
514
|
+
results = []
|
|
515
|
+
for img_name, embeddings in search_embeddings.items():
|
|
516
|
+
for idx, embedding in enumerate(embeddings):
|
|
517
|
+
similarity = cosine_similarity(face_embedding, embedding)
|
|
518
|
+
if similarity >= search_threshold:
|
|
519
|
+
existing = None
|
|
520
|
+
for result in results:
|
|
521
|
+
if result.get("image_path") == img_name:
|
|
522
|
+
existing = result
|
|
523
|
+
break
|
|
524
|
+
if existing is None:
|
|
525
|
+
results.append({
|
|
526
|
+
"image_path": img_name,
|
|
527
|
+
"face_index": idx,
|
|
528
|
+
"similarity": float(similarity)
|
|
529
|
+
})
|
|
530
|
+
elif existing["similarity"] < float(similarity):
|
|
531
|
+
existing["face_index"] = idx
|
|
532
|
+
existing["similarity"] = float(similarity)
|
|
533
|
+
|
|
534
|
+
results.sort(key=lambda x: x["similarity"], reverse=True)
|
|
535
|
+
|
|
536
|
+
basic_config["quick_search_result"] = results
|
|
537
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
538
|
+
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
539
|
+
|
|
540
|
+
return results, "搜索完成"
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def classify_all_users(search_path, config_path='references/config.json'):
|
|
544
|
+
"""一键分类:遍历所有注册用户,搜索所有图片并返回每个用户的匹配结果"""
|
|
545
|
+
basic_config = get_config(config_path)
|
|
546
|
+
user_list = basic_config.get("users", [])
|
|
547
|
+
|
|
548
|
+
if not user_list:
|
|
549
|
+
return {}
|
|
550
|
+
|
|
551
|
+
json_list = embeddings_from_imagepath(search_path, det_thr=basic_config.get("query_threshold", 0.7))
|
|
552
|
+
search_embeddings = get_searchface_embeddings_from_json(json_list)
|
|
553
|
+
search_threshold = basic_config.get("search_similarity_threshold", 0.3)
|
|
554
|
+
|
|
555
|
+
all_results = {}
|
|
556
|
+
for user in user_list:
|
|
557
|
+
name = user.get("name", "")
|
|
558
|
+
embedding_list = []
|
|
559
|
+
for info in user.get("info", []):
|
|
560
|
+
embedding = info.get("embedding")
|
|
561
|
+
if embedding:
|
|
562
|
+
embedding_list.append(embedding)
|
|
563
|
+
|
|
564
|
+
if not embedding_list:
|
|
565
|
+
continue
|
|
566
|
+
|
|
567
|
+
results = []
|
|
568
|
+
for face_embedding in embedding_list:
|
|
569
|
+
for image_name, embeddings in search_embeddings.items():
|
|
570
|
+
for idx, embedding in enumerate(embeddings):
|
|
571
|
+
similarity = cosine_similarity(face_embedding, embedding)
|
|
572
|
+
if similarity >= search_threshold:
|
|
573
|
+
existing = None
|
|
574
|
+
for result in results:
|
|
575
|
+
if result.get("image_path") == image_name:
|
|
576
|
+
existing = result
|
|
577
|
+
break
|
|
578
|
+
if existing is None:
|
|
579
|
+
results.append({
|
|
580
|
+
"image_path": image_name,
|
|
581
|
+
"face_index": idx,
|
|
582
|
+
"similarity": float(similarity)
|
|
583
|
+
})
|
|
584
|
+
elif existing["similarity"] < float(similarity):
|
|
585
|
+
existing["face_index"] = idx
|
|
586
|
+
existing["similarity"] = float(similarity)
|
|
587
|
+
|
|
588
|
+
results.sort(key=lambda x: x["similarity"], reverse=True)
|
|
589
|
+
user["search_result"] = results
|
|
590
|
+
all_results[name] = results
|
|
591
|
+
|
|
592
|
+
with open(config_path, 'w', encoding='utf-8') as f:
|
|
593
|
+
json.dump(basic_config, f, ensure_ascii=False, indent=2)
|
|
594
|
+
|
|
595
|
+
return all_results
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _format_classify_dm_message(name: str, items: list, max_lines: int = 30, max_chars: int = 3500) -> str:
|
|
599
|
+
"""将单用户分类结果格式化为私信正文(控制长度)。"""
|
|
600
|
+
n = len(items)
|
|
601
|
+
lines = [f"【照片分类结果】👤 {name}", f"匹配共 {n} 张。"]
|
|
602
|
+
if n == 0:
|
|
603
|
+
lines.append("本轮目录中暂无匹配照片。")
|
|
604
|
+
return "\n".join(lines)
|
|
605
|
+
for i, item in enumerate(items[:max_lines]):
|
|
606
|
+
path = item.get("image_path", "")
|
|
607
|
+
sim = item.get("similarity", 0)
|
|
608
|
+
try:
|
|
609
|
+
sim_s = f"{float(sim):.4f}"
|
|
610
|
+
except (TypeError, ValueError):
|
|
611
|
+
sim_s = str(sim)
|
|
612
|
+
lines.append(f"- {path} · 相似度 {sim_s}")
|
|
613
|
+
if n > max_lines:
|
|
614
|
+
lines.append(f"… 另有 {n - max_lines} 张未列出,请在本机打包下载查看完整列表。")
|
|
615
|
+
text = "\n".join(lines)
|
|
616
|
+
if len(text) > max_chars:
|
|
617
|
+
text = text[: max_chars - 20] + "\n…(正文过长已截断)"
|
|
618
|
+
return text
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _format_pack_link_dm_message(name: str, url: str) -> str:
|
|
622
|
+
"""pack 成功后发给 friendId 的私信正文(含下载链接)。"""
|
|
623
|
+
return (
|
|
624
|
+
f"✨「{name}」的照片已为您送达\n\n"
|
|
625
|
+
f"{url}\n\n"
|
|
626
|
+
f"请在 24 小时内下载保存;逾期链接可能失效,请及时转存。"
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _find_user_by_name(config_path: str, name: str):
|
|
631
|
+
if not name:
|
|
632
|
+
return None
|
|
633
|
+
for u in get_config(config_path).get("users", []):
|
|
634
|
+
if u.get("name") == name:
|
|
635
|
+
return u
|
|
636
|
+
return None
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _send_classify_dm(friend_id: int, message: str, timeout: int = 90) -> tuple:
|
|
640
|
+
"""调用同目录 send_dm_message.py 发送私信(--user-id 即 friendId)。返回 (success: bool, detail: str)。"""
|
|
641
|
+
script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "send_dm_message.py")
|
|
642
|
+
if not os.path.isfile(script):
|
|
643
|
+
return False, f"未找到 DM 脚本: {script}"
|
|
644
|
+
try:
|
|
645
|
+
proc = subprocess.run(
|
|
646
|
+
[sys.executable, script, "--user-id", str(int(friend_id)), "-m", message],
|
|
647
|
+
capture_output=True,
|
|
648
|
+
text=True,
|
|
649
|
+
timeout=timeout,
|
|
650
|
+
)
|
|
651
|
+
if proc.returncode == 0:
|
|
652
|
+
return True, (proc.stdout or "").strip() or "ok"
|
|
653
|
+
err = (proc.stderr or proc.stdout or "").strip() or f"exit {proc.returncode}"
|
|
654
|
+
return False, err[:2000]
|
|
655
|
+
except subprocess.TimeoutExpired:
|
|
656
|
+
return False, "发送私信超时"
|
|
657
|
+
except Exception as e:
|
|
658
|
+
return False, str(e)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def notify_classify_dm(config_path: str, all_results: dict) -> dict:
|
|
662
|
+
"""对配置了 friendId 的用户推送 DM。返回 {用户名: {success, friendId, friendLabel?, detail?}}。"""
|
|
663
|
+
basic_config = get_config(config_path)
|
|
664
|
+
out = {}
|
|
665
|
+
for user in basic_config.get("users", []):
|
|
666
|
+
xid = _user_friend_id(user)
|
|
667
|
+
name = user.get("name") or ""
|
|
668
|
+
if xid is None:
|
|
669
|
+
continue
|
|
670
|
+
try:
|
|
671
|
+
xid_int = int(xid)
|
|
672
|
+
except (TypeError, ValueError):
|
|
673
|
+
bad = {"success": False, "detail": f"无效的 friendId: {xid!r}"}
|
|
674
|
+
lab_bad = _user_friend_label(user)
|
|
675
|
+
if isinstance(lab_bad, str) and lab_bad.strip():
|
|
676
|
+
bad["friendLabel"] = lab_bad.strip()
|
|
677
|
+
out[name if name else f"invalid_friend_id:{xid}"] = bad
|
|
678
|
+
continue
|
|
679
|
+
if xid_int <= 0:
|
|
680
|
+
fail_e = {"success": False, "detail": "friendId 须为正整数", "friendId": xid_int}
|
|
681
|
+
lab0 = _user_friend_label(user)
|
|
682
|
+
if isinstance(lab0, str) and lab0.strip():
|
|
683
|
+
fail_e["friendLabel"] = lab0.strip()
|
|
684
|
+
out[name] = fail_e
|
|
685
|
+
continue
|
|
686
|
+
lab = _user_friend_label(user)
|
|
687
|
+
if isinstance(lab, str) and lab.strip():
|
|
688
|
+
label_s = lab.strip()
|
|
689
|
+
else:
|
|
690
|
+
label_s = None
|
|
691
|
+
items = all_results.get(name, [])
|
|
692
|
+
msg = _format_classify_dm_message(name, items)
|
|
693
|
+
ok, detail = _send_classify_dm(xid_int, msg)
|
|
694
|
+
entry = {"success": ok, "friendId": xid_int}
|
|
695
|
+
if label_s:
|
|
696
|
+
entry["friendLabel"] = label_s
|
|
697
|
+
if not ok:
|
|
698
|
+
entry["detail"] = detail
|
|
699
|
+
out[name] = entry
|
|
700
|
+
return out
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def notify_pack_link_dm(config_path: str, name_to_url: dict) -> dict:
|
|
704
|
+
"""pack 成功后,向配置了 friendId 的用户私信下载链接。name_to_url: {本地用户名: url}。"""
|
|
705
|
+
out = {}
|
|
706
|
+
for name, url in (name_to_url or {}).items():
|
|
707
|
+
if not name or name == "quick_search":
|
|
708
|
+
continue
|
|
709
|
+
if not url:
|
|
710
|
+
continue
|
|
711
|
+
user = _find_user_by_name(config_path, name)
|
|
712
|
+
if not user:
|
|
713
|
+
continue
|
|
714
|
+
xid = _user_friend_id(user)
|
|
715
|
+
if xid is None:
|
|
716
|
+
continue
|
|
717
|
+
try:
|
|
718
|
+
xid_int = int(xid)
|
|
719
|
+
except (TypeError, ValueError):
|
|
720
|
+
bad = {"success": False, "detail": f"无效的 friendId: {xid!r}", "url": url}
|
|
721
|
+
lab_bad = _user_friend_label(user)
|
|
722
|
+
if isinstance(lab_bad, str) and lab_bad.strip():
|
|
723
|
+
bad["friendLabel"] = lab_bad.strip()
|
|
724
|
+
out[name] = bad
|
|
725
|
+
continue
|
|
726
|
+
if xid_int <= 0:
|
|
727
|
+
fail_e = {"success": False, "detail": "friendId 须为正整数", "friendId": xid_int, "url": url}
|
|
728
|
+
lab0 = _user_friend_label(user)
|
|
729
|
+
if isinstance(lab0, str) and lab0.strip():
|
|
730
|
+
fail_e["friendLabel"] = lab0.strip()
|
|
731
|
+
out[name] = fail_e
|
|
732
|
+
continue
|
|
733
|
+
lab = _user_friend_label(user)
|
|
734
|
+
label_s = lab.strip() if isinstance(lab, str) and lab.strip() else None
|
|
735
|
+
msg = _format_pack_link_dm_message(name, url)
|
|
736
|
+
ok, detail = _send_classify_dm(xid_int, msg)
|
|
737
|
+
entry = {"success": ok, "friendId": xid_int, "url": url}
|
|
738
|
+
if label_s:
|
|
739
|
+
entry["friendLabel"] = label_s
|
|
740
|
+
if not ok:
|
|
741
|
+
entry["detail"] = detail
|
|
742
|
+
out[name] = entry
|
|
743
|
+
return out
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _fmt_dm_line_ok(disp: str) -> str:
|
|
747
|
+
"""dm_lines:私信发送成功(展示虾友昵称或 friendId)。"""
|
|
748
|
+
return f"✅ 💌 已向虾友「{disp}」发送成功"
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _fmt_dm_line_fail(disp: str, detail: str) -> str:
|
|
752
|
+
"""dm_lines:私信发送失败。"""
|
|
753
|
+
return f"❌ 💢 虾友「{disp}」发送失败:{detail}"
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def _fmt_dm_line_no_friend(name: str) -> str:
|
|
757
|
+
"""dm_lines:未配置 friendId。"""
|
|
758
|
+
return f"📭 「{name}」暂未绑定虾友,跳过私信"
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
def _fmt_pack_line_fail(name: str, err: str) -> str:
|
|
762
|
+
"""dm_lines:pack 打包失败。"""
|
|
763
|
+
return f"📦❌ 「{name}」下载包生成失败:{err}"
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def build_pack_dm_status_lines(
|
|
767
|
+
config_path: str,
|
|
768
|
+
results_dict: dict,
|
|
769
|
+
user_urls: dict,
|
|
770
|
+
errors: dict | None,
|
|
771
|
+
pack_dm_out: dict,
|
|
772
|
+
) -> list:
|
|
773
|
+
"""pack 后的虾友发送状态短句(与 classify 的 dm_lines 同一套 emoji 模板)。"""
|
|
774
|
+
lines = []
|
|
775
|
+
errors = errors or {}
|
|
776
|
+
pack_dm_out = pack_dm_out or {}
|
|
777
|
+
for name in results_dict.keys():
|
|
778
|
+
if name == "quick_search":
|
|
779
|
+
continue
|
|
780
|
+
if name in errors and name not in user_urls:
|
|
781
|
+
lines.append(_fmt_pack_line_fail(name, str(errors[name])))
|
|
782
|
+
continue
|
|
783
|
+
if name not in user_urls:
|
|
784
|
+
continue
|
|
785
|
+
u = _find_user_by_name(config_path, name) or {}
|
|
786
|
+
xid = _user_friend_id(u)
|
|
787
|
+
if xid is None:
|
|
788
|
+
lines.append(_fmt_dm_line_no_friend(name))
|
|
789
|
+
continue
|
|
790
|
+
|
|
791
|
+
def _disp(uu) -> str:
|
|
792
|
+
lab = _user_friend_label(uu)
|
|
793
|
+
if isinstance(lab, str) and lab.strip():
|
|
794
|
+
return lab.strip()
|
|
795
|
+
try:
|
|
796
|
+
return str(int(_user_friend_id(uu)))
|
|
797
|
+
except (TypeError, ValueError):
|
|
798
|
+
return str(_user_friend_id(uu))
|
|
799
|
+
|
|
800
|
+
try:
|
|
801
|
+
xid_int = int(xid)
|
|
802
|
+
except (TypeError, ValueError):
|
|
803
|
+
disp = _disp(u)
|
|
804
|
+
info = pack_dm_out.get(name)
|
|
805
|
+
if info and not info.get("success"):
|
|
806
|
+
det = (info.get("detail") or "未知错误").strip()
|
|
807
|
+
lines.append(_fmt_dm_line_fail(disp, det))
|
|
808
|
+
else:
|
|
809
|
+
lines.append(_fmt_dm_line_fail(disp, "friendId 无效"))
|
|
810
|
+
continue
|
|
811
|
+
|
|
812
|
+
if xid_int <= 0:
|
|
813
|
+
disp = _disp(u)
|
|
814
|
+
info = pack_dm_out.get(name)
|
|
815
|
+
if info and not info.get("success"):
|
|
816
|
+
det = (info.get("detail") or "未知错误").strip()
|
|
817
|
+
lines.append(_fmt_dm_line_fail(disp, det))
|
|
818
|
+
else:
|
|
819
|
+
lines.append(_fmt_dm_line_fail(disp, "friendId 须为正整数"))
|
|
820
|
+
continue
|
|
821
|
+
|
|
822
|
+
disp = _disp(u)
|
|
823
|
+
info = pack_dm_out.get(name)
|
|
824
|
+
if info and info.get("success"):
|
|
825
|
+
lines.append(_fmt_dm_line_ok(disp))
|
|
826
|
+
elif info:
|
|
827
|
+
det = (info.get("detail") or "未知错误").strip()
|
|
828
|
+
lines.append(_fmt_dm_line_fail(disp, det))
|
|
829
|
+
else:
|
|
830
|
+
lines.append(_fmt_dm_line_fail(disp, "未返回状态"))
|
|
831
|
+
return lines
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def build_dm_status_lines(config_path: str, all_results: dict, dm_out: dict) -> list:
|
|
835
|
+
"""按本轮参与分类的用户顺序,生成最终提示用短句(含 emoji,见 _fmt_dm_line_*)。"""
|
|
836
|
+
basic_config = get_config(config_path)
|
|
837
|
+
name_to_user = {u.get("name"): u for u in basic_config.get("users", []) if u.get("name")}
|
|
838
|
+
lines = []
|
|
839
|
+
for name in all_results.keys():
|
|
840
|
+
u = name_to_user.get(name, {})
|
|
841
|
+
xid = _user_friend_id(u)
|
|
842
|
+
if xid is None:
|
|
843
|
+
lines.append(_fmt_dm_line_no_friend(name))
|
|
844
|
+
continue
|
|
845
|
+
|
|
846
|
+
def _disp_for_xid(raw, fallback_int=None) -> str:
|
|
847
|
+
lab = _user_friend_label(u)
|
|
848
|
+
if isinstance(lab, str) and lab.strip():
|
|
849
|
+
return lab.strip()
|
|
850
|
+
if fallback_int is not None:
|
|
851
|
+
return str(fallback_int)
|
|
852
|
+
return str(raw)
|
|
853
|
+
|
|
854
|
+
try:
|
|
855
|
+
xid_int = int(xid)
|
|
856
|
+
except (TypeError, ValueError):
|
|
857
|
+
disp = _disp_for_xid(xid, None)
|
|
858
|
+
info = dm_out.get(name) if dm_out else None
|
|
859
|
+
if info and not info.get("success"):
|
|
860
|
+
det = (info.get("detail") or "未知错误").strip()
|
|
861
|
+
lines.append(_fmt_dm_line_fail(disp, det))
|
|
862
|
+
else:
|
|
863
|
+
lines.append(_fmt_dm_line_fail(disp, "friendId 无效"))
|
|
864
|
+
continue
|
|
865
|
+
|
|
866
|
+
if xid_int <= 0:
|
|
867
|
+
disp = _disp_for_xid(xid, xid_int)
|
|
868
|
+
info = dm_out.get(name) if dm_out else None
|
|
869
|
+
if info and not info.get("success"):
|
|
870
|
+
det = (info.get("detail") or "未知错误").strip()
|
|
871
|
+
lines.append(_fmt_dm_line_fail(disp, det))
|
|
872
|
+
else:
|
|
873
|
+
lines.append(_fmt_dm_line_fail(disp, "friendId 须为正整数"))
|
|
874
|
+
continue
|
|
875
|
+
|
|
876
|
+
disp = _disp_for_xid(xid, xid_int)
|
|
877
|
+
info = dm_out.get(name) if dm_out else None
|
|
878
|
+
if info and info.get("success"):
|
|
879
|
+
lines.append(_fmt_dm_line_ok(disp))
|
|
880
|
+
elif info:
|
|
881
|
+
det = (info.get("detail") or "未知错误").strip()
|
|
882
|
+
lines.append(_fmt_dm_line_fail(disp, det))
|
|
883
|
+
else:
|
|
884
|
+
lines.append(_fmt_dm_line_fail(disp, "未返回状态"))
|
|
885
|
+
return lines
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
def copy_results_to_folder(results, target_folder, user_name=None):
|
|
889
|
+
"""将搜索结果中的照片复制到指定文件夹。
|
|
890
|
+
如果指定了 user_name,会在 target_folder 下创建以用户名命名的子目录。
|
|
891
|
+
results 可以是列表(单用户)或字典(多用户一键分类结果)。
|
|
892
|
+
返回 (成功数, 失败数, 目标目录路径)。
|
|
893
|
+
"""
|
|
894
|
+
if isinstance(results, dict):
|
|
895
|
+
total_ok, total_fail = 0, 0
|
|
896
|
+
base = os.path.realpath(target_folder)
|
|
897
|
+
os.makedirs(base, exist_ok=True)
|
|
898
|
+
for name, items in results.items():
|
|
899
|
+
ok, fail, _ = copy_results_to_folder(items, os.path.join(base, name))
|
|
900
|
+
total_ok += ok
|
|
901
|
+
total_fail += fail
|
|
902
|
+
return total_ok, total_fail, base
|
|
903
|
+
|
|
904
|
+
dest = os.path.realpath(target_folder)
|
|
905
|
+
if user_name:
|
|
906
|
+
dest = os.path.join(dest, user_name)
|
|
907
|
+
os.makedirs(dest, exist_ok=True)
|
|
908
|
+
|
|
909
|
+
ok_count, fail_count = 0, 0
|
|
910
|
+
for item in results:
|
|
911
|
+
src = item.get("image_path", "")
|
|
912
|
+
if not src or not os.path.exists(src):
|
|
913
|
+
fail_count += 1
|
|
914
|
+
continue
|
|
915
|
+
base_name = os.path.basename(src)
|
|
916
|
+
dst = os.path.join(dest, base_name)
|
|
917
|
+
if os.path.exists(dst):
|
|
918
|
+
stem, ext = os.path.splitext(base_name)
|
|
919
|
+
counter = 1
|
|
920
|
+
while os.path.exists(dst):
|
|
921
|
+
dst = os.path.join(dest, f"{stem}_{counter}{ext}")
|
|
922
|
+
counter += 1
|
|
923
|
+
try:
|
|
924
|
+
shutil.copy2(src, dst)
|
|
925
|
+
ok_count += 1
|
|
926
|
+
except Exception as e:
|
|
927
|
+
print(f"复制失败 {src}: {e}", file=sys.stderr)
|
|
928
|
+
fail_count += 1
|
|
929
|
+
|
|
930
|
+
return ok_count, fail_count, str(dest)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def pack_results_to_url(results, archive_name="search_results", upload_timeout=120):
|
|
934
|
+
"""将搜索结果中的照片压缩为 zip 并上传获取下载链接。
|
|
935
|
+
results 可以是列表(单用户)或字典(多用户一键分类结果)。
|
|
936
|
+
upload_timeout 用于控制上传超时时间(秒),压缩文件较大时应适当增大。
|
|
937
|
+
返回 (下载链接, zip文件路径) 或 (None, 错误信息)。
|
|
938
|
+
"""
|
|
939
|
+
import tempfile
|
|
940
|
+
tmp_dir = tempfile.mkdtemp()
|
|
941
|
+
zip_path = os.path.join(tmp_dir, f"{archive_name}.zip")
|
|
942
|
+
|
|
943
|
+
try:
|
|
944
|
+
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zf:
|
|
945
|
+
file_count = 0
|
|
946
|
+
if isinstance(results, dict):
|
|
947
|
+
for name, items in results.items():
|
|
948
|
+
for item in items:
|
|
949
|
+
src = item.get("image_path", "")
|
|
950
|
+
if not src or not os.path.exists(src):
|
|
951
|
+
continue
|
|
952
|
+
arcname = f"{name}/{os.path.basename(src)}"
|
|
953
|
+
zf.write(src, arcname)
|
|
954
|
+
file_count += 1
|
|
955
|
+
else:
|
|
956
|
+
for item in results:
|
|
957
|
+
src = item.get("image_path", "")
|
|
958
|
+
if not src or not os.path.exists(src):
|
|
959
|
+
continue
|
|
960
|
+
zf.write(src, os.path.basename(src))
|
|
961
|
+
file_count += 1
|
|
962
|
+
|
|
963
|
+
if file_count == 0:
|
|
964
|
+
return None, "没有可打包的文件"
|
|
965
|
+
|
|
966
|
+
url = convert_to_url(zip_path, timeout=upload_timeout)
|
|
967
|
+
return url, zip_path
|
|
968
|
+
|
|
969
|
+
except Exception as e:
|
|
970
|
+
return None, str(e)
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def _json_output(data):
|
|
974
|
+
"""统一 JSON 输出到 stdout"""
|
|
975
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def main():
|
|
979
|
+
parser = argparse.ArgumentParser(description="📸 照片分类器 CLI")
|
|
980
|
+
parser.add_argument("-c", "--config", default="references/config.json",
|
|
981
|
+
help="配置文件路径 (默认: references/config.json)")
|
|
982
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
983
|
+
|
|
984
|
+
# check — 检查用户名是否存在
|
|
985
|
+
sp = subparsers.add_parser("check", help="检查用户名是否已注册")
|
|
986
|
+
sp.add_argument("name", help="用户名")
|
|
987
|
+
|
|
988
|
+
# add — 注册新用户
|
|
989
|
+
sp = subparsers.add_parser("add", help="注册新用户")
|
|
990
|
+
sp.add_argument("name", help="用户名")
|
|
991
|
+
sp.add_argument("image", help="照片路径")
|
|
992
|
+
sp.add_argument(
|
|
993
|
+
"--friend-id",
|
|
994
|
+
type=int,
|
|
995
|
+
default=None,
|
|
996
|
+
dest="friend_id",
|
|
997
|
+
metavar="ID",
|
|
998
|
+
help="可选:好友 userId(friendId)。设置后,一键 classify 会将该用户分类结果自动 DM 到此账号",
|
|
999
|
+
)
|
|
1000
|
+
sp.add_argument(
|
|
1001
|
+
"--friend-label",
|
|
1002
|
+
default=None,
|
|
1003
|
+
dest="friend_label",
|
|
1004
|
+
metavar="NAME",
|
|
1005
|
+
help="可选:DM 展示名(与自然语言「虾友:xxx」对应,用于最终提示括号内文案)",
|
|
1006
|
+
)
|
|
1007
|
+
|
|
1008
|
+
# replace — 替换用户照片
|
|
1009
|
+
sp = subparsers.add_parser("replace", help="替换用户照片(删除旧照片)")
|
|
1010
|
+
sp.add_argument("name", help="用户名")
|
|
1011
|
+
sp.add_argument("image", help="新照片路径")
|
|
1012
|
+
|
|
1013
|
+
# append — 追加照片
|
|
1014
|
+
sp = subparsers.add_parser("append", help="为已有用户追加一张照片")
|
|
1015
|
+
sp.add_argument("name", help="用户名")
|
|
1016
|
+
sp.add_argument("image", help="照片路径")
|
|
1017
|
+
|
|
1018
|
+
# rename — 修改用户名
|
|
1019
|
+
sp = subparsers.add_parser("rename", help="修改用户名")
|
|
1020
|
+
sp.add_argument("old_name", help="原用户名")
|
|
1021
|
+
sp.add_argument("new_name", help="新用户名")
|
|
1022
|
+
|
|
1023
|
+
# delete — 删除用户
|
|
1024
|
+
sp = subparsers.add_parser("delete", help="删除用户")
|
|
1025
|
+
sp.add_argument("name", help="用户名")
|
|
1026
|
+
|
|
1027
|
+
# search — 搜索某用户的相似人脸
|
|
1028
|
+
sp = subparsers.add_parser("search", help="在指定目录搜索某用户的相似人脸")
|
|
1029
|
+
sp.add_argument("name", help="用户名")
|
|
1030
|
+
sp.add_argument("search_path", help="搜索目录路径")
|
|
1031
|
+
|
|
1032
|
+
# quick-search — 直接用照片搜索,不写入 config
|
|
1033
|
+
sp = subparsers.add_parser("quick-search", help="直接用一张照片搜索相似人脸(不注册)")
|
|
1034
|
+
sp.add_argument("image", help="查询照片路径")
|
|
1035
|
+
sp.add_argument("search_path", help="搜索目录路径")
|
|
1036
|
+
|
|
1037
|
+
# classify — 一键分类
|
|
1038
|
+
sp = subparsers.add_parser("classify", help="遍历所有注册用户,搜索所有照片")
|
|
1039
|
+
sp.add_argument("search_path", help="搜索目录路径")
|
|
1040
|
+
|
|
1041
|
+
# copy — 将上次搜索结果复制到文件夹
|
|
1042
|
+
sp = subparsers.add_parser("copy", help="将搜索结果中的照片复制到指定文件夹")
|
|
1043
|
+
sp.add_argument("target_folder", help="目标文件夹路径")
|
|
1044
|
+
sp.add_argument("--name", default=None, help="指定用户名(仅复制该用户的结果);不指定则复制所有用户")
|
|
1045
|
+
|
|
1046
|
+
# pack — 打包搜索结果并上传
|
|
1047
|
+
sp = subparsers.add_parser("pack", help="将搜索结果压缩为 zip 并上传获取下载链接")
|
|
1048
|
+
sp.add_argument("--name", default=None, help="指定用户名;不指定则打包所有用户")
|
|
1049
|
+
sp.add_argument("--archive-name", default="search_results", help="压缩包名称 (默认: search_results)")
|
|
1050
|
+
sp.add_argument("--timeout", type=int, default=120, help="上传超时秒数 (默认: 120)")
|
|
1051
|
+
|
|
1052
|
+
# upload — 上传文件获取 URL
|
|
1053
|
+
sp = subparsers.add_parser("upload", help="上传本地文件获取 URL")
|
|
1054
|
+
sp.add_argument("file_path", help="文件路径")
|
|
1055
|
+
sp.add_argument("--timeout", type=int, default=10, help="上传超时秒数 (默认: 10)")
|
|
1056
|
+
|
|
1057
|
+
# init-today-folder — 在待搜索目录下按当前日期创建子目录
|
|
1058
|
+
subparsers.add_parser("init-today-folder", help="在待搜索目录 images 下创建当天日期文件夹(YYYYMMDD)")
|
|
1059
|
+
|
|
1060
|
+
# list-date-folders — 列出待搜索目录下日期子目录(供未指定搜索目录时选择)
|
|
1061
|
+
subparsers.add_parser(
|
|
1062
|
+
"list-date-folders",
|
|
1063
|
+
help="列出 images 下 YYYYMMDD 日期文件夹(从新到旧),JSON 含 folders 与 preview_n",
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
# upload-image — 将图片剪切到待搜索目录的当天日期目录
|
|
1067
|
+
sp = subparsers.add_parser("upload-image", help="将图片串行剪切到待搜索目录 images/当天日期目录")
|
|
1068
|
+
sp.add_argument("file_paths", nargs="+", help="一个或多个图片文件路径")
|
|
1069
|
+
|
|
1070
|
+
args = parser.parse_args()
|
|
1071
|
+
cfg = args.config
|
|
1072
|
+
|
|
1073
|
+
if args.command == "check":
|
|
1074
|
+
exists = name_in_config(args.name, cfg)
|
|
1075
|
+
_json_output({"exists": exists, "name": args.name})
|
|
1076
|
+
|
|
1077
|
+
elif args.command == "add":
|
|
1078
|
+
friend_id = getattr(args, "friend_id", None)
|
|
1079
|
+
raw_label = getattr(args, "friend_label", None)
|
|
1080
|
+
friend_label = str(raw_label).strip() if raw_label is not None and str(raw_label).strip() else None
|
|
1081
|
+
if friend_id is not None and friend_id <= 0:
|
|
1082
|
+
_json_output(
|
|
1083
|
+
{
|
|
1084
|
+
"success": False,
|
|
1085
|
+
"message": "friendId 须为正整数",
|
|
1086
|
+
"name": args.name,
|
|
1087
|
+
}
|
|
1088
|
+
)
|
|
1089
|
+
else:
|
|
1090
|
+
ok, msg = add_user_config(
|
|
1091
|
+
args.name,
|
|
1092
|
+
args.image,
|
|
1093
|
+
cfg,
|
|
1094
|
+
friend_id=friend_id,
|
|
1095
|
+
friend_label=friend_label,
|
|
1096
|
+
)
|
|
1097
|
+
result = {"success": ok, "message": msg, "name": args.name}
|
|
1098
|
+
if friend_id is not None:
|
|
1099
|
+
result["friendId"] = friend_id
|
|
1100
|
+
if friend_label:
|
|
1101
|
+
result["friendLabel"] = friend_label
|
|
1102
|
+
if ok:
|
|
1103
|
+
try:
|
|
1104
|
+
result["image_url"] = convert_to_url(args.image, timeout=30)
|
|
1105
|
+
except Exception:
|
|
1106
|
+
result["image_url"] = None
|
|
1107
|
+
_json_output(result)
|
|
1108
|
+
|
|
1109
|
+
elif args.command == "replace":
|
|
1110
|
+
ok, msg = replace_user_embeding_config(args.name, args.image, cfg)
|
|
1111
|
+
result = {"success": ok, "message": msg, "name": args.name}
|
|
1112
|
+
if ok:
|
|
1113
|
+
try:
|
|
1114
|
+
result["image_url"] = convert_to_url(args.image, timeout=30)
|
|
1115
|
+
except Exception:
|
|
1116
|
+
result["image_url"] = None
|
|
1117
|
+
_json_output(result)
|
|
1118
|
+
|
|
1119
|
+
elif args.command == "append":
|
|
1120
|
+
ok, msg = append_user_embeding_config(args.name, args.image, cfg)
|
|
1121
|
+
result = {"success": ok, "message": msg, "name": args.name}
|
|
1122
|
+
if ok:
|
|
1123
|
+
try:
|
|
1124
|
+
result["image_url"] = convert_to_url(args.image, timeout=30)
|
|
1125
|
+
except Exception:
|
|
1126
|
+
result["image_url"] = None
|
|
1127
|
+
_json_output(result)
|
|
1128
|
+
|
|
1129
|
+
elif args.command == "rename":
|
|
1130
|
+
replace_name_config(args.old_name, args.new_name, cfg)
|
|
1131
|
+
_json_output({"success": True, "old_name": args.old_name, "new_name": args.new_name})
|
|
1132
|
+
|
|
1133
|
+
elif args.command == "delete":
|
|
1134
|
+
delete_user_config(args.name, cfg)
|
|
1135
|
+
_json_output({"success": True, "name": args.name})
|
|
1136
|
+
|
|
1137
|
+
elif args.command == "search":
|
|
1138
|
+
results = search_similar_faces(args.name, args.search_path, cfg)
|
|
1139
|
+
_json_output({"name": args.name, "count": len(results), "results": results})
|
|
1140
|
+
|
|
1141
|
+
elif args.command == "quick-search":
|
|
1142
|
+
results, msg = quick_search(args.image, args.search_path, cfg)
|
|
1143
|
+
if results is None:
|
|
1144
|
+
_json_output({"success": False, "message": msg})
|
|
1145
|
+
else:
|
|
1146
|
+
_json_output({"success": True, "count": len(results), "results": results})
|
|
1147
|
+
|
|
1148
|
+
elif args.command == "classify":
|
|
1149
|
+
all_results = classify_all_users(args.search_path, cfg)
|
|
1150
|
+
summary = {}
|
|
1151
|
+
for name, items in all_results.items():
|
|
1152
|
+
summary[name] = {"count": len(items), "results": items}
|
|
1153
|
+
payload = {"user_count": len(all_results), "users": summary}
|
|
1154
|
+
dm_status = notify_classify_dm(cfg, all_results)
|
|
1155
|
+
if dm_status:
|
|
1156
|
+
payload["dm"] = dm_status
|
|
1157
|
+
if all_results:
|
|
1158
|
+
payload["dm_lines"] = build_dm_status_lines(cfg, all_results, dm_status or {})
|
|
1159
|
+
_json_output(payload)
|
|
1160
|
+
|
|
1161
|
+
elif args.command == "copy":
|
|
1162
|
+
results = _load_search_results(cfg, args.name)
|
|
1163
|
+
if results is None:
|
|
1164
|
+
_json_output({"success": False, "message": "未找到搜索结果,请先执行 search 或 classify"})
|
|
1165
|
+
return
|
|
1166
|
+
ok, fail, dest = copy_results_to_folder(results, args.target_folder)
|
|
1167
|
+
_json_output({"success": True, "copied": ok, "failed": fail, "target": dest})
|
|
1168
|
+
|
|
1169
|
+
elif args.command == "pack":
|
|
1170
|
+
results = _load_search_results(cfg, args.name)
|
|
1171
|
+
if results is None:
|
|
1172
|
+
_json_output({"success": False, "message": "未找到搜索结果,请先执行 search 或 classify"})
|
|
1173
|
+
return
|
|
1174
|
+
if isinstance(results, dict) and not args.name:
|
|
1175
|
+
user_urls = {}
|
|
1176
|
+
errors = {}
|
|
1177
|
+
for user_name, user_results in results.items():
|
|
1178
|
+
url, info = pack_results_to_url(user_results, f"search_results_{user_name}", args.timeout)
|
|
1179
|
+
if url:
|
|
1180
|
+
user_urls[user_name] = url
|
|
1181
|
+
else:
|
|
1182
|
+
errors[user_name] = info
|
|
1183
|
+
pack_dm = notify_pack_link_dm(cfg, user_urls)
|
|
1184
|
+
dm_lines = build_pack_dm_status_lines(cfg, results, user_urls, errors, pack_dm)
|
|
1185
|
+
payload = {
|
|
1186
|
+
"success": bool(user_urls),
|
|
1187
|
+
"user_urls": user_urls,
|
|
1188
|
+
"errors": errors if errors else None,
|
|
1189
|
+
}
|
|
1190
|
+
if pack_dm:
|
|
1191
|
+
payload["dm"] = pack_dm
|
|
1192
|
+
if dm_lines:
|
|
1193
|
+
payload["dm_lines"] = dm_lines
|
|
1194
|
+
_json_output(payload)
|
|
1195
|
+
else:
|
|
1196
|
+
url, info = pack_results_to_url(results, args.archive_name, args.timeout)
|
|
1197
|
+
if url:
|
|
1198
|
+
payload = {"success": True, "url": url, "zip_path": info}
|
|
1199
|
+
if args.name:
|
|
1200
|
+
pack_dm = notify_pack_link_dm(cfg, {args.name: url})
|
|
1201
|
+
dm_lines = build_pack_dm_status_lines(
|
|
1202
|
+
cfg, {args.name: results}, {args.name: url}, None, pack_dm
|
|
1203
|
+
)
|
|
1204
|
+
if pack_dm:
|
|
1205
|
+
payload["dm"] = pack_dm
|
|
1206
|
+
if dm_lines:
|
|
1207
|
+
payload["dm_lines"] = dm_lines
|
|
1208
|
+
_json_output(payload)
|
|
1209
|
+
else:
|
|
1210
|
+
_json_output({"success": False, "message": info})
|
|
1211
|
+
|
|
1212
|
+
elif args.command == "upload":
|
|
1213
|
+
url = convert_to_url(args.file_path, timeout=args.timeout)
|
|
1214
|
+
_json_output({"success": True, "url": url})
|
|
1215
|
+
|
|
1216
|
+
elif args.command == "init-today-folder":
|
|
1217
|
+
_json_output({"folder_path": create_today_folder()})
|
|
1218
|
+
|
|
1219
|
+
elif args.command == "list-date-folders":
|
|
1220
|
+
folders = list_date_folders()
|
|
1221
|
+
preview_n = 3
|
|
1222
|
+
_json_output({
|
|
1223
|
+
"images_base": get_images_path(),
|
|
1224
|
+
"folders": folders,
|
|
1225
|
+
"total": len(folders),
|
|
1226
|
+
"preview_n": preview_n,
|
|
1227
|
+
"preview": folders[:preview_n],
|
|
1228
|
+
})
|
|
1229
|
+
|
|
1230
|
+
elif args.command == "upload-image":
|
|
1231
|
+
result = move_images_to_today_folder(args.file_paths)
|
|
1232
|
+
if len(args.file_paths) == 1:
|
|
1233
|
+
_json_output({
|
|
1234
|
+
"success": result["success"],
|
|
1235
|
+
"folder_path": result["folder_path"],
|
|
1236
|
+
"file_path": result["uploaded"][0] if result["uploaded"] else None,
|
|
1237
|
+
"failed": result["failed"],
|
|
1238
|
+
})
|
|
1239
|
+
else:
|
|
1240
|
+
_json_output(result)
|
|
1241
|
+
|
|
1242
|
+
|
|
1243
|
+
def _load_search_results(config_path, name=None):
|
|
1244
|
+
"""从 config.json 加载上次的搜索结果。
|
|
1245
|
+
name=None 时返回所有用户的结果(字典)或 quick-search 的结果(列表)。
|
|
1246
|
+
指定 name 时返回该用户的结果(列表)。
|
|
1247
|
+
"""
|
|
1248
|
+
basic_config = get_config(config_path)
|
|
1249
|
+
user_list = basic_config.get("users", [])
|
|
1250
|
+
quick_results = basic_config.get("quick_search_result")
|
|
1251
|
+
|
|
1252
|
+
if name:
|
|
1253
|
+
for user in user_list:
|
|
1254
|
+
if user.get("name") == name:
|
|
1255
|
+
sr = user.get("search_result")
|
|
1256
|
+
return sr if sr else None
|
|
1257
|
+
return None
|
|
1258
|
+
|
|
1259
|
+
all_results = {}
|
|
1260
|
+
for user in user_list:
|
|
1261
|
+
sr = user.get("search_result")
|
|
1262
|
+
if sr:
|
|
1263
|
+
all_results[user.get("name", "")] = sr
|
|
1264
|
+
|
|
1265
|
+
if all_results and quick_results:
|
|
1266
|
+
all_results["quick_search"] = quick_results
|
|
1267
|
+
return all_results
|
|
1268
|
+
if all_results:
|
|
1269
|
+
return all_results
|
|
1270
|
+
if quick_results:
|
|
1271
|
+
return quick_results
|
|
1272
|
+
return None
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
if __name__ == "__main__":
|
|
1276
|
+
main()
|