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.
Files changed (633) hide show
  1. package/README.md +199 -187
  2. package/agents/ai-cs-admin/.config.json +51 -51
  3. package/agents/ai-cs-admin/AGENTS.md +293 -293
  4. package/agents/ai-cs-admin/HEARTBEAT.md +18 -18
  5. package/agents/ai-cs-qa/.config.json +47 -47
  6. package/agents/ai-cs-qa/BOOTSTRAP.md +22 -22
  7. package/agents/ai-cs-qa/scripts/setup_links.sh +39 -39
  8. package/agents/beauty/.config.json +17 -17
  9. package/agents/beauty/AGENTS.md +234 -234
  10. package/agents/beauty/BOOTSTRAP.md +55 -55
  11. package/agents/beauty/HEARTBEAT.md +5 -5
  12. package/agents/beauty/IDENTITY.md +5 -5
  13. package/agents/beauty/MEMORY.md +44 -44
  14. package/agents/beauty/SOUL.md +64 -64
  15. package/agents/beauty/TOOLS.md +160 -160
  16. package/agents/beauty/USER.md +114 -114
  17. package/agents/intern-admin/.config.json +60 -60
  18. package/agents/intern-admin/AGENTS.md +267 -267
  19. package/agents/intern-admin/BOOTSTRAP.md +21 -21
  20. package/agents/intern-admin/HEARTBEAT.md +3 -3
  21. package/agents/intern-admin/IDENTITY.md +6 -6
  22. package/agents/intern-admin/MEMORY.md +21 -21
  23. package/agents/intern-admin/SOUL.md +23 -23
  24. package/agents/intern-admin/TOOLS.md +93 -93
  25. package/agents/intern-admin/USER.md +16 -16
  26. package/agents/intern-admin/scripts/init_workspace.sh +27 -27
  27. package/agents/intern-qa/.config.json +46 -46
  28. package/agents/intern-qa/AGENTS.md +303 -303
  29. package/agents/intern-qa/BOOTSTRAP.md +16 -16
  30. package/agents/intern-qa/HEARTBEAT.md +3 -3
  31. package/agents/intern-qa/IDENTITY.md +6 -6
  32. package/agents/intern-qa/MEMORY.md +22 -22
  33. package/agents/intern-qa/SOUL.md +24 -24
  34. package/agents/intern-qa/TOOLS.md +24 -24
  35. package/agents/intern-qa/USER.md +27 -27
  36. package/agents/intern-qa/scripts/setup_links.sh +54 -54
  37. package/agents/parent-toddler/.config.json +37 -37
  38. package/agents/parent-toddler/AGENTS.md +51 -51
  39. package/agents/parent-toddler/BOOTSTRAP.md +55 -55
  40. package/agents/parent-toddler/HEARTBEAT.md +5 -5
  41. package/agents/parent-toddler/IDENTITY.md +5 -5
  42. package/agents/parent-toddler/MEMORY.md +22 -22
  43. package/agents/parent-toddler/SOUL.md +35 -35
  44. package/agents/parent-toddler/TOOLS.md +31 -31
  45. package/agents/parent-toddler/USER.md +44 -44
  46. package/agents/vip-admin/.config.json +51 -51
  47. package/agents/vip-admin/AGENTS.md +314 -314
  48. package/agents/vip-admin/BOOTSTRAP.md +21 -21
  49. package/agents/vip-admin/HEARTBEAT.md +19 -19
  50. package/agents/vip-admin/IDENTITY.md +6 -6
  51. package/agents/vip-admin/MEMORY.md +30 -30
  52. package/agents/vip-admin/SOUL.md +25 -25
  53. package/agents/vip-admin/TOOLS.md +108 -108
  54. package/agents/vip-admin/USER.md +31 -31
  55. package/agents/vip-qa/.config.json +58 -58
  56. package/agents/vip-qa/AGENTS.md +319 -319
  57. package/agents/vip-qa/BOOTSTRAP.md +73 -73
  58. package/agents/vip-qa/HEARTBEAT.md +23 -23
  59. package/agents/vip-qa/IDENTITY.md +7 -7
  60. package/agents/vip-qa/MEMORY.md +23 -23
  61. package/agents/vip-qa/SOUL.md +34 -34
  62. package/agents/vip-qa/TOOLS.md +41 -41
  63. package/agents/vip-qa/USER.md +16 -16
  64. package/agents/vip-qa/scripts/setup_links.sh +39 -39
  65. package/bin/sophhub.js +25 -25
  66. package/package.json +35 -33
  67. package/skills/agent-install/skill.json +34 -34
  68. package/skills/agent-install/src/SKILL.md +240 -240
  69. package/skills/agent-install/src/pyproject.toml +6 -6
  70. package/skills/agent-install/src/scripts/backup_agent.py +120 -120
  71. package/skills/agent-install/src/scripts/check_installed.py +479 -479
  72. package/skills/agent-install/src/scripts/common.py +568 -568
  73. package/skills/agent-install/src/scripts/copy_agent_files.py +59 -59
  74. package/skills/agent-install/src/scripts/list_agents.py +285 -285
  75. package/skills/agent-install/src/scripts/resolve_install_params.py +90 -90
  76. package/skills/agent-install/src/scripts/update_agent_md.py +76 -76
  77. package/skills/agent-install/src/scripts/update_openclaw.py +193 -193
  78. package/skills/agent-install/src/scripts/verify_download.py +148 -148
  79. package/skills/aippt/skill.json +20 -20
  80. package/skills/aippt/src/SKILL.md +235 -235
  81. package/skills/aippt/src/pyproject.toml +8 -8
  82. package/skills/aippt/src/scripts/auth.py +122 -122
  83. package/skills/aippt/src/scripts/ppt.py +361 -361
  84. package/skills/aippt/src/scripts/provider_docmee.py +299 -299
  85. package/skills/beauty-salon-inventory/skill.json +16 -16
  86. package/skills/beauty-salon-inventory/src/SKILL.md +69 -69
  87. package/skills/beauty-salon-inventory/src/scripts/init_salon_inventory.py +39 -39
  88. package/skills/beauty-salon-inventory/src/scripts/init_salon_inventory.sh +4 -4
  89. package/skills/beauty-salon-inventory/src/scripts/salon_inventory_cli.py +244 -244
  90. package/skills/beauty-salon-marketing/skill.json +10 -10
  91. package/skills/beauty-salon-marketing/src/SKILL.md +36 -36
  92. package/skills/beauty-salon-marketing/src/playbooks/beauty-salon-festival.md +19 -19
  93. package/skills/beauty-salon-marketing/src/playbooks/beauty-salon-segment.md +18 -18
  94. package/skills/beauty-salon-marketing/src/scripts/beauty_marketing_cli.py +99 -99
  95. package/skills/beauty-salon-marketing/src/scripts/member_segment.py +114 -114
  96. package/skills/beauty-salon-member-appointment/skill.json +10 -10
  97. package/skills/beauty-salon-member-appointment/src/SKILL.md +36 -36
  98. package/skills/beauty-salon-member-appointment/src/pyproject.toml +9 -9
  99. package/skills/beauty-salon-member-appointment/src/scripts/run_e2e_smoke.py +160 -160
  100. package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/__init__.py +1 -1
  101. package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/__main__.py +4 -4
  102. package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/cli.py +921 -921
  103. package/skills/beauty-salon-member-appointment/src/src/member_appt_cli/db.py +30 -30
  104. package/skills/beauty-salon-membership/skill.json +20 -20
  105. package/skills/beauty-salon-membership/src/SKILL.md +67 -67
  106. package/skills/beauty-salon-product-service/skill.json +12 -12
  107. package/skills/beauty-salon-product-service/src/SKILL.md +42 -42
  108. package/skills/beauty-salon-product-service/src/pyproject.toml +9 -9
  109. package/skills/beauty-salon-product-service/src/src/product_service_cli/__init__.py +1 -1
  110. package/skills/beauty-salon-product-service/src/src/product_service_cli/__main__.py +4 -4
  111. package/skills/beauty-salon-product-service/src/src/product_service_cli/cli.py +329 -329
  112. package/skills/beauty-salon-product-service/src/src/product_service_cli/db.py +29 -29
  113. package/skills/beauty-salon-staff/skill.json +10 -10
  114. package/skills/beauty-salon-staff/src/SKILL.md +37 -37
  115. package/skills/beauty-salon-staff/src/pyproject.toml +9 -9
  116. package/skills/beauty-salon-staff/src/src/staff_cli/__init__.py +1 -1
  117. package/skills/beauty-salon-staff/src/src/staff_cli/__main__.py +4 -4
  118. package/skills/beauty-salon-staff/src/src/staff_cli/cli.py +479 -479
  119. package/skills/beauty-salon-staff/src/src/staff_cli/db.py +28 -28
  120. package/skills/beauty-salon-suite/skill.json +13 -13
  121. package/skills/beauty-salon-suite/src/SKILL.md +18 -18
  122. package/skills/beauty-salon-suite/src/beauty_db/__init__.py +2 -2
  123. package/skills/beauty-salon-suite/src/beauty_db/db.py +249 -249
  124. package/skills/beauty-salon-traffic/skill.json +20 -20
  125. package/skills/beauty-salon-traffic/src/SKILL.md +84 -84
  126. package/skills/bing-image-search/skill.json +20 -20
  127. package/skills/bing-image-search/src/SKILL.md +105 -105
  128. package/skills/bot-api-status/skill.json +44 -44
  129. package/skills/bot-api-status/src/SKILL.md +99 -99
  130. package/skills/bot-api-status/src/pyproject.toml +5 -5
  131. package/skills/bot-api-status/src/scripts/secret.py +496 -496
  132. package/skills/bot-secret/skill.json +35 -35
  133. package/skills/bot-secret/src/SKILL.md +51 -51
  134. package/skills/bot-secret/src/pyproject.toml +5 -5
  135. package/skills/bot-secret/src/scripts/secret.py +120 -120
  136. package/skills/cake-flower-holiday-campaign/skill.json +20 -20
  137. package/skills/cake-flower-holiday-campaign/src/SKILL.md +68 -68
  138. package/skills/cake-flower-order-sop/skill.json +20 -20
  139. package/skills/cake-flower-order-sop/src/SKILL.md +65 -65
  140. package/skills/claw-agent-get-send/skill.json +32 -32
  141. package/skills/claw-agent-get-send/src/SKILL.md +43 -43
  142. package/skills/claw-agent-get-send/src/pyproject.toml +5 -5
  143. package/skills/claw-agent-get-send/src/scripts/appia_claw.py +379 -379
  144. package/skills/compact-context/skill.json +20 -20
  145. package/skills/compact-context/src/SKILL.md +133 -133
  146. package/skills/compact-context/src/scripts/check.sh +381 -381
  147. package/skills/compact-context/src/scripts/set-keep-recent.mjs +1337 -1337
  148. package/skills/compact-context/src/scripts/setup.sh +96 -96
  149. package/skills/consensus/skill.json +20 -20
  150. package/skills/consensus/src/SKILL.md +93 -93
  151. package/skills/deepwiki/skill.json +20 -20
  152. package/skills/deepwiki/src/SKILL.md +45 -45
  153. package/skills/deepwiki/src/_meta.json +5 -5
  154. package/skills/deepwiki/src/scripts/deepwiki.js +135 -135
  155. package/skills/didi-ride/skill.json +20 -20
  156. package/skills/didi-ride/src/SKILL.md +309 -309
  157. package/skills/didi-ride/src/_meta.json +5 -5
  158. package/skills/didi-ride/src/assets/PREFERENCE.md +58 -58
  159. package/skills/didi-ride/src/package.json +15 -15
  160. package/skills/didi-ride/src/references/api_references.md +171 -171
  161. package/skills/didi-ride/src/references/error_handling.md +68 -68
  162. package/skills/didi-ride/src/references/setup.md +73 -73
  163. package/skills/didi-ride/src/references/workflow.md +150 -150
  164. package/skills/feishu-bitable/skill.json +20 -20
  165. package/skills/feishu-bitable/src/CHECKLIST.md +149 -149
  166. package/skills/feishu-bitable/src/README.md +177 -177
  167. package/skills/feishu-bitable/src/SKILL.md +113 -113
  168. package/skills/feishu-bitable/src/_meta.json +5 -5
  169. package/skills/feishu-bitable/src/api.js +380 -380
  170. package/skills/feishu-bitable/src/bin/cli.js +283 -283
  171. package/skills/feishu-bitable/src/description.md +142 -142
  172. package/skills/feishu-bitable/src/examples/create-records.json +51 -51
  173. package/skills/feishu-bitable/src/examples/create-table.json +63 -63
  174. package/skills/feishu-bitable/src/package-lock.json +324 -324
  175. package/skills/feishu-bitable/src/package.json +32 -32
  176. package/skills/feishu-bitable/src/publish-config.json +13 -13
  177. package/skills/feishu-bitable/src/test-simple.js +60 -60
  178. package/skills/feishu-bitable/src/utils.js +260 -260
  179. package/skills/feishu-notes-assistant-universal/skill.json +20 -20
  180. package/skills/feishu-notes-assistant-universal/src/README.md +55 -55
  181. package/skills/feishu-notes-assistant-universal/src/SKILL.md +159 -159
  182. package/skills/feishu-notes-assistant-universal/src/scripts/_resolve_lark_cli.py +58 -58
  183. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_meeting_minutes.py +462 -462
  184. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud.py +547 -547
  185. package/skills/feishu-notes-assistant-universal/src/scripts/openclaw_notes_crud_test.py +181 -181
  186. package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.py +80 -80
  187. package/skills/feishu-notes-assistant-universal/src/scripts/run_meeting_minutes.sh +5 -5
  188. package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.py +32 -32
  189. package/skills/feishu-notes-assistant-universal/src/scripts/run_note_crud.sh +5 -5
  190. package/skills/flight-booking/skill.json +36 -36
  191. package/skills/flight-booking/src/SKILL.md +288 -288
  192. package/skills/flight-booking/src/scripts/flight_booking.py +1237 -1237
  193. package/skills/flyai/skill.json +20 -20
  194. package/skills/flyai/src/SKILL.md +119 -119
  195. package/skills/flyai/src/references/fliggy-fast-search.md +53 -53
  196. package/skills/flyai/src/references/search-flight.md +89 -89
  197. package/skills/flyai/src/references/search-hotels.md +57 -57
  198. package/skills/flyai/src/references/search-poi.md +48 -48
  199. package/skills/google-maps/skill.json +20 -20
  200. package/skills/google-maps/src/SKILL.md +237 -237
  201. package/skills/google-maps/src/_meta.json +5 -5
  202. package/skills/google-maps/src/lib/map_helper.py +912 -912
  203. package/skills/image-classify/skill.json +42 -42
  204. package/skills/image-classify/src/SKILL.md +368 -368
  205. package/skills/image-classify/src/references/config.json +4 -4
  206. package/skills/image-classify/src/scripts/face_search.py +1276 -1276
  207. package/skills/image-description/skill.json +34 -34
  208. package/skills/image-description/src/SKILL.md +33 -33
  209. package/skills/image-description/src/pyproject.toml +8 -8
  210. package/skills/image-description/src/scripts/ana_image.py +112 -112
  211. package/skills/image-identify-world/skill.json +20 -20
  212. package/skills/image-identify-world/src/SKILL.md +40 -40
  213. package/skills/image-identify-world/src/pyproject.toml +8 -8
  214. package/skills/image-identify-world/src/scripts/identify_world.py +115 -115
  215. package/skills/insurance-policy-review/skill.json +27 -27
  216. package/skills/insurance-policy-review/src/SKILL.md +75 -75
  217. package/skills/insurance-sales-playbook/skill.json +20 -20
  218. package/skills/insurance-sales-playbook/src/SKILL.md +58 -58
  219. package/skills/inventory-management/skill.json +20 -20
  220. package/skills/inventory-management/src/SKILL.md +241 -241
  221. package/skills/inventory-management/src/scripts/inventory.py +1844 -1844
  222. package/skills/large-task-router/skill.json +20 -20
  223. package/skills/large-task-router/src/SKILL.md +79 -79
  224. package/skills/large-task-router/src/templates/plan.md +74 -74
  225. package/skills/lawding-contract-review/skill.json +20 -20
  226. package/skills/lawding-contract-review/src/SKILL.md +284 -284
  227. package/skills/lawding-contract-review/src/references/legal-language-library.md +1385 -1385
  228. package/skills/lawding-contract-review/src/scripts/build_reminders.py +471 -471
  229. package/skills/lawding-contract-review/src/scripts/register_contract_cron.py +457 -457
  230. package/skills/md2pdf-converter/skill.json +20 -20
  231. package/skills/md2pdf-converter/src/SKILL.md +244 -244
  232. package/skills/md2pdf-converter/src/_meta.json +5 -5
  233. package/skills/md2pdf-converter/src/scripts/generate_emoji_mapping.py +74 -74
  234. package/skills/md2pdf-converter/src/scripts/md2pdf-local.sh +291 -291
  235. package/skills/notes-hub-assistant/skill.json +20 -20
  236. package/skills/notes-hub-assistant/src/SKILL.md +233 -233
  237. package/skills/notes-hub-assistant/src/scripts/_resolve_lark_cli.py +48 -48
  238. package/skills/notes-hub-assistant/src/scripts/openclaw_meeting_minutes.py +473 -473
  239. package/skills/notes-hub-assistant/src/scripts/openclaw_notes_crud.py +596 -596
  240. package/skills/notes-hub-assistant/src/scripts/openclaw_wolai_notes_crud.py +364 -364
  241. package/skills/notes-hub-assistant/src/scripts/run_meeting_minutes.py +79 -79
  242. package/skills/notes-hub-assistant/src/scripts/run_note_crud.py +37 -37
  243. package/skills/notes-hub-assistant/src/scripts/run_notionbot.py +36 -36
  244. package/skills/notes-hub-assistant/src/scripts/run_wolai_note_crud.py +27 -27
  245. package/skills/schedule-reminder/skill.json +20 -20
  246. package/skills/schedule-reminder/src/SKILL.md +619 -619
  247. package/skills/schedule-reminder/src/schedule_template.md +68 -68
  248. package/skills/schedule-reminder/src/scripts/append_event.py +204 -204
  249. package/skills/schedule-reminder/src/scripts/create_reminders.sh +163 -163
  250. package/skills/schedule-reminder/src/scripts/daily_activate.sh +175 -175
  251. package/skills/schedule-reminder/src/scripts/parse_schedule.py +704 -704
  252. package/skills/schedule-reminder/src/scripts/setup.sh +242 -242
  253. package/skills/schedule-reminder/src//347/224/250/346/210/267/346/214/207/345/215/227.md +311 -311
  254. package/skills/sessions-analysis/skill.json +34 -34
  255. package/skills/sessions-analysis/src/SKILL.md +81 -81
  256. package/skills/sessions-analysis/src/pyproject.toml +5 -5
  257. package/skills/sessions-analysis/src/scripts/ana_logs.py +205 -205
  258. package/skills/share-skill/skill.json +20 -20
  259. package/skills/share-skill/src/SKILL.md +261 -261
  260. package/skills/share-skill/src/scripts/share_skill_to_friend.py +1031 -1031
  261. package/skills/skill-creator/skill.json +20 -20
  262. package/skills/skill-creator/src/SKILL.md +370 -370
  263. package/skills/skill-creator/src/license.txt +202 -202
  264. package/skills/skill-creator/src/scripts/init_skill.py +378 -378
  265. package/skills/skill-creator/src/scripts/package_skill.py +111 -111
  266. package/skills/skill-creator/src/scripts/quick_validate.py +101 -101
  267. package/skills/skillhub/skill.json +27 -27
  268. package/skills/skillhub/src/SKILL.md +121 -121
  269. package/skills/sophnet-age-appearance/skill.json +20 -20
  270. package/skills/sophnet-age-appearance/src/SKILL.md +83 -83
  271. package/skills/sophnet-age-appearance/src/pyproject.toml +10 -10
  272. package/skills/sophnet-age-appearance/src/scripts/age_appearance.py +395 -395
  273. package/skills/sophnet-age-appearance/src/scripts/age_face_crop.py +313 -313
  274. package/skills/sophnet-bot-client/skill.json +20 -20
  275. package/skills/sophnet-bot-client/src/SKILL.md +255 -255
  276. package/skills/sophnet-bot-client/src/pyproject.toml +13 -13
  277. package/skills/sophnet-bot-client/src/scripts/bot_client_proxy.py +165 -165
  278. package/skills/sophnet-bot-client/src/scripts/bot_client_safe.sh +29 -29
  279. package/skills/sophnet-bot-client/src/scripts/bot_client_setup.py +502 -502
  280. package/skills/sophnet-bot-client/src/tests/test_bot_client_proxy.py +255 -255
  281. package/skills/sophnet-bot-client/src/tests/test_bot_client_setup.py +679 -679
  282. package/skills/sophnet-bot-client/src/uv.lock +8 -8
  283. package/skills/sophnet-customer-management/skill.json +20 -20
  284. package/skills/sophnet-customer-management/src/SKILL.md +270 -270
  285. package/skills/sophnet-customer-management/src/pyproject.toml +15 -15
  286. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/__init__.py +2 -2
  287. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/__main__.py +5 -5
  288. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/cli.py +67 -67
  289. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/__init__.py +2 -2
  290. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/customer.py +60 -60
  291. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/export_file.py +18 -18
  292. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/import_file.py +15 -15
  293. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/reminder.py +26 -26
  294. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/commands/schema.py +28 -28
  295. package/skills/sophnet-customer-management/src/src/customer_mgmt_cli/config.py +54 -54
  296. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/__init__.py +2 -2
  297. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/exporter.py +85 -85
  298. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/models.py +84 -84
  299. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/normalizer.py +144 -144
  300. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/parser.py +241 -241
  301. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/query.py +109 -109
  302. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/reminder.py +121 -121
  303. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/repository.py +397 -397
  304. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/schema.py +106 -106
  305. package/skills/sophnet-customer-management/src/src/customer_mgmt_core/service.py +565 -565
  306. package/skills/sophnet-customer-management/src/uv.lock +48 -48
  307. package/skills/sophnet-customized-marketing/skill.json +28 -28
  308. package/skills/sophnet-customized-marketing/src/SKILL.md +144 -144
  309. package/skills/sophnet-customized-marketing/src/playbooks/campaign-planning.md +187 -187
  310. package/skills/sophnet-customized-marketing/src/playbooks/content-generation.md +124 -124
  311. package/skills/sophnet-customized-marketing/src/playbooks/marketing-calendar.md +59 -59
  312. package/skills/sophnet-customized-marketing/src/playbooks/multi-channel-bundle.md +94 -94
  313. package/skills/sophnet-customized-marketing/src/playbooks/poster-generation.md +182 -182
  314. package/skills/sophnet-customized-marketing/src/playbooks/style-profile-workflow.md +103 -103
  315. package/skills/sophnet-customized-marketing/src/pyproject.toml +8 -8
  316. package/skills/sophnet-customized-marketing/src/references/campaign-mechanics.md +168 -168
  317. package/skills/sophnet-customized-marketing/src/references/content-safety.md +26 -26
  318. package/skills/sophnet-customized-marketing/src/references/marketing-date-checklist.md +99 -99
  319. package/skills/sophnet-customized-marketing/src/references/platform-writing-guidelines.md +88 -88
  320. package/skills/sophnet-customized-marketing/src/references/quality-checklist.md +44 -44
  321. package/skills/sophnet-customized-marketing/src/scripts/generate_poster.py +572 -572
  322. package/skills/sophnet-customized-marketing/src/scripts/style_profile.py +215 -215
  323. package/skills/sophnet-dailynews/skill.json +20 -20
  324. package/skills/sophnet-dailynews/src/SKILL.md +179 -179
  325. package/skills/sophnet-dailynews/src/cache.json +150 -150
  326. package/skills/sophnet-dailynews/src/sources.json +230 -230
  327. package/skills/sophnet-docx/skill.json +20 -20
  328. package/skills/sophnet-docx/src/SKILL.md +463 -463
  329. package/skills/sophnet-docx/src/package-lock.json +208 -208
  330. package/skills/sophnet-docx/src/package.json +16 -16
  331. package/skills/sophnet-docx/src/pyproject.toml +11 -11
  332. package/skills/sophnet-docx/src/scripts/__init__.py +1 -1
  333. package/skills/sophnet-docx/src/scripts/accept_changes.py +135 -135
  334. package/skills/sophnet-docx/src/scripts/comment.py +318 -318
  335. package/skills/sophnet-docx/src/scripts/ensure_uv_env.sh +68 -68
  336. package/skills/sophnet-docx/src/scripts/office/helpers/merge_runs.py +199 -199
  337. package/skills/sophnet-docx/src/scripts/office/helpers/simplify_redlines.py +197 -197
  338. package/skills/sophnet-docx/src/scripts/office/pack.py +159 -159
  339. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
  340. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -146
  341. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
  342. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -11
  343. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
  344. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -23
  345. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -185
  346. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
  347. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
  348. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -28
  349. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -144
  350. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
  351. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -25
  352. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -18
  353. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -59
  354. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -56
  355. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -195
  356. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
  357. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -25
  358. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
  359. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
  360. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -509
  361. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -12
  362. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -108
  363. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -96
  364. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -3646
  365. package/skills/sophnet-docx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
  366. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
  367. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
  368. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
  369. package/skills/sophnet-docx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
  370. package/skills/sophnet-docx/src/scripts/office/schemas/mce/mc.xsd +75 -75
  371. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -560
  372. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -67
  373. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -14
  374. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -20
  375. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -13
  376. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -4
  377. package/skills/sophnet-docx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -8
  378. package/skills/sophnet-docx/src/scripts/office/soffice.py +183 -183
  379. package/skills/sophnet-docx/src/scripts/office/unpack.py +132 -132
  380. package/skills/sophnet-docx/src/scripts/office/validate.py +111 -111
  381. package/skills/sophnet-docx/src/scripts/office/validators/__init__.py +15 -15
  382. package/skills/sophnet-docx/src/scripts/office/validators/base.py +847 -847
  383. package/skills/sophnet-docx/src/scripts/office/validators/docx.py +446 -446
  384. package/skills/sophnet-docx/src/scripts/office/validators/pptx.py +275 -275
  385. package/skills/sophnet-docx/src/scripts/office/validators/redlining.py +247 -247
  386. package/skills/sophnet-docx/src/scripts/templates/comments.xml +3 -3
  387. package/skills/sophnet-docx/src/scripts/templates/commentsExtended.xml +3 -3
  388. package/skills/sophnet-docx/src/scripts/templates/commentsExtensible.xml +3 -3
  389. package/skills/sophnet-docx/src/scripts/templates/commentsIds.xml +3 -3
  390. package/skills/sophnet-docx/src/scripts/templates/people.xml +3 -3
  391. package/skills/sophnet-docx/src/scripts/upload_file.sh +96 -96
  392. package/skills/sophnet-docx/src/uv.lock +320 -320
  393. package/skills/sophnet-face-search/skill.json +20 -20
  394. package/skills/sophnet-face-search/src/SKILL.md +115 -115
  395. package/skills/sophnet-face-search/src/pyproject.toml +11 -11
  396. package/skills/sophnet-face-search/src/scripts/face_search.py +335 -335
  397. package/skills/sophnet-face-search/src/uv.lock +508 -508
  398. package/skills/sophnet-id-photo/skill.json +20 -20
  399. package/skills/sophnet-id-photo/src/SKILL.md +107 -107
  400. package/skills/sophnet-id-photo/src/pyproject.toml +10 -10
  401. package/skills/sophnet-id-photo/src/scripts/id_photo.py +540 -540
  402. package/skills/sophnet-id-photo/src/scripts/id_photo_compliance.py +215 -215
  403. package/skills/sophnet-id-photo/src/scripts/id_photo_face_crop.py +313 -313
  404. package/skills/sophnet-image-edit/skill.json +20 -20
  405. package/skills/sophnet-image-edit/src/SKILL.md +140 -140
  406. package/skills/sophnet-image-edit/src/pyproject.toml +9 -9
  407. package/skills/sophnet-image-edit/src/scripts/edit_and_preview.sh +68 -68
  408. package/skills/sophnet-image-edit/src/scripts/edit_image.py +279 -279
  409. package/skills/sophnet-image-edit/src/uv.lock +234 -234
  410. package/skills/sophnet-image-generate/skill.json +20 -20
  411. package/skills/sophnet-image-generate/src/SKILL.md +62 -62
  412. package/skills/sophnet-image-generate/src/pyproject.toml +9 -9
  413. package/skills/sophnet-image-generate/src/scripts/generate_image.py +156 -156
  414. package/skills/sophnet-image-generate/src/uv.lock +234 -234
  415. package/skills/sophnet-image-ocr/skill.json +20 -20
  416. package/skills/sophnet-image-ocr/src/SKILL.md +167 -167
  417. package/skills/sophnet-image-ocr/src/pyproject.toml +13 -13
  418. package/skills/sophnet-image-ocr/src/scripts/ocr.py +225 -225
  419. package/skills/sophnet-image-ocr/src/uv.lock +234 -234
  420. package/skills/sophnet-infinite-talk/skill.json +20 -20
  421. package/skills/sophnet-infinite-talk/src/SKILL.md +140 -140
  422. package/skills/sophnet-infinite-talk/src/pyproject.toml +9 -9
  423. package/skills/sophnet-infinite-talk/src/scripts/gen.py +172 -172
  424. package/skills/sophnet-oss/skill.json +27 -27
  425. package/skills/sophnet-oss/src/SKILL.md +118 -118
  426. package/skills/sophnet-oss/src/pyproject.toml +8 -8
  427. package/skills/sophnet-oss/src/scripts/upload_file.py +43 -43
  428. package/skills/sophnet-pdf/skill.json +20 -20
  429. package/skills/sophnet-pdf/src/SKILL.md +413 -413
  430. package/skills/sophnet-pdf/src/forms.md +297 -297
  431. package/skills/sophnet-pdf/src/pyproject.toml +14 -14
  432. package/skills/sophnet-pdf/src/reference.md +611 -611
  433. package/skills/sophnet-pdf/src/scripts/check_bounding_boxes.py +65 -65
  434. package/skills/sophnet-pdf/src/scripts/check_fillable_fields.py +11 -11
  435. package/skills/sophnet-pdf/src/scripts/convert_pdf_to_images.py +33 -33
  436. package/skills/sophnet-pdf/src/scripts/create_validation_image.py +37 -37
  437. package/skills/sophnet-pdf/src/scripts/enhance_tutorial.py +557 -557
  438. package/skills/sophnet-pdf/src/scripts/ensure_uv_env.sh +68 -68
  439. package/skills/sophnet-pdf/src/scripts/extract_form_field_info.py +122 -122
  440. package/skills/sophnet-pdf/src/scripts/extract_form_structure.py +115 -115
  441. package/skills/sophnet-pdf/src/scripts/extract_pdf_content.py +34 -34
  442. package/skills/sophnet-pdf/src/scripts/fill_fillable_fields.py +98 -98
  443. package/skills/sophnet-pdf/src/scripts/fill_pdf_form_with_annotations.py +107 -107
  444. package/skills/sophnet-pdf/src/scripts/upload_file.sh +88 -88
  445. package/skills/sophnet-pdf/src/uv.lock +537 -537
  446. package/skills/sophnet-qa-install/skill.json +27 -27
  447. package/skills/sophnet-qa-install/src/SKILL.md +210 -210
  448. package/skills/sophnet-qa-install/src/pyproject.toml +6 -6
  449. package/skills/sophnet-qa-install/src/scripts/backup_md.py +35 -35
  450. package/skills/sophnet-qa-install/src/scripts/check_installed.py +143 -143
  451. package/skills/sophnet-qa-install/src/scripts/update_config.py +142 -142
  452. package/skills/sophnet-qa-install/src/scripts/update_md.py +73 -73
  453. package/skills/sophnet-schedule/skill.json +20 -20
  454. package/skills/sophnet-schedule/src/ARCHITECTURE.md +321 -321
  455. package/skills/sophnet-schedule/src/IMPROVEMENTS.md +145 -145
  456. package/skills/sophnet-schedule/src/SKILL.md +1050 -1050
  457. package/skills/sophnet-schedule/src/_meta.json +6 -6
  458. package/skills/sophnet-schedule/src/api/models.py +245 -245
  459. package/skills/sophnet-schedule/src/apps/add_event.py +237 -237
  460. package/skills/sophnet-schedule/src/apps/check_reminders.py +112 -112
  461. package/skills/sophnet-schedule/src/apps/check_roc.py +246 -246
  462. package/skills/sophnet-schedule/src/apps/generate_daily_plan.py +342 -342
  463. package/skills/sophnet-schedule/src/apps/import_events.py +216 -216
  464. package/skills/sophnet-schedule/src/apps/monitor_calendar_changes.py +140 -140
  465. package/skills/sophnet-schedule/src/apps/register_tasks.py +169 -169
  466. package/skills/sophnet-schedule/src/apps/sync_roc_to_gcal.py +174 -174
  467. package/skills/sophnet-schedule/src/compat.py +66 -66
  468. package/skills/sophnet-schedule/src/config/reminder_rules.yaml +96 -96
  469. package/skills/sophnet-schedule/src/config/roc_events.yaml +44 -44
  470. package/skills/sophnet-schedule/src/config/settings.py +133 -133
  471. package/skills/sophnet-schedule/src/config/task_registry.yaml +92 -92
  472. package/skills/sophnet-schedule/src/docs/FRONTEND_INTEGRATION_GUIDE.md +437 -437
  473. package/skills/sophnet-schedule/src/gcal/client.py +374 -374
  474. package/skills/sophnet-schedule/src/gcal/models.py +91 -91
  475. package/skills/sophnet-schedule/src/requirements.txt +6 -6
  476. package/skills/sophnet-schedule/src/scripts/setup_gcal_token.py +85 -85
  477. package/skills/sophnet-schedule/src/server.py +669 -669
  478. package/skills/sophnet-schedule/src/services/calendar_backend.py +139 -139
  479. package/skills/sophnet-schedule/src/services/conflict_detector.py +96 -96
  480. package/skills/sophnet-schedule/src/services/datetime_utils.py +117 -117
  481. package/skills/sophnet-schedule/src/services/event_classifier.py +100 -100
  482. package/skills/sophnet-schedule/src/services/event_diff.py +160 -160
  483. package/skills/sophnet-schedule/src/services/google_integration.py +500 -500
  484. package/skills/sophnet-schedule/src/services/job_store.py +100 -100
  485. package/skills/sophnet-schedule/src/services/local_event_store.py +266 -266
  486. package/skills/sophnet-schedule/src/services/reminder_planner.py +116 -116
  487. package/skills/sophnet-schedule/src/services/runtime_utils.py +31 -31
  488. package/skills/sophnet-schedule/src/services/table_parser.py +286 -286
  489. package/skills/sophnet-schedule/src/services/task_builder.py +167 -167
  490. package/skills/sophnet-schedule/src/services/time_window.py +72 -72
  491. package/skills/sophnet-sticker-edit/skill.json +27 -27
  492. package/skills/sophnet-sticker-edit/src/SKILL.md +80 -80
  493. package/skills/sophnet-sticker-edit/src/pyproject.toml +9 -9
  494. package/skills/sophnet-sticker-edit/src/scripts/edit_sticker_image.py +403 -403
  495. package/skills/sophnet-stock/skill.json +20 -20
  496. package/skills/sophnet-stock/src/App-Plan.md +442 -442
  497. package/skills/sophnet-stock/src/README.md +214 -214
  498. package/skills/sophnet-stock/src/SKILL.md +236 -236
  499. package/skills/sophnet-stock/src/TODO.md +394 -394
  500. package/skills/sophnet-stock/src/_meta.json +5 -5
  501. package/skills/sophnet-stock/src/docs/ARCHITECTURE.md +408 -408
  502. package/skills/sophnet-stock/src/docs/CONCEPT.md +233 -233
  503. package/skills/sophnet-stock/src/docs/HOT_SCANNER.md +288 -288
  504. package/skills/sophnet-stock/src/docs/README.md +95 -95
  505. package/skills/sophnet-stock/src/docs/USAGE.md +465 -465
  506. package/skills/sophnet-stock/src/scripts/analyze_stock.py +2565 -2565
  507. package/skills/sophnet-stock/src/scripts/dividends.py +365 -365
  508. package/skills/sophnet-stock/src/scripts/hot_scanner.py +582 -582
  509. package/skills/sophnet-stock/src/scripts/portfolio.py +548 -548
  510. package/skills/sophnet-stock/src/scripts/rumor_scanner.py +342 -342
  511. package/skills/sophnet-stock/src/scripts/test_stock_analysis.py +409 -409
  512. package/skills/sophnet-stock/src/scripts/watchlist.py +336 -336
  513. package/skills/sophnet-training-install/skill.json +27 -27
  514. package/skills/sophnet-training-install/src/SKILL.md +211 -211
  515. package/skills/sophnet-training-install/src/pyproject.toml +6 -6
  516. package/skills/sophnet-training-install/src/scripts/backup_md.py +35 -35
  517. package/skills/sophnet-training-install/src/scripts/check_installed.py +144 -144
  518. package/skills/sophnet-training-install/src/scripts/update_config.py +142 -142
  519. package/skills/sophnet-training-install/src/scripts/update_md.py +73 -73
  520. package/skills/sophnet-tts/skill.json +20 -20
  521. package/skills/sophnet-tts/src/SKILL.md +79 -79
  522. package/skills/sophnet-tts/src/pyproject.toml +9 -9
  523. package/skills/sophnet-tts/src/scripts/gen_tts.py +130 -130
  524. package/skills/sophnet-video-generate/skill.json +37 -37
  525. package/skills/sophnet-video-generate/src/SKILL.md +117 -117
  526. package/skills/sophnet-video-generate/src/scripts/gen_video.py +321 -321
  527. package/skills/sophnet-xlsx/skill.json +20 -20
  528. package/skills/sophnet-xlsx/src/SKILL.md +399 -399
  529. package/skills/sophnet-xlsx/src/pyproject.toml +11 -11
  530. package/skills/sophnet-xlsx/src/scripts/ensure_uv_env.sh +68 -68
  531. package/skills/sophnet-xlsx/src/scripts/office/helpers/merge_runs.py +199 -199
  532. package/skills/sophnet-xlsx/src/scripts/office/helpers/simplify_redlines.py +197 -197
  533. package/skills/sophnet-xlsx/src/scripts/office/pack.py +159 -159
  534. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chart.xsd +1499 -1499
  535. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-chartDrawing.xsd +146 -146
  536. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-diagram.xsd +1085 -1085
  537. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-lockedCanvas.xsd +11 -11
  538. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-main.xsd +3081 -3081
  539. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-picture.xsd +23 -23
  540. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-spreadsheetDrawing.xsd +185 -185
  541. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/dml-wordprocessingDrawing.xsd +287 -287
  542. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/pml.xsd +1676 -1676
  543. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-additionalCharacteristics.xsd +28 -28
  544. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-bibliography.xsd +144 -144
  545. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-commonSimpleTypes.xsd +174 -174
  546. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlDataProperties.xsd +25 -25
  547. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-customXmlSchemaProperties.xsd +18 -18
  548. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesCustom.xsd +59 -59
  549. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesExtended.xsd +56 -56
  550. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-documentPropertiesVariantTypes.xsd +195 -195
  551. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-math.xsd +582 -582
  552. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/shared-relationshipReference.xsd +25 -25
  553. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/sml.xsd +4439 -4439
  554. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-main.xsd +570 -570
  555. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-officeDrawing.xsd +509 -509
  556. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-presentationDrawing.xsd +12 -12
  557. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-spreadsheetDrawing.xsd +108 -108
  558. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/vml-wordprocessingDrawing.xsd +96 -96
  559. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/wml.xsd +3646 -3646
  560. package/skills/sophnet-xlsx/src/scripts/office/schemas/ISO-IEC29500-4_2016/xml.xsd +116 -116
  561. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-contentTypes.xsd +42 -42
  562. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-coreProperties.xsd +50 -50
  563. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-digSig.xsd +49 -49
  564. package/skills/sophnet-xlsx/src/scripts/office/schemas/ecma/fouth-edition/opc-relationships.xsd +33 -33
  565. package/skills/sophnet-xlsx/src/scripts/office/schemas/mce/mc.xsd +75 -75
  566. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2010.xsd +560 -560
  567. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2012.xsd +67 -67
  568. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-2018.xsd +14 -14
  569. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cex-2018.xsd +20 -20
  570. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-cid-2016.xsd +13 -13
  571. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-sdtdatahash-2020.xsd +4 -4
  572. package/skills/sophnet-xlsx/src/scripts/office/schemas/microsoft/wml-symex-2015.xsd +8 -8
  573. package/skills/sophnet-xlsx/src/scripts/office/soffice.py +183 -183
  574. package/skills/sophnet-xlsx/src/scripts/office/unpack.py +132 -132
  575. package/skills/sophnet-xlsx/src/scripts/office/validate.py +111 -111
  576. package/skills/sophnet-xlsx/src/scripts/office/validators/__init__.py +15 -15
  577. package/skills/sophnet-xlsx/src/scripts/office/validators/base.py +847 -847
  578. package/skills/sophnet-xlsx/src/scripts/office/validators/docx.py +446 -446
  579. package/skills/sophnet-xlsx/src/scripts/office/validators/pptx.py +275 -275
  580. package/skills/sophnet-xlsx/src/scripts/office/validators/redlining.py +247 -247
  581. package/skills/sophnet-xlsx/src/scripts/recalc.py +184 -184
  582. package/skills/sophnet-xlsx/src/scripts/upload_file.sh +96 -96
  583. package/skills/sophnet-xlsx/src/uv.lock +319 -319
  584. package/skills/ui-ux-pro-max/skill.json +20 -20
  585. package/skills/ui-ux-pro-max/src/SKILL.md +377 -377
  586. package/skills/ui-ux-pro-max/src/data/icons.csv +101 -101
  587. package/skills/ui-ux-pro-max/src/data/react-performance.csv +45 -45
  588. package/skills/ui-ux-pro-max/src/data/stacks/astro.csv +54 -54
  589. package/skills/ui-ux-pro-max/src/data/stacks/jetpack-compose.csv +53 -53
  590. package/skills/ui-ux-pro-max/src/data/stacks/nuxt-ui.csv +51 -51
  591. package/skills/ui-ux-pro-max/src/data/stacks/nuxtjs.csv +59 -59
  592. package/skills/ui-ux-pro-max/src/data/stacks/shadcn.csv +61 -61
  593. package/skills/ui-ux-pro-max/src/data/typography.csv +57 -57
  594. package/skills/ui-ux-pro-max/src/data/ui-reasoning.csv +101 -101
  595. package/skills/ui-ux-pro-max/src/data/web-interface.csv +31 -31
  596. package/skills/ui-ux-pro-max/src/scripts/core.py +253 -253
  597. package/skills/ui-ux-pro-max/src/scripts/design_system.py +1067 -1067
  598. package/skills/video-understand/skill.json +20 -20
  599. package/skills/video-understand/src/SKILL.md +79 -79
  600. package/skills/video-understand/src/scripts/video_understand.py +204 -204
  601. package/skills/weather/skill.json +19 -19
  602. package/skills/weather/src/SKILL.md +112 -112
  603. package/skills/web-scraper/skill.json +20 -20
  604. package/skills/web-scraper/src/SKILL.md +101 -101
  605. package/skills/web-scraper/src/scripts/scrape.py +270 -270
  606. package/skills/website-builder/skill.json +20 -20
  607. package/skills/website-builder/src/SKILL.md +266 -266
  608. package/skills/website-builder/src/scripts/deploy_site.sh +46 -46
  609. package/skills/wechat-article-publisher/skill.json +20 -20
  610. package/skills/wechat-article-publisher/src/SKILL.md +60 -60
  611. package/skills/wechat-article-publisher/src/config.json +6 -6
  612. package/skills/wechat-article-publisher/src/pyproject.toml +12 -12
  613. package/skills/wechat-article-publisher/src/scripts/publish_wechat.py +825 -825
  614. package/skills/xiaohongshu/skill.json +20 -20
  615. package/skills/xiaohongshu/src/SKILL.md +91 -91
  616. package/skills/xiaohongshu/src/_meta.json +5 -5
  617. package/skills/xiaohongshu/src/assets/card.html +216 -216
  618. package/skills/xiaohongshu/src/assets/cover.html +82 -82
  619. package/skills/xiaohongshu/src/assets/example.md +84 -84
  620. package/skills/xiaohongshu/src/assets/styles.css +318 -318
  621. package/skills/xiaohongshu/src/scripts/render_xhs_v2.py +737 -737
  622. package/skills/xiaohongshu/src/scripts/sign_server.py +158 -158
  623. package/skills/xiaohongshu/src/scripts/stealth.min.js +6 -6
  624. package/skills/xiaohongshu/src/scripts/xhs_tool.py +186 -186
  625. package/skills/xiaohongshu/src/workflow.py +185 -185
  626. package/src/commands/agent.js +112 -112
  627. package/src/commands/download.js +101 -101
  628. package/src/commands/info.js +58 -58
  629. package/src/commands/list.js +71 -71
  630. package/src/utils/agents.js +36 -36
  631. package/src/utils/config.js +22 -22
  632. package/src/utils/paths.js +31 -31
  633. 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()