@11agents/cli 0.1.24 → 0.1.26

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 (71) hide show
  1. package/README.md +52 -0
  2. package/bin/11agents.js +12 -0
  3. package/mobile-runtime/README.md +19 -0
  4. package/mobile-runtime/configs/platforms/xiaohongshu_d01.json +73 -0
  5. package/mobile-runtime/configs/platforms/xiaohongshu_d02.json +70 -0
  6. package/mobile-runtime/configs/platforms/xiaohongshu_d03.json +73 -0
  7. package/mobile-runtime/configs/publish_policy.json +40 -0
  8. package/mobile-runtime/data-templates/README.md +4 -0
  9. package/mobile-runtime/data-templates/accounts.example.csv +6 -0
  10. package/mobile-runtime/data-templates/devices.example.csv +2 -0
  11. package/mobile-runtime/data-templates/publish_records.example.jsonl +2 -0
  12. package/mobile-runtime/data-templates/tasks.example.jsonl +5 -0
  13. package/mobile-runtime/data-templates/video_metrics.example.jsonl +1 -0
  14. package/mobile-runtime/python/pyproject.toml +34 -0
  15. package/mobile-runtime/python/src/device_control/__init__.py +5 -0
  16. package/mobile-runtime/python/src/device_control/adapters/__init__.py +31 -0
  17. package/mobile-runtime/python/src/device_control/adapters/base.py +43 -0
  18. package/mobile-runtime/python/src/device_control/adapters/facebook.py +30 -0
  19. package/mobile-runtime/python/src/device_control/adapters/instagram.py +25 -0
  20. package/mobile-runtime/python/src/device_control/adapters/reddit.py +29 -0
  21. package/mobile-runtime/python/src/device_control/adapters/tiktok.py +25 -0
  22. package/mobile-runtime/python/src/device_control/adapters/x.py +29 -0
  23. package/mobile-runtime/python/src/device_control/adapters/xiaohongshu.py +26 -0
  24. package/mobile-runtime/python/src/device_control/adb.py +161 -0
  25. package/mobile-runtime/python/src/device_control/appium_client.py +131 -0
  26. package/mobile-runtime/python/src/device_control/appium_manager.py +403 -0
  27. package/mobile-runtime/python/src/device_control/cli.py +1608 -0
  28. package/mobile-runtime/python/src/device_control/entrypoints.py +60 -0
  29. package/mobile-runtime/python/src/device_control/locks.py +162 -0
  30. package/mobile-runtime/python/src/device_control/metrics/__init__.py +33 -0
  31. package/mobile-runtime/python/src/device_control/metrics/collectors.py +320 -0
  32. package/mobile-runtime/python/src/device_control/metrics/tiktok_account_adb.py +367 -0
  33. package/mobile-runtime/python/src/device_control/metrics/tiktok_video_adb.py +714 -0
  34. package/mobile-runtime/python/src/device_control/models.py +439 -0
  35. package/mobile-runtime/python/src/device_control/publish_policy.py +173 -0
  36. package/mobile-runtime/python/src/device_control/publishers/__init__.py +24 -0
  37. package/mobile-runtime/python/src/device_control/publishers/facebook_adb.py +494 -0
  38. package/mobile-runtime/python/src/device_control/publishers/instagram_adb.py +663 -0
  39. package/mobile-runtime/python/src/device_control/publishers/reddit_adb.py +595 -0
  40. package/mobile-runtime/python/src/device_control/publishers/tiktok_adb.py +477 -0
  41. package/mobile-runtime/python/src/device_control/publishers/tiktok_appium.py +259 -0
  42. package/mobile-runtime/python/src/device_control/publishers/ui_helpers.py +372 -0
  43. package/mobile-runtime/python/src/device_control/publishers/x_adb.py +636 -0
  44. package/mobile-runtime/python/src/device_control/publishers/xiaohongshu_adb.py +1143 -0
  45. package/mobile-runtime/python/src/device_control/store.py +137 -0
  46. package/mobile-runtime/scripts/appium_smoke.py +71 -0
  47. package/mobile-runtime/skills/android-collect-tiktok-metrics/SKILL.md +60 -0
  48. package/mobile-runtime/skills/android-collect-tiktok-metrics/agents/openai.yaml +4 -0
  49. package/mobile-runtime/skills/android-group-control-cli/SKILL.md +76 -0
  50. package/mobile-runtime/skills/android-group-control-cli/agents/openai.yaml +4 -0
  51. package/mobile-runtime/skills/android-group-control-cli/references/command-reference.md +122 -0
  52. package/mobile-runtime/skills/android-publish-facebook/SKILL.md +41 -0
  53. package/mobile-runtime/skills/android-publish-facebook/agents/openai.yaml +4 -0
  54. package/mobile-runtime/skills/android-publish-instagram/SKILL.md +45 -0
  55. package/mobile-runtime/skills/android-publish-instagram/agents/openai.yaml +4 -0
  56. package/mobile-runtime/skills/android-publish-reddit/SKILL.md +41 -0
  57. package/mobile-runtime/skills/android-publish-reddit/agents/openai.yaml +4 -0
  58. package/mobile-runtime/skills/android-publish-tiktok/SKILL.md +43 -0
  59. package/mobile-runtime/skills/android-publish-tiktok/agents/openai.yaml +4 -0
  60. package/mobile-runtime/skills/android-publish-x/SKILL.md +40 -0
  61. package/mobile-runtime/skills/android-publish-x/agents/openai.yaml +4 -0
  62. package/mobile-runtime/skills/android-publish-xiaohongshu/SKILL.md +50 -0
  63. package/mobile-runtime/skills/android-publish-xiaohongshu/agents/openai.yaml +4 -0
  64. package/mobile-runtime/skills/mobile-publish-data-collection/SKILL.md +49 -0
  65. package/mobile-runtime/skills/mobile-publish-device-health/SKILL.md +47 -0
  66. package/mobile-runtime/skills/mobile-publish-execution/SKILL.md +57 -0
  67. package/mobile-runtime/skills/mobile-publish-records/SKILL.md +29 -0
  68. package/package.json +4 -1
  69. package/scripts/mobile-postinstall.js +26 -0
  70. package/src/commands/mobile.js +695 -0
  71. package/src/commands/runtime.js +21 -5
@@ -0,0 +1,494 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ import xml.etree.ElementTree as ET
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+ from zoneinfo import ZoneInfo
10
+
11
+ from device_control.adb import AdbClient
12
+ from device_control.models import Device, PublishRecord, utc_now_iso
13
+ from device_control.publishers import ui_helpers as ui
14
+ from device_control.publishers.tiktok_adb import TikTokAdbPublisher
15
+ from device_control.store import append_publish_record
16
+
17
+
18
+ FACEBOOK_LITE_PACKAGE = "com.facebook.lite"
19
+
20
+
21
+ class NeedsHumanError(RuntimeError):
22
+ pass
23
+
24
+
25
+ @dataclass
26
+ class FacebookAdbPublishResult:
27
+ device_id: str
28
+ status: str
29
+ record_id: str
30
+ started_at: str
31
+ ended_at: str
32
+ duration_seconds: int
33
+ post_type: str
34
+ text: str
35
+ screenshot_path: str
36
+ remote_media_path: str = ""
37
+ link_url: str = ""
38
+ error: str = ""
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class VisualPoint:
43
+ x: int
44
+ y: int
45
+ confidence: float
46
+ source: str
47
+
48
+
49
+ class FacebookAdbPublisher:
50
+ """Facebook Lite text-post publisher for the D03 POC layout."""
51
+
52
+ def __init__(
53
+ self,
54
+ adb: AdbClient,
55
+ *,
56
+ records_path: str | Path = "data/publish_records.jsonl",
57
+ artifact_root: str | Path = "artifacts/screenshots",
58
+ ) -> None:
59
+ self.adb = adb
60
+ self.records_path = Path(records_path)
61
+ self.artifact_root = Path(artifact_root)
62
+ self.media_helper = TikTokAdbPublisher(adb, records_path=records_path, artifact_root=artifact_root)
63
+
64
+ def publish_text(
65
+ self,
66
+ device: Device,
67
+ *,
68
+ text: str,
69
+ account_id: str,
70
+ dry_run: bool = False,
71
+ app_package: str = FACEBOOK_LITE_PACKAGE,
72
+ ) -> FacebookAdbPublishResult:
73
+ return self.publish_post(
74
+ device,
75
+ post_type="text",
76
+ text=text,
77
+ account_id=account_id,
78
+ dry_run=dry_run,
79
+ app_package=app_package,
80
+ )
81
+
82
+ def publish_post(
83
+ self,
84
+ device: Device,
85
+ *,
86
+ post_type: str,
87
+ text: str = "",
88
+ account_id: str,
89
+ media_path: str | Path | None = None,
90
+ link_url: str = "",
91
+ dry_run: bool = False,
92
+ app_package: str = FACEBOOK_LITE_PACKAGE,
93
+ ) -> FacebookAdbPublishResult:
94
+ post_type = _normalize_post_type(post_type)
95
+ local: Path | None = Path(media_path).expanduser() if media_path else None
96
+ if post_type in {"image", "video"}:
97
+ if local is None or not local.exists():
98
+ raise FileNotFoundError(f"media not found: {media_path}")
99
+ if ui.media_kind_from_path(local) != post_type:
100
+ raise ValueError(f"post_type={post_type} does not match media file suffix: {local}")
101
+ if post_type == "link":
102
+ _require_adb_text_safe(link_url, "link_url")
103
+ if post_type == "text":
104
+ _require_adb_text_safe(text, "text")
105
+ else:
106
+ _require_adb_text_safe(text, "text", required=False)
107
+ publish_text = _facebook_text_body(text=text, link_url=link_url)
108
+
109
+ started_epoch = int(time.time())
110
+ started_at = _now_shanghai()
111
+ stamp = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y%m%d-%H%M%S")
112
+ run_dir = self.artifact_root / f"{device.device_id}-facebook-{stamp}"
113
+ run_dir.mkdir(parents=True, exist_ok=True)
114
+ record_id = f"pub_{stamp.replace('-', '')}_{device.device_id.lower()}_facebook"
115
+ remote = ""
116
+ if local is not None:
117
+ remote = f"/sdcard/DCIM/Camera/groupctl-{device.device_id}-facebook-{stamp}{local.suffix}"
118
+
119
+ try:
120
+ self._prepare_device(device)
121
+ if local is not None:
122
+ self.media_helper._push_media(device, local, remote)
123
+ self._open_home(device, app_package, run_dir)
124
+ self._open_composer(device, run_dir)
125
+ if publish_text:
126
+ self._input_text(device, publish_text, run_dir)
127
+ if local is not None:
128
+ self._attach_media(device, run_dir, post_type)
129
+
130
+ if dry_run:
131
+ final_shot = run_dir / "dry-run-post-form.png"
132
+ self.adb.screenshot(device.adb_serial, final_shot)
133
+ status = "dry_run"
134
+ else:
135
+ self._tap_post(device, run_dir)
136
+ self._wait_for_publish_complete(device, run_dir)
137
+ final_shot = run_dir / "after-post.png"
138
+ self.adb.screenshot(device.adb_serial, final_shot)
139
+ status = "published"
140
+ append_publish_record(
141
+ self.records_path,
142
+ PublishRecord(
143
+ record_id=record_id,
144
+ platform="facebook",
145
+ account_id=account_id,
146
+ device_id=device.device_id,
147
+ post_type=post_type,
148
+ local_media_path=str(local) if local else "",
149
+ remote_media_path=remote,
150
+ caption=publish_text,
151
+ published_at=utc_now_iso(),
152
+ result_screenshot_path=str(final_shot),
153
+ status="published",
154
+ ),
155
+ )
156
+
157
+ return FacebookAdbPublishResult(
158
+ device_id=device.device_id,
159
+ status=status,
160
+ record_id=record_id,
161
+ started_at=started_at,
162
+ ended_at=_now_shanghai(),
163
+ duration_seconds=int(time.time()) - started_epoch,
164
+ post_type=post_type,
165
+ text=publish_text,
166
+ remote_media_path=remote,
167
+ link_url=link_url,
168
+ screenshot_path=str(final_shot),
169
+ )
170
+ except NeedsHumanError as exc:
171
+ return self._failure_result(
172
+ device,
173
+ run_dir,
174
+ record_id,
175
+ started_at,
176
+ started_epoch,
177
+ publish_text,
178
+ post_type=post_type,
179
+ remote_media_path=remote,
180
+ link_url=link_url,
181
+ status="needs_human",
182
+ error=str(exc),
183
+ )
184
+ except Exception as exc:
185
+ return self._failure_result(
186
+ device,
187
+ run_dir,
188
+ record_id,
189
+ started_at,
190
+ started_epoch,
191
+ publish_text,
192
+ post_type=post_type,
193
+ remote_media_path=remote,
194
+ link_url=link_url,
195
+ status="failed",
196
+ error=str(exc),
197
+ )
198
+
199
+ def _failure_result(
200
+ self,
201
+ device: Device,
202
+ run_dir: Path,
203
+ record_id: str,
204
+ started_at: str,
205
+ started_epoch: int,
206
+ text: str,
207
+ *,
208
+ post_type: str,
209
+ remote_media_path: str = "",
210
+ link_url: str = "",
211
+ status: str,
212
+ error: str,
213
+ ) -> FacebookAdbPublishResult:
214
+ failure_shot = run_dir / f"{status}.png"
215
+ self.adb.screenshot(device.adb_serial, failure_shot)
216
+ return FacebookAdbPublishResult(
217
+ device_id=device.device_id,
218
+ status=status,
219
+ record_id=record_id,
220
+ started_at=started_at,
221
+ ended_at=_now_shanghai(),
222
+ duration_seconds=int(time.time()) - started_epoch,
223
+ post_type=post_type,
224
+ text=text,
225
+ remote_media_path=remote_media_path,
226
+ link_url=link_url,
227
+ screenshot_path=str(failure_shot),
228
+ error=error,
229
+ )
230
+
231
+ def _prepare_device(self, device: Device) -> None:
232
+ self._ok(self.adb.wake(device.adb_serial), "wake device")
233
+ time.sleep(1)
234
+ self.adb.swipe(device.adb_serial, 540, 1850, 540, 550, 350)
235
+ time.sleep(1)
236
+
237
+ def _open_home(self, device: Device, app_package: str, run_dir: Path) -> None:
238
+ ui.grant_publish_permissions(self.adb, device, app_package)
239
+ self.adb.shell(device.adb_serial, "am", "force-stop", app_package)
240
+ time.sleep(1)
241
+ self._ok(self.adb.launch_package(device.adb_serial, app_package), f"launch {app_package}")
242
+ time.sleep(7)
243
+ self.adb.screenshot(device.adb_serial, run_dir / "home.png")
244
+ if app_package not in self.adb.current_focus(device.adb_serial):
245
+ raise NeedsHumanError(f"Facebook is not foreground after launch; check install/login state for {app_package}")
246
+
247
+ def _open_composer(self, device: Device, run_dir: Path) -> None:
248
+ self._tap(device, 95, 180, 6)
249
+ home_shot = run_dir / "home-ready.png"
250
+ self.adb.screenshot(device.adb_serial, home_shot)
251
+ composer_entry = _locate_home_composer(home_shot) or VisualPoint(540, 480, 0.0, "fallback")
252
+ (run_dir / "composer-entry.txt").write_text(
253
+ f"x={composer_entry.x} y={composer_entry.y} confidence={composer_entry.confidence:.3f} source={composer_entry.source}\n",
254
+ encoding="utf-8",
255
+ )
256
+ self._tap(device, composer_entry.x, composer_entry.y, 4)
257
+ composer_shot = run_dir / "composer.png"
258
+ self.adb.screenshot(device.adb_serial, composer_shot)
259
+ if not self._is_compose_form(device, run_dir / "composer.xml") and not _locate_blue_post_button(composer_shot):
260
+ raise NeedsHumanError("Facebook composer not detected; check login, verification, prompts, or layout calibration")
261
+
262
+ def _input_text(self, device: Device, text: str, run_dir: Path) -> None:
263
+ self._tap(device, 170, 520, 1)
264
+ result = self.adb.input_text(device.adb_serial, text)
265
+ if not result.ok:
266
+ self.adb.screenshot(device.adb_serial, run_dir / "text-input-failed.png")
267
+ raise RuntimeError(f"text input failed: {result.stderr or result.stdout}")
268
+ time.sleep(1)
269
+ self.adb.keyevent(device.adb_serial, "KEYCODE_BACK")
270
+ time.sleep(1)
271
+ self.adb.screenshot(device.adb_serial, run_dir / "after-text.png")
272
+
273
+ def _attach_media(self, device: Device, run_dir: Path, post_type: str) -> None:
274
+ self._tap(device, 220, 1225, 3)
275
+ ui.tap_permission_prompt_if_present(self.adb, device, run_dir / "media-permission.xml")
276
+ picker_xml = run_dir / "media-picker.xml"
277
+ root = self._dump_optional(device, picker_xml)
278
+ if root is not None:
279
+ nodes = ui.sorted_visible_nodes(
280
+ ui.find_nodes(root, ids={"com.facebook.lite:id/gallery_item_image"}, id_suffixes={"gallery_item_image"})
281
+ )
282
+ if nodes:
283
+ ui.tap_node(self.adb, device, nodes[0], sleep_s=3)
284
+ self.adb.screenshot(device.adb_serial, run_dir / "after-media-select.png")
285
+ return
286
+ self.adb.screenshot(device.adb_serial, run_dir / "media-picker-not-found.png")
287
+ raise NeedsHumanError(f"Facebook {post_type} media picker did not expose a selectable gallery item")
288
+
289
+ def _tap_post(self, device: Device, run_dir: Path) -> None:
290
+ post_shot = run_dir / "before-post-locator.png"
291
+ self.adb.screenshot(device.adb_serial, post_shot)
292
+ post_button = _locate_blue_post_button(post_shot)
293
+ (run_dir / "post-button.txt").write_text(
294
+ (
295
+ f"x={post_button.x} y={post_button.y} confidence={post_button.confidence:.3f} source={post_button.source}\n"
296
+ if post_button
297
+ else "not_found\n"
298
+ ),
299
+ encoding="utf-8",
300
+ )
301
+ point = post_button or VisualPoint(540, 1845, 0.0, "fallback")
302
+ self._tap(device, point.x, point.y, 0)
303
+
304
+ def _wait_for_publish_complete(self, device: Device, run_dir: Path) -> None:
305
+ last_xml = run_dir / "after-post-poll.xml"
306
+ time.sleep(8)
307
+ for attempt in range(8):
308
+ time.sleep(3)
309
+ poll_shot = run_dir / f"after-post-poll-{attempt + 1}.png"
310
+ self.adb.screenshot(device.adb_serial, poll_shot)
311
+ if not self._is_compose_form(device, last_xml) and not _locate_blue_post_button(poll_shot):
312
+ return
313
+ raise RuntimeError("Facebook still appears to be on the composer after tapping POST")
314
+
315
+ def _is_compose_form(self, device: Device, xml_path: Path) -> bool:
316
+ dump = self.adb.dump_ui(device.adb_serial, xml_path)
317
+ if not dump.ok:
318
+ return False
319
+ try:
320
+ root = ET.parse(xml_path).getroot()
321
+ except ET.ParseError:
322
+ return False
323
+ for node in root.iter("node"):
324
+ if node.attrib.get("clickable") != "true":
325
+ continue
326
+ bounds = node.attrib.get("bounds", "")
327
+ match = re.fullmatch(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]", bounds)
328
+ if not match:
329
+ continue
330
+ left, top, right, bottom = [int(part) for part in match.groups()]
331
+ if left <= 70 and right >= 1000 and 1760 <= top <= 1820 and 1870 <= bottom <= 1925:
332
+ return True
333
+ return False
334
+
335
+ def _dump_optional(self, device: Device, xml_path: Path) -> ET.Element | None:
336
+ dump = self.adb.dump_ui(device.adb_serial, xml_path)
337
+ if not dump.ok:
338
+ return None
339
+ try:
340
+ return ET.parse(xml_path).getroot()
341
+ except ET.ParseError:
342
+ return None
343
+
344
+ def _tap(self, device: Device, x: int, y: int, sleep_s: int) -> None:
345
+ self._ok(self.adb.tap(device.adb_serial, x, y), f"tap {x},{y}")
346
+ if sleep_s:
347
+ time.sleep(sleep_s)
348
+
349
+ @staticmethod
350
+ def _ok(result, action: str) -> None:
351
+ if not result.ok:
352
+ raise RuntimeError(f"{action} failed: {result.stderr or result.stdout}")
353
+
354
+
355
+ def _require_adb_text_safe(value: str, field_name: str, *, required: bool = True) -> None:
356
+ if required and not value.strip():
357
+ raise ValueError(f"{field_name} is required")
358
+ if not value:
359
+ return
360
+ if "\n" in value or "\r" in value or "\t" in value:
361
+ raise ValueError(f"{field_name} must be a single line for the current Facebook Lite POC")
362
+ if not value.isascii():
363
+ raise ValueError(f"{field_name} must be ASCII for the current Facebook Lite POC")
364
+
365
+
366
+ def _normalize_post_type(value: str) -> str:
367
+ text = value.strip().lower().replace("_", "-")
368
+ if text in {"text", "link", "image", "photo", "video"}:
369
+ return "image" if text == "photo" else text
370
+ raise ValueError("Facebook post_type must be one of: text, link, image, video")
371
+
372
+
373
+ def _facebook_text_body(*, text: str, link_url: str) -> str:
374
+ if text and link_url:
375
+ return f"{text} {link_url}"
376
+ return text or link_url
377
+
378
+
379
+ def _locate_home_composer(image_path: Path) -> VisualPoint | None:
380
+ return _locate_wide_band(
381
+ image_path,
382
+ x_range=(0.12, 0.9),
383
+ y_range=(0.14, 0.32),
384
+ min_width_ratio=0.42,
385
+ color_predicate=_is_light_neutral_gray,
386
+ source="visual_home_composer",
387
+ )
388
+
389
+
390
+ def _locate_blue_post_button(image_path: Path) -> VisualPoint | None:
391
+ return _locate_wide_band(
392
+ image_path,
393
+ x_range=(0.02, 0.98),
394
+ y_range=(0.6, 0.9),
395
+ min_width_ratio=0.58,
396
+ color_predicate=_is_facebook_blue,
397
+ source="visual_post_button",
398
+ )
399
+
400
+
401
+ def _locate_wide_band(
402
+ image_path: Path,
403
+ *,
404
+ x_range: tuple[float, float],
405
+ y_range: tuple[float, float],
406
+ min_width_ratio: float,
407
+ color_predicate,
408
+ source: str,
409
+ ) -> VisualPoint | None:
410
+ try:
411
+ from PIL import Image
412
+ except Exception:
413
+ return None
414
+
415
+ try:
416
+ image = Image.open(image_path).convert("RGB")
417
+ except Exception:
418
+ return None
419
+
420
+ width, height = image.size
421
+ pixels = image.load()
422
+ x0 = max(0, int(width * x_range[0]))
423
+ x1 = min(width, int(width * x_range[1]))
424
+ y0 = max(0, int(height * y_range[0]))
425
+ y1 = min(height, int(height * y_range[1]))
426
+ min_width = int(width * min_width_ratio)
427
+
428
+ rows: list[tuple[int, int, int, int]] = []
429
+ for y in range(y0, y1, 2):
430
+ best_start = -1
431
+ best_end = -1
432
+ run_start = -1
433
+ for x in range(x0, x1):
434
+ if color_predicate(pixels[x, y]):
435
+ if run_start < 0:
436
+ run_start = x
437
+ continue
438
+ if run_start >= 0:
439
+ if x - run_start > best_end - best_start:
440
+ best_start = run_start
441
+ best_end = x
442
+ run_start = -1
443
+ if run_start >= 0 and x1 - run_start > best_end - best_start:
444
+ best_start = run_start
445
+ best_end = x1
446
+ best_width = best_end - best_start
447
+ if best_width >= min_width:
448
+ rows.append((y, best_start, best_end, best_width))
449
+
450
+ if not rows:
451
+ return None
452
+
453
+ groups: list[list[tuple[int, int, int, int]]] = []
454
+ for row in rows:
455
+ if not groups or row[0] - groups[-1][-1][0] > 4:
456
+ groups.append([row])
457
+ else:
458
+ groups[-1].append(row)
459
+
460
+ best_group: list[tuple[int, int, int, int]] | None = None
461
+ best_score = 0
462
+ for group in groups:
463
+ band_height = group[-1][0] - group[0][0] + 2
464
+ avg_width = sum(row[3] for row in group) / len(group)
465
+ if band_height < 20:
466
+ continue
467
+ score = int(avg_width * band_height)
468
+ if score > best_score:
469
+ best_score = score
470
+ best_group = group
471
+
472
+ if not best_group:
473
+ return None
474
+
475
+ weighted_x = sum(((row[1] + row[2]) / 2) * row[3] for row in best_group)
476
+ total_width = sum(row[3] for row in best_group)
477
+ center_x = int(weighted_x / total_width)
478
+ center_y = int((best_group[0][0] + best_group[-1][0]) / 2)
479
+ confidence = min(1.0, best_score / max(1, (width * (y1 - y0) * 0.2)))
480
+ return VisualPoint(center_x, center_y, confidence, source)
481
+
482
+
483
+ def _is_light_neutral_gray(rgb: tuple[int, int, int]) -> bool:
484
+ red, green, blue = rgb
485
+ return 220 <= red <= 248 and 220 <= green <= 248 and 220 <= blue <= 248 and max(rgb) - min(rgb) <= 10
486
+
487
+
488
+ def _is_facebook_blue(rgb: tuple[int, int, int]) -> bool:
489
+ red, green, blue = rgb
490
+ return red <= 80 and 70 <= green <= 170 and blue >= 180 and blue - red >= 110 and blue - green >= 50
491
+
492
+
493
+ def _now_shanghai() -> str:
494
+ return datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S %z")