feat: complete production workflow migration
This commit is contained in:
+111
-19
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
|
||||
from gyxx_flow.catalog import CatalogError, WorkflowCatalog
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.script_catalog import ScriptCatalog
|
||||
from gyxx_flow.script_catalog import ScriptCatalog, ScriptCatalogError
|
||||
from gyxx_flow.security import scan_repository
|
||||
|
||||
_CHECKLIST = re.compile(r"^\s*-\s*\[([ xX])\]\s+(P\d+\.\d+)\b", re.MULTILINE)
|
||||
@@ -61,38 +61,101 @@ def parse_plan_checklist(path: Path) -> tuple[PlanItem, ...]:
|
||||
|
||||
def build_acceptance_report(settings: Settings) -> AcceptanceReport:
|
||||
project_root = settings.project_root
|
||||
items = parse_plan_checklist(project_root / "plan.md")
|
||||
items = parse_plan_checklist(project_root / "docs" / "plan.md")
|
||||
checks = {
|
||||
"catalog_21_tasks": _catalog_has_21_tasks(project_root),
|
||||
"baseline_21_tasks": _baseline_has_21_tasks(settings.data_root),
|
||||
"catalog_22_tasks": _catalog_has_22_tasks(project_root),
|
||||
"scheduled_graphs_explicit": _scheduled_graphs_are_explicit(project_root),
|
||||
"python_scheduler_only": _python_scheduler_is_the_only_scheduler(project_root),
|
||||
"runtime_service_policy": _runtime_service_policy_is_configured(project_root),
|
||||
"native_entrypoints_local": _native_entrypoints_are_local(project_root),
|
||||
"runtime_sources_decoupled": _runtime_sources_are_decoupled(project_root),
|
||||
"runnable_script_catalog": _runnable_script_catalog_is_complete(),
|
||||
"public_command_registry_complete": _public_command_registry_is_complete(
|
||||
project_root
|
||||
),
|
||||
"source_manifests_verified": _source_manifests_are_verified(project_root),
|
||||
"secret_scan_clean": not scan_repository(project_root),
|
||||
}
|
||||
return AcceptanceReport(items, checks)
|
||||
|
||||
|
||||
def _catalog_has_21_tasks(project_root: Path) -> bool:
|
||||
def _catalog_has_22_tasks(project_root: Path) -> bool:
|
||||
try:
|
||||
catalog = WorkflowCatalog.load(project_root / "config")
|
||||
except Exception:
|
||||
return False
|
||||
scheduled = catalog.scheduled_workflows()
|
||||
return len(scheduled) == 21 and len(catalog.schedules) == 21
|
||||
return (
|
||||
len(scheduled) == 23
|
||||
and len(catalog.schedules) == 23
|
||||
and sum(schedule.enabled for schedule in catalog.schedules) == 23
|
||||
)
|
||||
|
||||
|
||||
def _baseline_has_21_tasks(data_root: Path) -> bool:
|
||||
candidates = sorted((Path(data_root) / "baseline").glob("*/manifest.json"))
|
||||
if not candidates:
|
||||
return False
|
||||
def _scheduled_graphs_are_explicit(project_root: Path) -> bool:
|
||||
try:
|
||||
payload = json.loads(candidates[-1].read_text(encoding="utf-8"))
|
||||
tasks = payload["scheduled_tasks"]
|
||||
return tasks["actual_count"] == 21 and len(tasks["tasks"]) == 21
|
||||
catalog = WorkflowCatalog.load(project_root / "config")
|
||||
except Exception:
|
||||
return False
|
||||
return all(workflow.steps for workflow in catalog.scheduled_workflows())
|
||||
|
||||
|
||||
def _python_scheduler_is_the_only_scheduler(project_root: Path) -> bool:
|
||||
scheduler_service = project_root / "src" / "gyxx_flow" / "scheduler_service.py"
|
||||
retired_windows_scheduler = project_root / "src" / "gyxx_flow" / "scheduler.py"
|
||||
systemd_unit = project_root / "deploy" / "gyxx-flow.service"
|
||||
legacy_installer = project_root / "deploy" / "windows-service" / "install.ps1"
|
||||
try:
|
||||
unit = systemd_unit.read_text(encoding="utf-8").casefold()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
legacy_is_safe = True
|
||||
if legacy_installer.exists():
|
||||
try:
|
||||
installer = legacy_installer.read_text(encoding="utf-8").casefold()
|
||||
except OSError:
|
||||
return False
|
||||
legacy_is_safe = (
|
||||
"schedule run" in installer
|
||||
and "schtasks" not in installer
|
||||
and "new-scheduledtask" not in installer
|
||||
)
|
||||
|
||||
return (
|
||||
scheduler_service.is_file()
|
||||
and not retired_windows_scheduler.exists()
|
||||
and "execstart=/opt/gyxx-flow/.venv/bin/python -m gyxx_flow schedule run"
|
||||
in unit
|
||||
and "environment=gyxx_data_root=/var/lib/gyxx-flow" in unit
|
||||
and "oncalendar=" not in unit
|
||||
and legacy_is_safe
|
||||
)
|
||||
|
||||
|
||||
def _runtime_service_policy_is_configured(project_root: Path) -> bool:
|
||||
try:
|
||||
payload = json.loads(
|
||||
(project_root / "config" / "runtime-bindings.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
services = payload["services"]
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError):
|
||||
return False
|
||||
local_urls = (
|
||||
services.get("hermes_url", ""),
|
||||
services.get("hermes_collector_url", ""),
|
||||
services.get("hermes_analyzer_gateway_url", ""),
|
||||
services.get("hermes_collector_gateway_url", ""),
|
||||
)
|
||||
return (
|
||||
services.get("postgres") == "cloud"
|
||||
and not services.get("postgres_host")
|
||||
and not services.get("postgres_database")
|
||||
and not services.get("postgres_user")
|
||||
and services.get("hermes") == "local"
|
||||
and all(url.startswith("http://127.0.0.1:") for url in local_urls)
|
||||
)
|
||||
|
||||
|
||||
def _native_entrypoints_are_local(project_root: Path) -> bool:
|
||||
@@ -107,7 +170,6 @@ def _native_entrypoints_are_local(project_root: Path) -> bool:
|
||||
/ "gyxx_flow"
|
||||
/ "modules"
|
||||
/ workflow.module
|
||||
/ "runtime"
|
||||
/ workflow.entry
|
||||
).resolve(strict=True)
|
||||
if not target.is_file() or not target.is_relative_to(project_root):
|
||||
@@ -120,12 +182,42 @@ def _native_entrypoints_are_local(project_root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _runnable_script_catalog_is_complete() -> bool:
|
||||
def _public_command_registry_is_complete(project_root: Path) -> bool:
|
||||
try:
|
||||
scripts = ScriptCatalog.discover_default().scripts
|
||||
except (OSError, ValueError):
|
||||
workflows = WorkflowCatalog.load(project_root / "config").workflows
|
||||
commands = ScriptCatalog.discover_default()
|
||||
except (OSError, ValueError, ScriptCatalogError):
|
||||
return False
|
||||
return len(scripts) >= 100 and len({item.module for item in scripts}) == 4
|
||||
|
||||
runnable = tuple(item for item in workflows if item.trigger != "unavailable")
|
||||
expected_modules = {item.module for item in runnable}
|
||||
scripts = commands.scripts
|
||||
if (
|
||||
not scripts
|
||||
or len(commands.command_ids) != len(set(commands.command_ids))
|
||||
or {item.module for item in scripts} != expected_modules
|
||||
):
|
||||
return False
|
||||
|
||||
resolved_root = project_root.resolve()
|
||||
for script in scripts:
|
||||
if not script.path.is_file() or not script.path.is_relative_to(resolved_root):
|
||||
return False
|
||||
|
||||
try:
|
||||
for workflow in runnable:
|
||||
entries = (
|
||||
tuple(step.entry for step in workflow.steps)
|
||||
if workflow.steps
|
||||
else (workflow.entry,)
|
||||
)
|
||||
for entry in entries:
|
||||
command = commands.get(f"{workflow.module}:{entry}")
|
||||
if command.module != workflow.module or command.entry != entry:
|
||||
return False
|
||||
except ScriptCatalogError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _runtime_sources_are_decoupled(project_root: Path) -> bool:
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
"""Replaceable infrastructure adapters exposed to business modules."""
|
||||
|
||||
from .acceptance_policy import (
|
||||
COOKIE_SKIP_EXIT_CODE,
|
||||
WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID,
|
||||
CookiePreflightResult,
|
||||
WorkflowAcceptancePolicy,
|
||||
WorkflowAcceptancePolicyError,
|
||||
current_acceptance_policy,
|
||||
resolve_notification_recipients,
|
||||
skip_feishu_table_write,
|
||||
)
|
||||
from .browser import BrowserCookieStore, BrowserProfileLease, BrowserProfileManager
|
||||
from .external import (
|
||||
FeishuOutboxAdapter,
|
||||
@@ -14,6 +24,7 @@ from .integration import (
|
||||
RuntimeServicePolicy,
|
||||
binding_from_environment,
|
||||
environment_for_child_script,
|
||||
resolve_hermes_profile_api_key,
|
||||
)
|
||||
from .native import (
|
||||
DeferredModuleCommandStep,
|
||||
@@ -27,6 +38,8 @@ __all__ = [
|
||||
"BrowserCookieStore",
|
||||
"BrowserProfileLease",
|
||||
"BrowserProfileManager",
|
||||
"COOKIE_SKIP_EXIT_CODE",
|
||||
"CookiePreflightResult",
|
||||
"DeferredModuleCommandStep",
|
||||
"FeishuOutboxAdapter",
|
||||
"HermesCommandAdapter",
|
||||
@@ -40,6 +53,13 @@ __all__ = [
|
||||
"RuntimeIntegrationCatalog",
|
||||
"RuntimeIntegrationError",
|
||||
"RuntimeServicePolicy",
|
||||
"WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID",
|
||||
"WorkflowAcceptancePolicy",
|
||||
"WorkflowAcceptancePolicyError",
|
||||
"binding_from_environment",
|
||||
"current_acceptance_policy",
|
||||
"environment_for_child_script",
|
||||
"resolve_notification_recipients",
|
||||
"resolve_hermes_profile_api_key",
|
||||
"skip_feishu_table_write",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,690 @@
|
||||
"""Fail-closed runtime policy for isolated workflow acceptance runs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
from .integration import RuntimeIntegrationBinding
|
||||
|
||||
WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID = "ou_8ee224968aa26a74c7d30ba27fed5eeb"
|
||||
|
||||
_TRUE_VALUES = frozenset({"1", "true", "yes", "on"})
|
||||
_FALSE_VALUES = frozenset({"0", "false", "no", "off"})
|
||||
_EVIDENCE_THREAD_LOCK = threading.Lock()
|
||||
_EVIDENCE_LOCK_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
def _reset_evidence_thread_lock_after_fork() -> None:
|
||||
global _EVIDENCE_THREAD_LOCK
|
||||
_EVIDENCE_THREAD_LOCK = threading.Lock()
|
||||
|
||||
|
||||
if hasattr(os, "register_at_fork"):
|
||||
os.register_at_fork(after_in_child=_reset_evidence_thread_lock_after_fork)
|
||||
|
||||
|
||||
class WorkflowAcceptancePolicyError(ValueError):
|
||||
"""Raised when an acceptance run would violate its isolation contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CookiePreflightResult:
|
||||
"""A non-interactive decision about one browser binding's login state."""
|
||||
|
||||
status: str
|
||||
reason: str
|
||||
cookie_file: str
|
||||
storage_state_file: str
|
||||
profile_dir: str
|
||||
cdp_url: str
|
||||
|
||||
@property
|
||||
def should_skip(self) -> bool:
|
||||
return self.status == "SKIPPED_COOKIE"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowAcceptancePolicy:
|
||||
"""Acceptance-only controls propagated to every migrated child process."""
|
||||
|
||||
enabled: bool
|
||||
skip_feishu_table_writes: bool
|
||||
notification_recipient_open_id: str | None
|
||||
skip_invalid_cookie: bool
|
||||
evidence_file: Path | None
|
||||
|
||||
@classmethod
|
||||
def from_environment(
|
||||
cls,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> "WorkflowAcceptancePolicy":
|
||||
values = os.environ if environment is None else environment
|
||||
enabled = _read_bool(values, "GYXX_WORKFLOW_ACCEPTANCE", default=False)
|
||||
if not enabled:
|
||||
return cls(
|
||||
enabled=False,
|
||||
skip_feishu_table_writes=False,
|
||||
notification_recipient_open_id=None,
|
||||
skip_invalid_cookie=False,
|
||||
evidence_file=None,
|
||||
)
|
||||
|
||||
skip_writes = _read_bool(
|
||||
values,
|
||||
"GYXX_FEISHU_TABLE_WRITE_DISABLED",
|
||||
default=True,
|
||||
)
|
||||
skip_cookie = _read_bool(
|
||||
values,
|
||||
"GYXX_COOKIE_INVALID_SKIP",
|
||||
default=True,
|
||||
)
|
||||
recipient = values.get(
|
||||
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID",
|
||||
WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID,
|
||||
).strip()
|
||||
if not skip_writes:
|
||||
raise WorkflowAcceptancePolicyError(
|
||||
"acceptance mode requires Feishu table writes to be disabled"
|
||||
)
|
||||
if not skip_cookie:
|
||||
raise WorkflowAcceptancePolicyError(
|
||||
"acceptance mode requires invalid cookies to be skipped"
|
||||
)
|
||||
if recipient != WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID:
|
||||
raise WorkflowAcceptancePolicyError(
|
||||
"acceptance notification recipient must be Wang Yunlong"
|
||||
)
|
||||
|
||||
evidence_value = values.get("GYXX_ACCEPTANCE_EVIDENCE_FILE", "").strip()
|
||||
if evidence_value:
|
||||
evidence_file = Path(evidence_value).expanduser().resolve()
|
||||
else:
|
||||
data_root = values.get("GYXX_DATA_ROOT", "var").strip() or "var"
|
||||
evidence_file = (
|
||||
Path(data_root).expanduser().resolve()
|
||||
/ "reports"
|
||||
/ "workflow-acceptance"
|
||||
/ "evidence.jsonl"
|
||||
)
|
||||
return cls(
|
||||
enabled=True,
|
||||
skip_feishu_table_writes=True,
|
||||
notification_recipient_open_id=recipient,
|
||||
skip_invalid_cookie=True,
|
||||
evidence_file=evidence_file,
|
||||
)
|
||||
|
||||
def environment(self) -> dict[str, str]:
|
||||
"""Return canonical child-process variables for this policy."""
|
||||
|
||||
if not self.enabled:
|
||||
return {}
|
||||
assert self.notification_recipient_open_id is not None
|
||||
assert self.evidence_file is not None
|
||||
return {
|
||||
"GYXX_WORKFLOW_ACCEPTANCE": "1",
|
||||
"GYXX_FEISHU_TABLE_WRITE_DISABLED": "1",
|
||||
"GYXX_COOKIE_INVALID_SKIP": "1",
|
||||
"GYXX_NOTIFICATION_RECIPIENT_OPEN_ID": (
|
||||
self.notification_recipient_open_id
|
||||
),
|
||||
"GYXX_ACCEPTANCE_EVIDENCE_FILE": str(self.evidence_file),
|
||||
}
|
||||
|
||||
def notification_recipients(self, defaults: Sequence[str]) -> tuple[str, ...]:
|
||||
"""Return the only recipients permitted for the current run."""
|
||||
|
||||
if self.enabled:
|
||||
assert self.notification_recipient_open_id is not None
|
||||
return (self.notification_recipient_open_id,)
|
||||
return tuple(dict.fromkeys(item.strip() for item in defaults if item.strip()))
|
||||
|
||||
def skip_feishu_write(
|
||||
self,
|
||||
operation: str,
|
||||
*,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Record and approve a required Feishu table-write skip."""
|
||||
|
||||
if not self.enabled or not self.skip_feishu_table_writes:
|
||||
return False
|
||||
self.record(
|
||||
"feishu_write_skipped",
|
||||
operation=operation,
|
||||
details=details or {},
|
||||
)
|
||||
return True
|
||||
|
||||
def preflight_cookie(
|
||||
self,
|
||||
binding: RuntimeIntegrationBinding,
|
||||
*,
|
||||
now_epoch: float | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> CookiePreflightResult:
|
||||
"""Skip missing, invalid or expired state without opening a login page."""
|
||||
|
||||
if self.enabled and binding.login_mode == "D":
|
||||
result = _cookie_result(
|
||||
binding,
|
||||
"SKIPPED_COOKIE",
|
||||
"interactive login is disabled during acceptance runs",
|
||||
)
|
||||
else:
|
||||
result = _inspect_cookie_state(binding, now_epoch=now_epoch)
|
||||
values = os.environ if environment is None else environment
|
||||
if (
|
||||
result.should_skip
|
||||
and binding.login_mode in {"B", "C"}
|
||||
and _credentials_are_available(binding, values)
|
||||
):
|
||||
result = _cookie_result(
|
||||
binding,
|
||||
"READY",
|
||||
"credential login fallback is available",
|
||||
)
|
||||
if self.enabled and self.skip_invalid_cookie and result.should_skip:
|
||||
self.record("cookie_skipped", details=asdict(result))
|
||||
return result
|
||||
|
||||
def record(
|
||||
self,
|
||||
event: str,
|
||||
*,
|
||||
operation: str | None = None,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Append non-secret, run-scoped acceptance evidence as JSONL."""
|
||||
|
||||
if not self.enabled or self.evidence_file is None:
|
||||
return
|
||||
payload = {
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
"event": event,
|
||||
"workflow_id": os.environ.get("GYXX_WORKFLOW_ID", ""),
|
||||
"run_id": os.environ.get("GYXX_RUN_ID", ""),
|
||||
"operation": operation or "",
|
||||
"details": _safe_details(details or {}),
|
||||
}
|
||||
self.evidence_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(payload, ensure_ascii=False, sort_keys=True) + "\n"
|
||||
lock_file = self.evidence_file.with_suffix(self.evidence_file.suffix + ".lock")
|
||||
with _exclusive_file_lock(lock_file):
|
||||
with self.evidence_file.open("a", encoding="utf-8", newline="") as handle:
|
||||
handle.write(line)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_file_lock(lock_file: Path):
|
||||
"""Serialize evidence appends across threads and child processes."""
|
||||
|
||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not _EVIDENCE_THREAD_LOCK.acquire(timeout=_EVIDENCE_LOCK_TIMEOUT_SECONDS):
|
||||
raise WorkflowAcceptancePolicyError(
|
||||
"timed out while locking acceptance evidence in this process"
|
||||
)
|
||||
try:
|
||||
handle = lock_file.open("a+b")
|
||||
except BaseException:
|
||||
_EVIDENCE_THREAD_LOCK.release()
|
||||
raise
|
||||
try:
|
||||
deadline = time.monotonic() + _EVIDENCE_LOCK_TIMEOUT_SECONDS
|
||||
while not _try_lock_file(handle):
|
||||
if time.monotonic() >= deadline:
|
||||
raise WorkflowAcceptancePolicyError(
|
||||
"timed out while locking the acceptance evidence file"
|
||||
)
|
||||
time.sleep(0.01)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_unlock_file(handle)
|
||||
finally:
|
||||
handle.close()
|
||||
_EVIDENCE_THREAD_LOCK.release()
|
||||
|
||||
|
||||
def _try_lock_file(handle) -> bool:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
handle.seek(0, os.SEEK_END)
|
||||
if handle.tell() == 0:
|
||||
handle.write(b"\0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
try:
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
import fcntl
|
||||
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _unlock_file(handle) -> None:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
return
|
||||
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def current_acceptance_policy(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> WorkflowAcceptancePolicy:
|
||||
return WorkflowAcceptancePolicy.from_environment(environment)
|
||||
|
||||
|
||||
def resolve_notification_recipients(
|
||||
defaults: Sequence[str],
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
return current_acceptance_policy(environment).notification_recipients(defaults)
|
||||
|
||||
|
||||
def skip_feishu_table_write(
|
||||
operation: str,
|
||||
*,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> bool:
|
||||
return current_acceptance_policy(environment).skip_feishu_write(
|
||||
operation,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _inspect_cookie_state(
|
||||
binding: RuntimeIntegrationBinding,
|
||||
*,
|
||||
now_epoch: float | None,
|
||||
) -> CookiePreflightResult:
|
||||
now = time.time() if now_epoch is None else now_epoch
|
||||
cookies: list[Mapping[str, Any]] = []
|
||||
storage_origins: list[str] = []
|
||||
invalid_reason = ""
|
||||
|
||||
if binding.cookie_file.is_file():
|
||||
try:
|
||||
payload = json.loads(binding.cookie_file.read_text(encoding="utf-8"))
|
||||
loaded, origins, invalid_reason = _cookie_payload(
|
||||
payload,
|
||||
label="cookie file",
|
||||
)
|
||||
cookies.extend(loaded)
|
||||
storage_origins.extend(origins)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
invalid_reason = "cookie file is unreadable"
|
||||
|
||||
if not invalid_reason and binding.storage_state_file.is_file():
|
||||
try:
|
||||
payload = json.loads(
|
||||
binding.storage_state_file.read_text(encoding="utf-8")
|
||||
)
|
||||
loaded, origins, invalid_reason = _cookie_payload(
|
||||
payload,
|
||||
label="storage state",
|
||||
)
|
||||
cookies.extend(loaded)
|
||||
storage_origins.extend(origins)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
invalid_reason = "storage state is unreadable"
|
||||
|
||||
if invalid_reason:
|
||||
return _cookie_result(binding, "SKIPPED_COOKIE", invalid_reason)
|
||||
|
||||
state_failure_reason = ""
|
||||
if cookies:
|
||||
usable: list[Mapping[str, Any]] = []
|
||||
for cookie in cookies:
|
||||
if not cookie.get("value"):
|
||||
continue
|
||||
expires = cookie.get("expires", cookie.get("expirationDate"))
|
||||
if expires in (None, "", 0, -1):
|
||||
usable.append(cookie)
|
||||
continue
|
||||
try:
|
||||
if float(expires) > now:
|
||||
usable.append(cookie)
|
||||
except (TypeError, ValueError):
|
||||
return _cookie_result(
|
||||
binding,
|
||||
"SKIPPED_COOKIE",
|
||||
"cookie expiry is invalid",
|
||||
)
|
||||
if not usable:
|
||||
return _cookie_result(binding, "SKIPPED_COOKIE", "all cookies are expired")
|
||||
domain_matched = _cookies_match_required_domains(
|
||||
usable,
|
||||
binding.required_cookie_domains,
|
||||
)
|
||||
if not domain_matched:
|
||||
state_failure_reason = (
|
||||
"cookie state does not match the required platform domain"
|
||||
)
|
||||
elif binding.required_cookie_names and not any(
|
||||
str(cookie.get("name", "")) in binding.required_cookie_names
|
||||
for cookie in domain_matched
|
||||
):
|
||||
state_failure_reason = "required platform login cookie is missing"
|
||||
else:
|
||||
return _cookie_result(binding, "READY", "cookie state is available")
|
||||
|
||||
if storage_origins and _origins_match_required_domains(
|
||||
storage_origins,
|
||||
binding.required_cookie_domains,
|
||||
):
|
||||
return _cookie_result(binding, "READY", "browser storage state is available")
|
||||
if _profile_has_usable_cookies(
|
||||
binding.profile_dir,
|
||||
now_epoch=now,
|
||||
required_domains=binding.required_cookie_domains,
|
||||
required_names=binding.required_cookie_names,
|
||||
):
|
||||
return _cookie_result(binding, "READY", "browser profile state is available")
|
||||
if (
|
||||
not binding.required_cookie_domains
|
||||
and not binding.required_cookie_names
|
||||
and _cdp_is_listening(binding)
|
||||
):
|
||||
return _cookie_result(binding, "READY", "bound CDP endpoint is listening")
|
||||
return _cookie_result(
|
||||
binding,
|
||||
"SKIPPED_COOKIE",
|
||||
state_failure_reason or "browser state is missing",
|
||||
)
|
||||
|
||||
|
||||
def _cookie_payload(
|
||||
payload: object,
|
||||
*,
|
||||
label: str,
|
||||
) -> tuple[list[Mapping[str, Any]], list[str], str]:
|
||||
if isinstance(payload, list):
|
||||
cookies = payload
|
||||
origins: object = []
|
||||
elif isinstance(payload, dict):
|
||||
cookies = payload.get("cookies", [])
|
||||
origins = payload.get("origins", [])
|
||||
else:
|
||||
return [], [], f"{label} is not a cookie list or storage-state object"
|
||||
if not isinstance(cookies, list) or not all(
|
||||
isinstance(item, dict) for item in cookies
|
||||
):
|
||||
return [], [], f"{label} cookies are invalid"
|
||||
if not isinstance(origins, list) or not all(
|
||||
isinstance(item, dict) and isinstance(item.get("origin"), str)
|
||||
for item in origins
|
||||
):
|
||||
return [], [], f"{label} origins are invalid"
|
||||
return cookies, [item["origin"] for item in origins], ""
|
||||
|
||||
|
||||
def _cookie_domain_matches(cookie_domain: str, required_domain: str) -> bool:
|
||||
cookie_host = cookie_domain.strip().casefold().lstrip(".")
|
||||
required_host = required_domain.strip().casefold().lstrip(".")
|
||||
if not cookie_host or not required_host:
|
||||
return False
|
||||
return (
|
||||
cookie_host == required_host
|
||||
or cookie_host.endswith(f".{required_host}")
|
||||
or required_host.endswith(f".{cookie_host}")
|
||||
)
|
||||
|
||||
|
||||
def _cookies_match_required_domains(
|
||||
cookies: Sequence[Mapping[str, Any]],
|
||||
required_domains: Sequence[str],
|
||||
) -> list[Mapping[str, Any]]:
|
||||
if not required_domains:
|
||||
return list(cookies)
|
||||
return [
|
||||
cookie
|
||||
for cookie in cookies
|
||||
if any(
|
||||
_cookie_domain_matches(str(cookie.get("domain", "")), required)
|
||||
for required in required_domains
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def _origins_match_required_domains(
|
||||
origins: Sequence[str],
|
||||
required_domains: Sequence[str],
|
||||
) -> bool:
|
||||
if not required_domains:
|
||||
return bool(origins)
|
||||
for origin in origins:
|
||||
try:
|
||||
host = urlparse(origin).hostname
|
||||
except (TypeError, ValueError):
|
||||
host = None
|
||||
if host and any(
|
||||
_cookie_domain_matches(host, required) for required in required_domains
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _credentials_are_available(
|
||||
binding: RuntimeIntegrationBinding,
|
||||
environment: Mapping[str, str],
|
||||
) -> bool:
|
||||
return bool(binding.credential_env_names) and all(
|
||||
environment.get(name, "").strip()
|
||||
for name in binding.credential_env_names
|
||||
)
|
||||
|
||||
|
||||
def _cookie_result(
|
||||
binding: RuntimeIntegrationBinding,
|
||||
status: str,
|
||||
reason: str,
|
||||
) -> CookiePreflightResult:
|
||||
return CookiePreflightResult(
|
||||
status=status,
|
||||
reason=reason,
|
||||
cookie_file=str(binding.cookie_file),
|
||||
storage_state_file=str(binding.storage_state_file),
|
||||
profile_dir=str(binding.profile_dir),
|
||||
cdp_url=binding.cdp_url,
|
||||
)
|
||||
|
||||
|
||||
def _profile_has_usable_cookies(
|
||||
path: Path,
|
||||
*,
|
||||
now_epoch: float,
|
||||
required_domains: Sequence[str] = (),
|
||||
required_names: Sequence[str] = (),
|
||||
) -> bool:
|
||||
"""Treat a Chrome profile as reusable only when it has live cookies.
|
||||
|
||||
A newly-created profile already contains many files, so directory
|
||||
non-emptiness is not evidence of an authenticated session. Chromium stores
|
||||
expiry values as microseconds since 1601-01-01.
|
||||
"""
|
||||
|
||||
if not path.is_dir():
|
||||
return False
|
||||
chrome_now = int((now_epoch + 11_644_473_600) * 1_000_000)
|
||||
candidates = [path / "Network" / "Cookies", path / "Default" / "Network" / "Cookies"]
|
||||
try:
|
||||
candidates.extend(
|
||||
profile / "Network" / "Cookies"
|
||||
for profile in path.glob("Profile *")
|
||||
if profile.is_dir()
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
for cookie_db in candidates:
|
||||
if not cookie_db.is_file():
|
||||
continue
|
||||
try:
|
||||
uri = f"{cookie_db.resolve().as_uri()}?mode=ro"
|
||||
with sqlite3.connect(uri, uri=True, timeout=0.2) as connection:
|
||||
columns = {
|
||||
str(row[1])
|
||||
for row in connection.execute("PRAGMA table_info(cookies)")
|
||||
}
|
||||
if "expires_utc" not in columns:
|
||||
continue
|
||||
if required_domains and "host_key" not in columns:
|
||||
continue
|
||||
if required_names and "name" not in columns:
|
||||
continue
|
||||
selected = [
|
||||
column
|
||||
for column in ("host_key", "name")
|
||||
if column in columns
|
||||
]
|
||||
rows = connection.execute(
|
||||
f"SELECT {', '.join(selected) or 'expires_utc'} FROM cookies "
|
||||
"WHERE expires_utc = 0 OR expires_utc > ?",
|
||||
(chrome_now,),
|
||||
).fetchall()
|
||||
host_index = selected.index("host_key") if "host_key" in selected else None
|
||||
name_index = selected.index("name") if "name" in selected else None
|
||||
if any(
|
||||
(
|
||||
not required_domains
|
||||
or (
|
||||
host_index is not None
|
||||
and any(
|
||||
_cookie_domain_matches(str(row[host_index]), required)
|
||||
for required in required_domains
|
||||
)
|
||||
)
|
||||
)
|
||||
and (
|
||||
not required_names
|
||||
or (
|
||||
name_index is not None
|
||||
and str(row[name_index]) in required_names
|
||||
)
|
||||
)
|
||||
for row in rows
|
||||
):
|
||||
return True
|
||||
except (OSError, sqlite3.Error):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _cdp_is_listening(binding: RuntimeIntegrationBinding) -> bool:
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", binding.cdp_port), timeout=0.2):
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _read_bool(
|
||||
values: Mapping[str, str],
|
||||
name: str,
|
||||
*,
|
||||
default: bool,
|
||||
) -> bool:
|
||||
raw = values.get(name, "").strip().casefold()
|
||||
if not raw:
|
||||
return default
|
||||
if raw in _TRUE_VALUES:
|
||||
return True
|
||||
if raw in _FALSE_VALUES:
|
||||
return False
|
||||
raise WorkflowAcceptancePolicyError(f"{name} must be a boolean value")
|
||||
|
||||
|
||||
def _safe_details(details: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return _safe_detail_mapping(details, seen=set())
|
||||
|
||||
|
||||
def _safe_detail_mapping(
|
||||
details: Mapping[object, Any],
|
||||
*,
|
||||
seen: set[int],
|
||||
) -> dict[str, Any]:
|
||||
identity = id(details)
|
||||
if identity in seen:
|
||||
return {"recursive": "<redacted>"}
|
||||
seen.add(identity)
|
||||
safe: dict[str, Any] = {}
|
||||
for key, value in details.items():
|
||||
safe_key = str(key)
|
||||
if _is_sensitive_detail_key(safe_key):
|
||||
safe[safe_key] = "<redacted>"
|
||||
else:
|
||||
safe[safe_key] = _safe_detail_value(value, seen=seen)
|
||||
seen.remove(identity)
|
||||
return safe
|
||||
|
||||
|
||||
def _safe_detail_value(value: Any, *, seen: set[int]) -> Any:
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return _safe_detail_mapping(value, seen=seen)
|
||||
if isinstance(value, list):
|
||||
identity = id(value)
|
||||
if identity in seen:
|
||||
return "<redacted>"
|
||||
seen.add(identity)
|
||||
safe = [_safe_detail_value(item, seen=seen) for item in value]
|
||||
seen.remove(identity)
|
||||
return safe
|
||||
if isinstance(value, tuple):
|
||||
identity = id(value)
|
||||
if identity in seen:
|
||||
return "<redacted>"
|
||||
seen.add(identity)
|
||||
safe = tuple(_safe_detail_value(item, seen=seen) for item in value)
|
||||
seen.remove(identity)
|
||||
return safe
|
||||
return str(value)
|
||||
|
||||
|
||||
def _is_sensitive_detail_key(key: str) -> bool:
|
||||
return any(
|
||||
marker in key.casefold()
|
||||
for marker in ("password", "secret", "token", "key")
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COOKIE_SKIP_EXIT_CODE",
|
||||
"CookiePreflightResult",
|
||||
"WORKFLOW_ACCEPTANCE_RECIPIENT_OPEN_ID",
|
||||
"WorkflowAcceptancePolicy",
|
||||
"WorkflowAcceptancePolicyError",
|
||||
"current_acceptance_policy",
|
||||
"resolve_notification_recipients",
|
||||
"skip_feishu_table_write",
|
||||
]
|
||||
@@ -19,6 +19,49 @@ class RuntimeIntegrationError(ValueError):
|
||||
"""Raised when an integration binding is incomplete or unsafe."""
|
||||
|
||||
|
||||
def resolve_hermes_profile_api_key(
|
||||
profile: str,
|
||||
environment: Mapping[str, str] | None = None,
|
||||
*,
|
||||
preferred_environment_names: tuple[str, ...] = (),
|
||||
) -> str:
|
||||
"""Resolve a local Hermes gateway key without copying it into project files."""
|
||||
|
||||
if (
|
||||
not isinstance(profile, str)
|
||||
or not profile.strip()
|
||||
or profile.strip() in {".", ".."}
|
||||
or any(separator in profile for separator in ("/", "\\"))
|
||||
):
|
||||
raise RuntimeIntegrationError("Hermes profile must be a safe directory name")
|
||||
values = os.environ if environment is None else environment
|
||||
for name in (
|
||||
*preferred_environment_names,
|
||||
"GYXX_HERMES_API_KEY",
|
||||
"HERMES_API_KEY",
|
||||
):
|
||||
configured = values.get(name, "").strip()
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
hermes_home = values.get("HERMES_HOME", "").strip()
|
||||
if not hermes_home:
|
||||
return ""
|
||||
env_path = Path(hermes_home).expanduser() / "profiles" / profile.strip() / ".env"
|
||||
try:
|
||||
lines = env_path.read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return ""
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
if key.strip() == "API_SERVER_KEY":
|
||||
return value.strip().strip('"').strip("'")
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeIntegrationBinding:
|
||||
script_id: str
|
||||
@@ -29,40 +72,138 @@ class RuntimeIntegrationBinding:
|
||||
profile_dir: Path
|
||||
cookie_file: Path
|
||||
storage_state_file: Path
|
||||
command_id: str = ""
|
||||
state_key: str = ""
|
||||
aliases: tuple[str, ...] = ()
|
||||
login_mode: str = "A"
|
||||
required_cookie_domains: tuple[str, ...] = ()
|
||||
required_cookie_names: tuple[str, ...] = ()
|
||||
credential_env_names: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Defaults keep direct construction and schema-v1 callers compatible.
|
||||
if not self.command_id:
|
||||
object.__setattr__(self, "command_id", self.script_id)
|
||||
if not self.state_key:
|
||||
object.__setattr__(self, "state_key", self.script_id)
|
||||
object.__setattr__(self, "aliases", tuple(self.aliases))
|
||||
login_mode = self.login_mode.strip().upper()
|
||||
if login_mode not in {"A", "B", "C", "D"}:
|
||||
raise RuntimeIntegrationError("browser login mode must be A, B, C or D")
|
||||
object.__setattr__(self, "login_mode", login_mode)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"required_cookie_domains",
|
||||
_normalize_cookie_domains(self.required_cookie_domains),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"required_cookie_names",
|
||||
_normalize_binding_strings(
|
||||
self.required_cookie_names,
|
||||
field="required cookie names",
|
||||
),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"credential_env_names",
|
||||
_normalize_binding_strings(
|
||||
self.credential_env_names,
|
||||
field="credential environment names",
|
||||
require_env_name=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeServicePolicy:
|
||||
"""Keep legacy Feishu, cloud PostgreSQL, and loopback-only Hermes."""
|
||||
"""Keep legacy Feishu, configurable PostgreSQL, and local Hermes."""
|
||||
|
||||
postgres_mode: str = "cloud"
|
||||
postgres_host: str = ""
|
||||
postgres_port: int = 5432
|
||||
postgres_database: str = ""
|
||||
postgres_user: str = ""
|
||||
hermes_url: str = "http://127.0.0.1:8642/v1/chat/completions"
|
||||
hermes_collector_url: str = "http://127.0.0.1:8643/v1/chat/completions"
|
||||
hermes_analyzer_gateway_url: str = "http://127.0.0.1:8642/v1"
|
||||
hermes_collector_gateway_url: str = "http://127.0.0.1:8643/v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
postgres_mode = self.postgres_mode.strip().casefold()
|
||||
if postgres_mode not in {"cloud", "local"}:
|
||||
raise RuntimeIntegrationError("PostgreSQL mode must be cloud or local")
|
||||
object.__setattr__(self, "postgres_mode", postgres_mode)
|
||||
if not 1 <= self.postgres_port <= 65535:
|
||||
raise RuntimeIntegrationError("PostgreSQL port must be valid")
|
||||
if postgres_mode == "local":
|
||||
if not _is_loopback_host(self.postgres_host):
|
||||
raise RuntimeIntegrationError("local PostgreSQL requires loopback host")
|
||||
if not self.postgres_database or not self.postgres_user:
|
||||
raise RuntimeIntegrationError(
|
||||
"local PostgreSQL database and user are required"
|
||||
)
|
||||
elif self.postgres_host and _is_loopback_host(self.postgres_host):
|
||||
raise RuntimeIntegrationError("cloud PostgreSQL requires a remote host")
|
||||
_require_loopback_url(self.hermes_url, field="Hermes local URL")
|
||||
_require_loopback_url(
|
||||
self.hermes_collector_url,
|
||||
field="Hermes collector URL",
|
||||
)
|
||||
_require_loopback_url(
|
||||
self.hermes_analyzer_gateway_url,
|
||||
field="Hermes analyzer gateway URL",
|
||||
)
|
||||
_require_loopback_url(
|
||||
self.hermes_collector_gateway_url,
|
||||
field="Hermes collector gateway URL",
|
||||
)
|
||||
|
||||
def apply(self, environment: Mapping[str, str]) -> dict[str, str]:
|
||||
result = dict(environment)
|
||||
_apply_canonical_secrets(result)
|
||||
_fill_database_aliases(result)
|
||||
_validate_cloud_database(result)
|
||||
if self.postgres_mode == "local":
|
||||
_fill_local_database_defaults(
|
||||
result,
|
||||
host=self.postgres_host,
|
||||
port=self.postgres_port,
|
||||
database=self.postgres_database,
|
||||
user=self.postgres_user,
|
||||
)
|
||||
_validate_local_database(result)
|
||||
else:
|
||||
_validate_cloud_database(result)
|
||||
configured_hermes = _configured_hermes_urls(result)
|
||||
for field, value in configured_hermes:
|
||||
_require_loopback_url(value, field=field)
|
||||
result.update(
|
||||
{
|
||||
"GYXX_FEISHU_MODE": "legacy",
|
||||
"GYXX_POSTGRES_MODE": "cloud",
|
||||
"GYXX_POSTGRES_MODE": self.postgres_mode,
|
||||
"GYXX_HERMES_MODE": "local",
|
||||
}
|
||||
)
|
||||
# Preserve every original Feishu/DB/Hermes setting. Defaults are only
|
||||
# supplied for the two historical Hermes HTTP variable names.
|
||||
result.setdefault("HERMES_ANALYZER_URL", self.hermes_url)
|
||||
result.setdefault("ANALYZER_API_SERVER_URL", self.hermes_url)
|
||||
_set_if_blank(result, "HERMES_ANALYZER_URL", self.hermes_url)
|
||||
_set_if_blank(result, "ANALYZER_API_SERVER_URL", self.hermes_url)
|
||||
_set_if_blank(result, "COLLECTOR_API_SERVER_URL", self.hermes_collector_url)
|
||||
_set_if_blank(
|
||||
result,
|
||||
"ANALYZER_HERMES_GATEWAY_URL",
|
||||
self.hermes_analyzer_gateway_url,
|
||||
)
|
||||
_set_if_blank(
|
||||
result,
|
||||
"COLLECTOR_HERMES_GATEWAY_URL",
|
||||
self.hermes_collector_gateway_url,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class RuntimeIntegrationCatalog:
|
||||
"""Immutable exact allocation of one portable browser binding per script."""
|
||||
"""Immutable browser bindings addressed by stable IDs and legacy aliases."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -70,7 +211,24 @@ class RuntimeIntegrationCatalog:
|
||||
*,
|
||||
service_policy: RuntimeServicePolicy,
|
||||
) -> None:
|
||||
self._bindings = MappingProxyType(dict(sorted(bindings.items())))
|
||||
primary = dict(sorted(bindings.items()))
|
||||
aliases: dict[str, str] = {}
|
||||
for command_id, binding in primary.items():
|
||||
if command_id != binding.command_id:
|
||||
raise RuntimeIntegrationError(
|
||||
f"runtime binding key does not match command_id: {command_id}"
|
||||
)
|
||||
for alias in (command_id, binding.script_id, *binding.aliases):
|
||||
if not isinstance(alias, str) or not alias.strip():
|
||||
raise RuntimeIntegrationError("runtime binding aliases must be non-empty")
|
||||
existing = aliases.get(alias)
|
||||
if existing is not None and existing != command_id:
|
||||
raise RuntimeIntegrationError(
|
||||
f"runtime binding alias is ambiguous: {alias}"
|
||||
)
|
||||
aliases[alias] = command_id
|
||||
self._bindings = MappingProxyType(primary)
|
||||
self._aliases = MappingProxyType(dict(sorted(aliases.items())))
|
||||
self.service_policy = service_policy
|
||||
|
||||
@classmethod
|
||||
@@ -88,60 +246,87 @@ class RuntimeIntegrationCatalog:
|
||||
raise RuntimeIntegrationError(
|
||||
f"cannot load runtime integration catalog: {path.name}"
|
||||
) from exc
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
|
||||
if not isinstance(payload, dict) or payload.get("schema_version") not in {1, 2}:
|
||||
raise RuntimeIntegrationError("unsupported runtime binding schema")
|
||||
host = payload.get("cdp_host")
|
||||
if host not in {"127.0.0.1", "localhost", "::1"}:
|
||||
raise RuntimeIntegrationError("browser CDP host must be loopback")
|
||||
allocations = payload.get("scripts")
|
||||
if not isinstance(allocations, dict):
|
||||
configured_bindings = payload.get("scripts")
|
||||
if not isinstance(configured_bindings, dict):
|
||||
raise RuntimeIntegrationError("runtime binding scripts must be an object")
|
||||
configured = set(allocations)
|
||||
discovered = set(scripts.script_ids)
|
||||
if configured != discovered:
|
||||
missing = len(discovered - configured)
|
||||
extra = len(configured - discovered)
|
||||
raise RuntimeIntegrationError(
|
||||
f"runtime binding coverage mismatch; missing={missing}, extra={extra}"
|
||||
)
|
||||
ports = list(allocations.values())
|
||||
if (
|
||||
not all(isinstance(port, int) and 22000 <= port <= 22999 for port in ports)
|
||||
or len(set(ports)) != len(ports)
|
||||
):
|
||||
raise RuntimeIntegrationError(
|
||||
"runtime binding ports must be unique integers in 22000..22999"
|
||||
)
|
||||
root = Path(data_root).expanduser().resolve()
|
||||
bindings: dict[str, RuntimeIntegrationBinding] = {}
|
||||
for script in scripts.scripts:
|
||||
port = allocations[script.script_id]
|
||||
state_root = _browser_state_root(root, script.module, script.script_id)
|
||||
bindings[script.script_id] = RuntimeIntegrationBinding(
|
||||
script_id=script.script_id,
|
||||
module=script.module,
|
||||
entry=script.entry,
|
||||
cdp_port=port,
|
||||
cdp_url=f"http://{host}:{port}",
|
||||
profile_dir=state_root / "profile",
|
||||
cookie_file=state_root / "cookies.json",
|
||||
storage_state_file=state_root / "storage_state.json",
|
||||
if payload["schema_version"] == 1:
|
||||
bindings = _load_legacy_bindings(
|
||||
configured_bindings,
|
||||
scripts=scripts,
|
||||
data_root=root,
|
||||
cdp_host=host,
|
||||
)
|
||||
else:
|
||||
bindings = _load_stable_bindings(
|
||||
configured_bindings,
|
||||
scripts=scripts,
|
||||
data_root=root,
|
||||
cdp_host=host,
|
||||
)
|
||||
services = payload.get("services")
|
||||
if not isinstance(services, dict):
|
||||
raise RuntimeIntegrationError("runtime binding services must be an object")
|
||||
postgres_mode = services.get("postgres")
|
||||
if (
|
||||
services.get("feishu") != "legacy"
|
||||
or services.get("postgres") != "cloud"
|
||||
or postgres_mode not in {"cloud", "local"}
|
||||
or services.get("hermes") != "local"
|
||||
):
|
||||
raise RuntimeIntegrationError("unsupported runtime service policy")
|
||||
hermes_url = services.get("hermes_url")
|
||||
if not isinstance(hermes_url, str):
|
||||
raise RuntimeIntegrationError("runtime service policy requires Hermes URL")
|
||||
hermes_collector_url = services.get("hermes_collector_url")
|
||||
hermes_analyzer_gateway_url = services.get("hermes_analyzer_gateway_url")
|
||||
hermes_collector_gateway_url = services.get("hermes_collector_gateway_url")
|
||||
if not all(
|
||||
isinstance(value, str)
|
||||
for value in (
|
||||
hermes_collector_url,
|
||||
hermes_analyzer_gateway_url,
|
||||
hermes_collector_gateway_url,
|
||||
)
|
||||
):
|
||||
raise RuntimeIntegrationError(
|
||||
"runtime service policy requires both local Hermes roles"
|
||||
)
|
||||
local_postgres = postgres_mode == "local"
|
||||
postgres_host = services.get(
|
||||
"postgres_host", "127.0.0.1" if local_postgres else ""
|
||||
)
|
||||
postgres_port = services.get("postgres_port", 5432)
|
||||
postgres_database = services.get(
|
||||
"postgres_database", "gyxx_super_data" if local_postgres else ""
|
||||
)
|
||||
postgres_user = services.get(
|
||||
"postgres_user", "gyxx_flow" if local_postgres else ""
|
||||
)
|
||||
if (
|
||||
not isinstance(postgres_host, str)
|
||||
or not isinstance(postgres_port, int)
|
||||
or not isinstance(postgres_database, str)
|
||||
or not isinstance(postgres_user, str)
|
||||
):
|
||||
raise RuntimeIntegrationError("invalid PostgreSQL service settings")
|
||||
return cls(
|
||||
bindings,
|
||||
service_policy=RuntimeServicePolicy(hermes_url=hermes_url),
|
||||
service_policy=RuntimeServicePolicy(
|
||||
postgres_mode=postgres_mode,
|
||||
postgres_host=postgres_host,
|
||||
postgres_port=postgres_port,
|
||||
postgres_database=postgres_database,
|
||||
postgres_user=postgres_user,
|
||||
hermes_url=hermes_url,
|
||||
hermes_collector_url=hermes_collector_url,
|
||||
hermes_analyzer_gateway_url=hermes_analyzer_gateway_url,
|
||||
hermes_collector_gateway_url=hermes_collector_gateway_url,
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -161,31 +346,57 @@ class RuntimeIntegrationCatalog:
|
||||
|
||||
@property
|
||||
def script_ids(self) -> tuple[str, ...]:
|
||||
"""Return current physical script IDs for bootstrap compatibility."""
|
||||
|
||||
return tuple(sorted(binding.script_id for binding in self._bindings.values()))
|
||||
|
||||
@property
|
||||
def command_ids(self) -> tuple[str, ...]:
|
||||
"""Return stable logical command IDs."""
|
||||
|
||||
return tuple(self._bindings)
|
||||
|
||||
def binding_for(self, script_id: str) -> RuntimeIntegrationBinding:
|
||||
def binding_for(self, binding_id: str) -> RuntimeIntegrationBinding:
|
||||
try:
|
||||
return self._bindings[script_id]
|
||||
command_id = self._aliases[binding_id]
|
||||
return self._bindings[command_id]
|
||||
except KeyError as exc:
|
||||
raise RuntimeIntegrationError(f"unknown runtime script binding: {script_id}") from exc
|
||||
raise RuntimeIntegrationError(
|
||||
f"unknown runtime script binding: {binding_id}"
|
||||
) from exc
|
||||
|
||||
def environment_for(
|
||||
self,
|
||||
script_id: str,
|
||||
binding_id: str,
|
||||
base_environment: Mapping[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
binding = self.binding_for(script_id)
|
||||
binding = self.binding_for(binding_id)
|
||||
result = self.service_policy.apply(
|
||||
os.environ if base_environment is None else base_environment
|
||||
)
|
||||
result.update(
|
||||
{
|
||||
"GYXX_SCRIPT_ID": binding.script_id,
|
||||
"GYXX_COMMAND_ID": binding.command_id,
|
||||
"GYXX_BROWSER_STATE_KEY": binding.state_key,
|
||||
"GYXX_BROWSER_CDP_PORT": str(binding.cdp_port),
|
||||
"GYXX_BROWSER_CDP_URL": binding.cdp_url,
|
||||
"GYXX_BROWSER_PROFILE_DIR": str(binding.profile_dir),
|
||||
"GYXX_BROWSER_COOKIE_FILE": str(binding.cookie_file),
|
||||
"GYXX_BROWSER_STORAGE_STATE_FILE": str(binding.storage_state_file),
|
||||
"GYXX_BROWSER_LOGIN_MODE": binding.login_mode,
|
||||
"GYXX_BROWSER_REQUIRED_COOKIE_DOMAINS": json.dumps(
|
||||
binding.required_cookie_domains,
|
||||
ensure_ascii=True,
|
||||
),
|
||||
"GYXX_BROWSER_REQUIRED_COOKIE_NAMES": json.dumps(
|
||||
binding.required_cookie_names,
|
||||
ensure_ascii=True,
|
||||
),
|
||||
"GYXX_BROWSER_CREDENTIAL_ENV_NAMES": json.dumps(
|
||||
binding.credential_env_names,
|
||||
ensure_ascii=True,
|
||||
),
|
||||
# Compatibility aliases consumed by the migrated browser engines.
|
||||
# They are deliberately overwritten so every engine uses the
|
||||
# catalog allocation instead of a legacy shared profile/port.
|
||||
@@ -199,6 +410,7 @@ class RuntimeIntegrationCatalog:
|
||||
"WANXIANG_USER_DATA_DIR": str(binding.profile_dir),
|
||||
"GUANGHE_USER_DATA_DIR": str(binding.profile_dir),
|
||||
"GUANGHE_LUGGAGE_USER_DATA_DIR": str(binding.profile_dir),
|
||||
"DY_COOKIES_FILE": str(binding.cookie_file),
|
||||
"DY_STORAGE_STATE_FILE": str(binding.storage_state_file),
|
||||
}
|
||||
)
|
||||
@@ -242,13 +454,30 @@ def binding_from_environment(
|
||||
try:
|
||||
script_id = values["GYXX_SCRIPT_ID"]
|
||||
module, entry = script_id.split(":", 1)
|
||||
command_id = values.get("GYXX_COMMAND_ID", script_id).strip()
|
||||
state_key = values.get("GYXX_BROWSER_STATE_KEY", script_id).strip()
|
||||
port = int(values["GYXX_BROWSER_CDP_PORT"])
|
||||
cdp_url = values["GYXX_BROWSER_CDP_URL"]
|
||||
profile = Path(values["GYXX_BROWSER_PROFILE_DIR"]).expanduser().resolve()
|
||||
cookie = Path(values["GYXX_BROWSER_COOKIE_FILE"]).expanduser().resolve()
|
||||
storage = Path(values["GYXX_BROWSER_STORAGE_STATE_FILE"]).expanduser().resolve()
|
||||
login_mode = values.get("GYXX_BROWSER_LOGIN_MODE", "A")
|
||||
required_cookie_domains = _binding_tuple_from_environment(
|
||||
values,
|
||||
"GYXX_BROWSER_REQUIRED_COOKIE_DOMAINS",
|
||||
)
|
||||
required_cookie_names = _binding_tuple_from_environment(
|
||||
values,
|
||||
"GYXX_BROWSER_REQUIRED_COOKIE_NAMES",
|
||||
)
|
||||
credential_env_names = _binding_tuple_from_environment(
|
||||
values,
|
||||
"GYXX_BROWSER_CREDENTIAL_ENV_NAMES",
|
||||
)
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise RuntimeIntegrationError("current process has no valid browser binding") from exc
|
||||
if not command_id or not state_key:
|
||||
raise RuntimeIntegrationError("current process has no stable browser identity")
|
||||
_require_loopback_url(cdp_url, field="browser CDP URL")
|
||||
if not 22000 <= port <= 22999:
|
||||
raise RuntimeIntegrationError("browser CDP port is outside the managed range")
|
||||
@@ -261,15 +490,235 @@ def binding_from_environment(
|
||||
profile_dir=profile,
|
||||
cookie_file=cookie,
|
||||
storage_state_file=storage,
|
||||
command_id=command_id,
|
||||
state_key=state_key,
|
||||
aliases=(script_id,),
|
||||
login_mode=login_mode,
|
||||
required_cookie_domains=required_cookie_domains,
|
||||
required_cookie_names=required_cookie_names,
|
||||
credential_env_names=credential_env_names,
|
||||
)
|
||||
|
||||
|
||||
def _browser_state_root(data_root: Path, module: str, script_id: str) -> Path:
|
||||
entry = script_id.split(":", 1)[1]
|
||||
stem = Path(entry).stem
|
||||
def _load_legacy_bindings(
|
||||
allocations: Mapping[str, object],
|
||||
*,
|
||||
scripts: ScriptCatalog,
|
||||
data_root: Path,
|
||||
cdp_host: str,
|
||||
) -> dict[str, RuntimeIntegrationBinding]:
|
||||
configured = set(allocations)
|
||||
discovered = set(scripts.script_ids)
|
||||
if configured != discovered:
|
||||
missing = len(discovered - configured)
|
||||
extra = len(configured - discovered)
|
||||
raise RuntimeIntegrationError(
|
||||
f"runtime binding coverage mismatch; missing={missing}, extra={extra}"
|
||||
)
|
||||
_validate_unique_ports(allocations.values())
|
||||
bindings: dict[str, RuntimeIntegrationBinding] = {}
|
||||
for script in scripts.scripts:
|
||||
script_id = script.script_id
|
||||
command_id = getattr(script, "command_id", script_id)
|
||||
port = allocations[script_id]
|
||||
if not isinstance(port, int):
|
||||
raise RuntimeIntegrationError(
|
||||
"runtime binding ports must be unique integers in 22000..22999"
|
||||
)
|
||||
state_root = _browser_state_root(data_root, script.module, script_id)
|
||||
bindings[command_id] = RuntimeIntegrationBinding(
|
||||
script_id=script_id,
|
||||
module=script.module,
|
||||
entry=script.entry,
|
||||
cdp_port=port,
|
||||
cdp_url=f"http://{cdp_host}:{port}",
|
||||
profile_dir=state_root / "profile",
|
||||
cookie_file=state_root / "cookies.json",
|
||||
storage_state_file=state_root / "storage_state.json",
|
||||
command_id=command_id,
|
||||
state_key=script_id,
|
||||
aliases=(script_id,),
|
||||
)
|
||||
return bindings
|
||||
|
||||
|
||||
def _load_stable_bindings(
|
||||
allocations: Mapping[str, object],
|
||||
*,
|
||||
scripts: ScriptCatalog,
|
||||
data_root: Path,
|
||||
cdp_host: str,
|
||||
) -> dict[str, RuntimeIntegrationBinding]:
|
||||
bindings: dict[str, RuntimeIntegrationBinding] = {}
|
||||
ports: list[object] = []
|
||||
state_keys: list[str] = []
|
||||
for command_id, raw_binding in allocations.items():
|
||||
if not isinstance(command_id, str) or not command_id.strip():
|
||||
raise RuntimeIntegrationError("runtime command IDs must be non-empty")
|
||||
if not isinstance(raw_binding, dict):
|
||||
raise RuntimeIntegrationError(
|
||||
f"stable runtime binding must be an object: {command_id}"
|
||||
)
|
||||
script_id = raw_binding.get("script_id")
|
||||
state_key = raw_binding.get("state_key")
|
||||
port = raw_binding.get("cdp_port")
|
||||
configured_aliases = raw_binding.get("aliases", [])
|
||||
login_mode = raw_binding.get("login_mode", "A")
|
||||
required_cookie_domains = raw_binding.get("required_cookie_domains", [])
|
||||
required_cookie_names = raw_binding.get("required_cookie_names", [])
|
||||
credential_env_names = raw_binding.get("credential_env_names", [])
|
||||
if (
|
||||
not isinstance(script_id, str)
|
||||
or ":" not in script_id
|
||||
or not isinstance(state_key, str)
|
||||
or not state_key.strip()
|
||||
or not isinstance(configured_aliases, list)
|
||||
or not all(
|
||||
isinstance(alias, str) and alias.strip()
|
||||
for alias in configured_aliases
|
||||
)
|
||||
or not isinstance(login_mode, str)
|
||||
or not isinstance(required_cookie_domains, list)
|
||||
or not isinstance(required_cookie_names, list)
|
||||
or not isinstance(credential_env_names, list)
|
||||
or not all(
|
||||
isinstance(value, str)
|
||||
for values in (
|
||||
required_cookie_domains,
|
||||
required_cookie_names,
|
||||
credential_env_names,
|
||||
)
|
||||
for value in values
|
||||
)
|
||||
):
|
||||
raise RuntimeIntegrationError(
|
||||
f"invalid stable runtime binding: {command_id}"
|
||||
)
|
||||
module, entry = script_id.split(":", 1)
|
||||
if not module or not entry:
|
||||
raise RuntimeIntegrationError(
|
||||
f"invalid stable runtime script ID: {command_id}"
|
||||
)
|
||||
ports.append(port)
|
||||
state_keys.append(state_key)
|
||||
state_root = _browser_state_root(data_root, module, state_key)
|
||||
bindings[command_id] = RuntimeIntegrationBinding(
|
||||
script_id=script_id,
|
||||
module=module,
|
||||
entry=entry,
|
||||
cdp_port=port,
|
||||
cdp_url=f"http://{cdp_host}:{port}",
|
||||
profile_dir=state_root / "profile",
|
||||
cookie_file=state_root / "cookies.json",
|
||||
storage_state_file=state_root / "storage_state.json",
|
||||
command_id=command_id,
|
||||
state_key=state_key,
|
||||
aliases=tuple(configured_aliases),
|
||||
login_mode=login_mode,
|
||||
required_cookie_domains=tuple(required_cookie_domains),
|
||||
required_cookie_names=tuple(required_cookie_names),
|
||||
credential_env_names=tuple(credential_env_names),
|
||||
)
|
||||
_validate_unique_ports(ports)
|
||||
if len(set(state_keys)) != len(state_keys):
|
||||
raise RuntimeIntegrationError("runtime binding state keys must be unique")
|
||||
|
||||
aliases = {
|
||||
alias: command_id
|
||||
for command_id, binding in bindings.items()
|
||||
for alias in (binding.script_id, *binding.aliases)
|
||||
}
|
||||
missing: list[str] = []
|
||||
mismatched: list[str] = []
|
||||
for script in scripts.scripts:
|
||||
legacy_id = getattr(script, "legacy_script_id", script.script_id)
|
||||
command_id = getattr(script, "command_id", None)
|
||||
if command_id is None:
|
||||
resolved_command_id = aliases.get(legacy_id)
|
||||
else:
|
||||
resolved_command_id = command_id if command_id in bindings else None
|
||||
if resolved_command_id is None:
|
||||
missing.append(command_id or legacy_id)
|
||||
continue
|
||||
binding = bindings[resolved_command_id]
|
||||
if binding.module != script.module or binding.entry != script.entry:
|
||||
mismatched.append(command_id or legacy_id)
|
||||
if missing or mismatched:
|
||||
raise RuntimeIntegrationError(
|
||||
"runtime binding coverage mismatch; "
|
||||
f"missing={len(missing)}, mismatched={len(mismatched)}"
|
||||
)
|
||||
return bindings
|
||||
|
||||
|
||||
def _validate_unique_ports(ports: object) -> None:
|
||||
values = list(ports) # type: ignore[arg-type]
|
||||
if (
|
||||
not all(isinstance(port, int) and 22000 <= port <= 22999 for port in values)
|
||||
or len(set(values)) != len(values)
|
||||
):
|
||||
raise RuntimeIntegrationError(
|
||||
"runtime binding ports must be unique integers in 22000..22999"
|
||||
)
|
||||
|
||||
|
||||
def _normalize_cookie_domains(values: tuple[str, ...]) -> tuple[str, ...]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeIntegrationError("required cookie domains must be strings")
|
||||
domain = value.strip().casefold().lstrip(".")
|
||||
if not domain or any(character.isspace() for character in domain):
|
||||
raise RuntimeIntegrationError("required cookie domains must be valid hosts")
|
||||
normalized.append(domain)
|
||||
return tuple(dict.fromkeys(normalized))
|
||||
|
||||
|
||||
def _normalize_binding_strings(
|
||||
values: tuple[str, ...],
|
||||
*,
|
||||
field: str,
|
||||
require_env_name: bool = False,
|
||||
) -> tuple[str, ...]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise RuntimeIntegrationError(f"{field} must be non-empty strings")
|
||||
item = value.strip()
|
||||
if require_env_name and not (
|
||||
item.replace("_", "a").isalnum() and not item[0].isdigit()
|
||||
):
|
||||
raise RuntimeIntegrationError(
|
||||
"credential environment names must be valid identifiers"
|
||||
)
|
||||
normalized.append(item)
|
||||
return tuple(dict.fromkeys(normalized))
|
||||
|
||||
|
||||
def _binding_tuple_from_environment(
|
||||
values: Mapping[str, str],
|
||||
name: str,
|
||||
) -> tuple[str, ...]:
|
||||
raw = values.get(name, "").strip()
|
||||
if not raw:
|
||||
return ()
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise RuntimeIntegrationError(f"{name} must be a JSON string array") from exc
|
||||
if not isinstance(payload, list) or not all(
|
||||
isinstance(item, str) for item in payload
|
||||
):
|
||||
raise RuntimeIntegrationError(f"{name} must be a JSON string array")
|
||||
return tuple(payload)
|
||||
|
||||
|
||||
def _browser_state_root(data_root: Path, module: str, state_key: str) -> Path:
|
||||
label = state_key.split(":", 1)[1] if ":" in state_key else state_key
|
||||
stem = Path(label).stem
|
||||
safe = "".join(character if character.isalnum() else "-" for character in stem)
|
||||
safe = safe.strip("-")[:48] or "script"
|
||||
digest = hashlib.sha256(script_id.encode("utf-8")).hexdigest()[:12]
|
||||
digest = hashlib.sha256(state_key.encode("utf-8")).hexdigest()[:12]
|
||||
return data_root / "state" / "browser" / module / f"{safe}-{digest}"
|
||||
|
||||
|
||||
@@ -284,17 +733,85 @@ def _configured_hermes_urls(environment: Mapping[str, str]) -> tuple[tuple[str,
|
||||
return tuple((name, environment[name]) for name in names if environment.get(name, "").strip())
|
||||
|
||||
|
||||
def _validate_local_database(environment: Mapping[str, str]) -> None:
|
||||
for name in ("PG_HOST", "DB_HOST", "AUTOFLOW_PG_HOST"):
|
||||
value = environment.get(name, "").strip()
|
||||
if value and not _is_loopback_host(value):
|
||||
raise RuntimeIntegrationError(f"local PostgreSQL requires loopback host in {name}")
|
||||
for name in ("DATABASE_URL", "DB_URL"):
|
||||
value = environment.get(name, "").strip()
|
||||
if value:
|
||||
host = urlparse(value).hostname
|
||||
if host is None or not _is_loopback_host(host):
|
||||
raise RuntimeIntegrationError(f"local PostgreSQL requires loopback host in {name}")
|
||||
|
||||
|
||||
def _validate_cloud_database(environment: Mapping[str, str]) -> None:
|
||||
for name in ("PG_HOST", "DB_HOST", "AUTOFLOW_PG_HOST"):
|
||||
value = environment.get(name, "").strip()
|
||||
if value and _is_loopback_host(value):
|
||||
raise RuntimeIntegrationError(f"cloud PostgreSQL cannot use loopback host in {name}")
|
||||
raise RuntimeIntegrationError(
|
||||
f"cloud PostgreSQL requires a remote host in {name}"
|
||||
)
|
||||
for name in ("DATABASE_URL", "DB_URL"):
|
||||
value = environment.get(name, "").strip()
|
||||
if value:
|
||||
host = urlparse(value).hostname
|
||||
if host is None or _is_loopback_host(host):
|
||||
raise RuntimeIntegrationError(f"cloud PostgreSQL requires a remote host in {name}")
|
||||
raise RuntimeIntegrationError(
|
||||
f"cloud PostgreSQL requires a remote host in {name}"
|
||||
)
|
||||
|
||||
|
||||
def _fill_local_database_defaults(
|
||||
environment: dict[str, str],
|
||||
*,
|
||||
host: str,
|
||||
port: int,
|
||||
database: str,
|
||||
user: str,
|
||||
) -> None:
|
||||
defaults = (
|
||||
(("PG_HOST", "DB_HOST", "AUTOFLOW_PG_HOST"), host),
|
||||
(("PG_PORT", "DB_PORT", "AUTOFLOW_PG_PORT"), str(port)),
|
||||
(("PG_DB", "DB_NAME", "AUTOFLOW_PG_DB"), database),
|
||||
(("PG_USER", "DB_USER", "AUTOFLOW_PG_USER"), user),
|
||||
)
|
||||
for aliases, value in defaults:
|
||||
for name in aliases:
|
||||
_set_if_blank(environment, name, value)
|
||||
|
||||
|
||||
def _apply_canonical_secrets(environment: dict[str, str]) -> None:
|
||||
dsn = environment.get("GYXX_POSTGRES_DSN", "").strip()
|
||||
if dsn:
|
||||
parsed = urlparse(dsn)
|
||||
if parsed.scheme not in {"postgres", "postgresql"} or not parsed.hostname:
|
||||
raise RuntimeIntegrationError("GYXX_POSTGRES_DSN must be a PostgreSQL URL")
|
||||
_set_if_blank(environment, "DATABASE_URL", dsn)
|
||||
_set_if_blank(environment, "PG_HOST", parsed.hostname)
|
||||
_set_if_blank(environment, "PG_PORT", str(parsed.port or 5432))
|
||||
_set_if_blank(environment, "PG_DB", parsed.path.lstrip("/"))
|
||||
if parsed.username:
|
||||
_set_if_blank(environment, "PG_USER", parsed.username)
|
||||
if parsed.password:
|
||||
_set_if_blank(environment, "PG_PASSWORD", parsed.password)
|
||||
|
||||
postgres_credential = environment.get("GYXX_POSTGRES_PASSWORD", "").strip()
|
||||
if postgres_credential:
|
||||
for name in ("PG_PASSWORD", "DB_PASSWORD", "AUTOFLOW_PG_PASSWORD"):
|
||||
_set_if_blank(environment, name, postgres_credential)
|
||||
|
||||
hermes_key = environment.get("GYXX_HERMES_API_KEY", "").strip()
|
||||
if hermes_key:
|
||||
_set_if_blank(environment, "HERMES_API_KEY", hermes_key)
|
||||
_set_if_blank(environment, "HERMES_ANALYZER_TOKEN", hermes_key)
|
||||
_set_if_blank(environment, "GYXX_SUPPLY_HERMES_TOKEN", hermes_key)
|
||||
|
||||
|
||||
def _set_if_blank(environment: dict[str, str], name: str, value: str) -> None:
|
||||
if not environment.get(name, "").strip():
|
||||
environment[name] = value
|
||||
|
||||
|
||||
def _fill_database_aliases(environment: dict[str, str]) -> None:
|
||||
|
||||
@@ -5,11 +5,12 @@ from __future__ import annotations
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path, PurePosixPath
|
||||
from types import MappingProxyType
|
||||
from typing import Callable, Mapping
|
||||
|
||||
from gyxx_flow.adapters.acceptance_policy import current_acceptance_policy
|
||||
from gyxx_flow.adapters.integration import RuntimeIntegrationCatalog
|
||||
from gyxx_flow.catalog import WorkflowEntry
|
||||
from gyxx_flow.core.config import Settings
|
||||
@@ -116,6 +117,17 @@ class ModuleCommandAdapter:
|
||||
"GYXX_SHADOW": "true" if context.shadow else "false",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
if entry.module == "product_commerce":
|
||||
product_paths = DataLayout(self._data_root).for_module("product_commerce")
|
||||
configured_product_config = env.get("GYXX_PRODUCT_CONFIG", "").strip()
|
||||
original_product_config = env.get("AUTO_FLOW_CONFIG", "").strip()
|
||||
portable_product_config = env.get("AUTOFLOW_CONFIG_PATH", "").strip()
|
||||
env["GYXX_PRODUCT_CONFIG"] = (
|
||||
configured_product_config
|
||||
or original_product_config
|
||||
or portable_product_config
|
||||
or str(product_paths.state_root / "auto-flow-config.json")
|
||||
)
|
||||
if entry.module == "supply_chain":
|
||||
supply_paths = DataLayout(self._data_root).for_module("supply_chain")
|
||||
env.update(
|
||||
@@ -127,9 +139,11 @@ class ModuleCommandAdapter:
|
||||
}
|
||||
)
|
||||
if self._integration_catalog is not None:
|
||||
env = self._integration_catalog.environment_for(
|
||||
f"{entry.module}:{entry.entry}", env
|
||||
)
|
||||
legacy_script_id = f"{entry.module}:{entry.entry}"
|
||||
binding = self._integration_catalog.binding_for(legacy_script_id)
|
||||
env = self._integration_catalog.environment_for(binding.command_id, env)
|
||||
acceptance_policy = current_acceptance_policy(env)
|
||||
env.update(acceptance_policy.environment())
|
||||
existing_pythonpath = env.get("PYTHONPATH", "").strip()
|
||||
env["PYTHONPATH"] = (
|
||||
f"{root}{os.pathsep}{existing_pythonpath}" if existing_pythonpath else str(root)
|
||||
@@ -142,10 +156,10 @@ ModuleCommandFactory = Callable[[WorkflowEntry, RunContext], ExecutableStep]
|
||||
|
||||
def _command_from_project(entry: WorkflowEntry, context: RunContext) -> ExecutableStep:
|
||||
package_root = Path(__file__).resolve().parents[1]
|
||||
runtime_root = package_root / "modules" / entry.module / "runtime"
|
||||
module_root = package_root / "modules" / entry.module
|
||||
settings = Settings.from_env()
|
||||
return ModuleCommandAdapter(
|
||||
ModuleSourceRoots({entry.module: runtime_root}),
|
||||
ModuleSourceRoots({entry.module: module_root}),
|
||||
project_root=settings.project_root,
|
||||
data_root=settings.data_root,
|
||||
).build(entry, context=context)
|
||||
@@ -157,10 +171,16 @@ class DeferredModuleCommandStep:
|
||||
|
||||
entry: WorkflowEntry
|
||||
command_factory: ModuleCommandFactory = _command_from_project
|
||||
command_entry: str | None = None
|
||||
command_args: tuple[str, ...] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not callable(self.command_factory):
|
||||
raise TypeError("command_factory must be callable")
|
||||
if self.command_entry is not None and not self.command_entry:
|
||||
raise ValueError("command_entry must be non-empty when provided")
|
||||
if self.command_args is not None:
|
||||
object.__setattr__(self, "command_args", tuple(self.command_args))
|
||||
|
||||
def execute(
|
||||
self,
|
||||
@@ -171,7 +191,19 @@ class DeferredModuleCommandStep:
|
||||
) -> StepExecution:
|
||||
if dry_run:
|
||||
return StepExecution(exit_code=0, skipped=True, reason="dry-run")
|
||||
command = self.command_factory(self.entry, context)
|
||||
resolved_entry = self.entry
|
||||
if self.command_entry is not None or self.command_args is not None:
|
||||
configured_args = (
|
||||
self.entry.args
|
||||
if self.command_args is None
|
||||
else self.command_args
|
||||
)
|
||||
resolved_entry = replace(
|
||||
self.entry,
|
||||
entry=self.command_entry or self.entry.entry,
|
||||
args=_render_command_args(configured_args, context),
|
||||
)
|
||||
command = self.command_factory(resolved_entry, context)
|
||||
if not callable(getattr(command, "execute", None)):
|
||||
raise TypeError("command_factory must return an executable step")
|
||||
return command.execute(
|
||||
@@ -181,6 +213,24 @@ class DeferredModuleCommandStep:
|
||||
)
|
||||
|
||||
|
||||
def _render_command_args(
|
||||
arguments: tuple[str, ...],
|
||||
context: RunContext,
|
||||
) -> tuple[str, ...]:
|
||||
replacements = {
|
||||
"{business_date}": context.business_date.isoformat(),
|
||||
"{run_id}": context.run_id,
|
||||
"{workflow_id}": context.workflow_id,
|
||||
}
|
||||
rendered = []
|
||||
for argument in arguments:
|
||||
value = argument
|
||||
for marker, replacement in replacements.items():
|
||||
value = value.replace(marker, replacement)
|
||||
rendered.append(value)
|
||||
return tuple(rendered)
|
||||
|
||||
|
||||
def _resolve_entry(root: Path, configured_entry: str) -> Path:
|
||||
if not configured_entry or "\\" in configured_entry or ":" in configured_entry:
|
||||
raise ModuleSourceError("module entry must use a safe relative path")
|
||||
|
||||
+310
-18
@@ -3,13 +3,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import time
|
||||
from datetime import date
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any, Literal
|
||||
|
||||
_WORKFLOW_ID = re.compile(r"^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$")
|
||||
_SCHEDULE_TIME = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
|
||||
_MODULES = {
|
||||
"content_marketing",
|
||||
"product_commerce",
|
||||
@@ -25,6 +27,35 @@ class CatalogError(ValueError):
|
||||
"""Raised for an invalid workflow catalog."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowDataEndpoint:
|
||||
label: str
|
||||
system: str | None = None
|
||||
detail: str | None = None
|
||||
condition: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowDataFlow:
|
||||
sources: tuple[WorkflowDataEndpoint, ...]
|
||||
processing: tuple[str, ...]
|
||||
destinations: tuple[WorkflowDataEndpoint, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowStepEntry:
|
||||
step_id: str
|
||||
entry: str
|
||||
name: str | None = None
|
||||
description: str | None = None
|
||||
args: tuple[str, ...] = ()
|
||||
depends_on: tuple[str, ...] = ()
|
||||
run_after_failure: bool = False
|
||||
timeout_seconds: float | None = None
|
||||
replay_policy: Literal["guarded", "idempotent"] | None = None
|
||||
data_flow: WorkflowDataFlow | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkflowEntry:
|
||||
workflow_id: str
|
||||
@@ -35,6 +66,7 @@ class WorkflowEntry:
|
||||
source_project: str | None = None
|
||||
source_task_name: str | None = None
|
||||
note: str | None = None
|
||||
steps: tuple[WorkflowStepEntry, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -46,6 +78,14 @@ class ScheduleEntry:
|
||||
day_of_month: int | None = None
|
||||
every_days: int | None = None
|
||||
anchor_date: str | None = None
|
||||
enabled: bool = True
|
||||
business_date_offset_days: int = 0
|
||||
at_times: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def effective_times(self) -> tuple[str, ...]:
|
||||
"""Return every configured wall-clock time, including legacy ``at``."""
|
||||
return self.at_times or (self.at,)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -59,7 +99,7 @@ class WorkflowCatalog:
|
||||
root = Path(config_dir)
|
||||
workflow_payload = _load_json(root / "workflows.json")
|
||||
schedule_payload = _load_json(root / "schedules.json")
|
||||
_require_schema(workflow_payload, "workflows.json", expected=2)
|
||||
_require_schema(workflow_payload, "workflows.json", expected=3)
|
||||
_require_schema(schedule_payload, "schedules.json", expected=1)
|
||||
workflows = tuple(_parse_workflow(item) for item in workflow_payload.get("workflows", []))
|
||||
schedules = tuple(_parse_schedule(item) for item in schedule_payload.get("schedules", []))
|
||||
@@ -144,18 +184,27 @@ def _parse_workflow(item: Any) -> WorkflowEntry:
|
||||
raise CatalogError(f"missing execution definition for {workflow_id}")
|
||||
if not isinstance(provenance, dict):
|
||||
raise CatalogError(f"invalid provenance definition for {workflow_id}")
|
||||
entry = execution.get("entry")
|
||||
if not _is_relative_entry(entry):
|
||||
raise CatalogError(f"execution entry must be relative for {workflow_id}")
|
||||
raw_steps = execution.get("steps")
|
||||
if raw_steps is None:
|
||||
entry = execution.get("entry")
|
||||
if not _is_relative_entry(entry):
|
||||
raise CatalogError(f"execution entry must be relative for {workflow_id}")
|
||||
args = _parse_args(execution.get("args", []), workflow_id)
|
||||
steps: tuple[WorkflowStepEntry, ...] = ()
|
||||
else:
|
||||
if "entry" in execution or "args" in execution:
|
||||
raise CatalogError(
|
||||
f"execution cannot mix entry and steps for {workflow_id}"
|
||||
)
|
||||
steps = _parse_workflow_steps(raw_steps, workflow_id)
|
||||
entry = steps[0].entry
|
||||
args = steps[0].args
|
||||
project = provenance.get("source_project")
|
||||
if project is not None and (not isinstance(project, str) or not project):
|
||||
raise CatalogError(f"invalid source project for {workflow_id}")
|
||||
task_name = provenance.get("task_name")
|
||||
if trigger == "scheduled" and (not isinstance(task_name, str) or not task_name):
|
||||
raise CatalogError(f"scheduled workflow requires source task name: {workflow_id}")
|
||||
args = execution.get("args", [])
|
||||
if not isinstance(args, list) or not all(isinstance(value, str) for value in args):
|
||||
raise CatalogError(f"execution args must be strings for {workflow_id}")
|
||||
return WorkflowEntry(
|
||||
workflow_id=workflow_id,
|
||||
module=module,
|
||||
@@ -165,9 +214,195 @@ def _parse_workflow(item: Any) -> WorkflowEntry:
|
||||
source_project=project,
|
||||
source_task_name=task_name,
|
||||
note=item.get("note"),
|
||||
steps=steps,
|
||||
)
|
||||
|
||||
|
||||
def _parse_workflow_steps(
|
||||
raw_steps: Any,
|
||||
workflow_id: str,
|
||||
) -> tuple[WorkflowStepEntry, ...]:
|
||||
if not isinstance(raw_steps, list) or not raw_steps:
|
||||
raise CatalogError(f"execution steps must be a non-empty list for {workflow_id}")
|
||||
steps: list[WorkflowStepEntry] = []
|
||||
for raw_step in raw_steps:
|
||||
if not isinstance(raw_step, dict):
|
||||
raise CatalogError(f"workflow step must be an object for {workflow_id}")
|
||||
step_id = raw_step.get("id")
|
||||
entry = raw_step.get("entry")
|
||||
if not isinstance(step_id, str) or not _WORKFLOW_ID.fullmatch(step_id):
|
||||
raise CatalogError(f"invalid workflow step id for {workflow_id}: {step_id!r}")
|
||||
if not _is_relative_entry(entry):
|
||||
raise CatalogError(
|
||||
f"workflow step entry must be relative for {workflow_id}.{step_id}"
|
||||
)
|
||||
depends_on = raw_step.get("depends_on", [])
|
||||
if not isinstance(depends_on, list) or not all(
|
||||
isinstance(value, str) and _WORKFLOW_ID.fullmatch(value)
|
||||
for value in depends_on
|
||||
):
|
||||
raise CatalogError(
|
||||
f"workflow step dependencies are invalid for {workflow_id}.{step_id}"
|
||||
)
|
||||
run_after_failure = raw_step.get("run_after_failure", False)
|
||||
if not isinstance(run_after_failure, bool):
|
||||
raise CatalogError(
|
||||
f"workflow step run_after_failure is invalid for {workflow_id}.{step_id}"
|
||||
)
|
||||
timeout_seconds = raw_step.get("timeout_seconds")
|
||||
if timeout_seconds is not None and (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(timeout_seconds)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise CatalogError(
|
||||
f"workflow step timeout_seconds is invalid for {workflow_id}.{step_id}"
|
||||
)
|
||||
replay_policy = raw_step.get("replay_policy")
|
||||
if replay_policy is not None and replay_policy not in {
|
||||
"guarded",
|
||||
"idempotent",
|
||||
}:
|
||||
raise CatalogError(
|
||||
f"workflow step replay_policy is invalid for {workflow_id}.{step_id}"
|
||||
)
|
||||
name = raw_step.get("name")
|
||||
if name is not None and (not isinstance(name, str) or not name.strip()):
|
||||
raise CatalogError(
|
||||
f"workflow step name is invalid for {workflow_id}.{step_id}"
|
||||
)
|
||||
description = raw_step.get("description")
|
||||
if description is not None and (
|
||||
not isinstance(description, str) or not description.strip()
|
||||
):
|
||||
raise CatalogError(
|
||||
f"workflow step description is invalid for {workflow_id}.{step_id}"
|
||||
)
|
||||
steps.append(
|
||||
WorkflowStepEntry(
|
||||
step_id=step_id,
|
||||
entry=entry,
|
||||
name=name.strip() if name is not None else None,
|
||||
description=description.strip() if description is not None else None,
|
||||
args=_parse_args(raw_step.get("args", []), workflow_id),
|
||||
depends_on=tuple(depends_on),
|
||||
run_after_failure=run_after_failure,
|
||||
timeout_seconds=(
|
||||
float(timeout_seconds) if timeout_seconds is not None else None
|
||||
),
|
||||
replay_policy=replay_policy,
|
||||
data_flow=_parse_step_data_flow(
|
||||
raw_step.get("data_flow"),
|
||||
workflow_id,
|
||||
step_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
step_ids = [step.step_id for step in steps]
|
||||
duplicates = _duplicates(step_ids)
|
||||
if duplicates:
|
||||
raise CatalogError(
|
||||
f"duplicate workflow step id for {workflow_id}: {sorted(duplicates)[0]}"
|
||||
)
|
||||
known = set(step_ids)
|
||||
for step in steps:
|
||||
unknown = set(step.depends_on) - known
|
||||
if unknown:
|
||||
raise CatalogError(
|
||||
f"unknown workflow step dependency for {workflow_id}.{step.step_id}: "
|
||||
f"{sorted(unknown)[0]}"
|
||||
)
|
||||
_validate_step_graph(steps, workflow_id)
|
||||
return tuple(steps)
|
||||
|
||||
|
||||
def _parse_step_data_flow(
|
||||
value: Any,
|
||||
workflow_id: str,
|
||||
step_id: str,
|
||||
) -> WorkflowDataFlow | None:
|
||||
if value is None:
|
||||
return None
|
||||
label = f"{workflow_id}.{step_id}"
|
||||
if not isinstance(value, dict):
|
||||
raise CatalogError(f"workflow step data_flow must be an object for {label}")
|
||||
allowed = {"sources", "processing", "destinations"}
|
||||
if set(value) != allowed:
|
||||
raise CatalogError(f"workflow step data_flow fields are invalid for {label}")
|
||||
processing = value.get("processing")
|
||||
if not isinstance(processing, list) or not processing or not all(
|
||||
isinstance(item, str) and item.strip() for item in processing
|
||||
):
|
||||
raise CatalogError(f"workflow step processing is invalid for {label}")
|
||||
return WorkflowDataFlow(
|
||||
sources=_parse_data_endpoints(value.get("sources"), label, "sources"),
|
||||
processing=tuple(item.strip() for item in processing),
|
||||
destinations=_parse_data_endpoints(
|
||||
value.get("destinations"),
|
||||
label,
|
||||
"destinations",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _parse_data_endpoints(
|
||||
value: Any,
|
||||
step_label: str,
|
||||
field: str,
|
||||
) -> tuple[WorkflowDataEndpoint, ...]:
|
||||
if not isinstance(value, list) or not value:
|
||||
raise CatalogError(f"workflow step {field} is invalid for {step_label}")
|
||||
endpoints: list[WorkflowDataEndpoint] = []
|
||||
allowed = {"label", "system", "detail", "condition"}
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or set(item) - allowed:
|
||||
raise CatalogError(f"workflow step {field} is invalid for {step_label}")
|
||||
endpoint_label = item.get("label")
|
||||
if not isinstance(endpoint_label, str) or not endpoint_label.strip():
|
||||
raise CatalogError(f"workflow step {field} is invalid for {step_label}")
|
||||
optional: dict[str, str | None] = {}
|
||||
for key in ("system", "detail", "condition"):
|
||||
raw = item.get(key)
|
||||
if raw is not None and (not isinstance(raw, str) or not raw.strip()):
|
||||
raise CatalogError(f"workflow step {field} is invalid for {step_label}")
|
||||
optional[key] = raw.strip() if raw is not None else None
|
||||
endpoints.append(
|
||||
WorkflowDataEndpoint(
|
||||
label=endpoint_label.strip(),
|
||||
system=optional["system"],
|
||||
detail=optional["detail"],
|
||||
condition=optional["condition"],
|
||||
)
|
||||
)
|
||||
return tuple(endpoints)
|
||||
|
||||
|
||||
def _parse_args(value: Any, workflow_id: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
||||
raise CatalogError(f"execution args must be strings for {workflow_id}")
|
||||
return tuple(value)
|
||||
|
||||
|
||||
def _validate_step_graph(
|
||||
steps: list[WorkflowStepEntry],
|
||||
workflow_id: str,
|
||||
) -> None:
|
||||
unresolved = {step.step_id: set(step.depends_on) for step in steps}
|
||||
completed: set[str] = set()
|
||||
while unresolved:
|
||||
ready = [
|
||||
step_id
|
||||
for step_id, dependencies in unresolved.items()
|
||||
if dependencies <= completed
|
||||
]
|
||||
if not ready:
|
||||
raise CatalogError(f"workflow step dependency cycle for {workflow_id}")
|
||||
completed.update(ready)
|
||||
for step_id in ready:
|
||||
del unresolved[step_id]
|
||||
|
||||
|
||||
def _is_relative_entry(entry: Any) -> bool:
|
||||
if not isinstance(entry, str) or not entry or "\\" in entry or ":" in entry:
|
||||
return False
|
||||
@@ -180,24 +415,78 @@ def _parse_schedule(item: Any) -> ScheduleEntry:
|
||||
raise CatalogError("schedule entry must be an object")
|
||||
workflow_id = item.get("workflow_id")
|
||||
kind = item.get("kind")
|
||||
at = item.get("at")
|
||||
raw_at = item.get("at")
|
||||
if not isinstance(workflow_id, str) or not _WORKFLOW_ID.fullmatch(workflow_id):
|
||||
raise CatalogError(f"invalid schedule workflow id: {workflow_id!r}")
|
||||
if kind not in _KINDS:
|
||||
raise CatalogError(f"invalid schedule kind for {workflow_id}: {kind!r}")
|
||||
try:
|
||||
time.fromisoformat(at)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CatalogError(f"invalid schedule time for {workflow_id}: {at!r}") from exc
|
||||
days = tuple(item.get("days", []))
|
||||
if kind == "weekly" and (not days or not set(days) <= _WEEKDAYS):
|
||||
if isinstance(raw_at, str):
|
||||
if not _SCHEDULE_TIME.fullmatch(raw_at):
|
||||
raise CatalogError(f"invalid schedule time for {workflow_id}: {raw_at!r}")
|
||||
at = raw_at
|
||||
at_times: tuple[str, ...] = ()
|
||||
elif isinstance(raw_at, list):
|
||||
if (
|
||||
not raw_at
|
||||
or not all(
|
||||
isinstance(value, str) and _SCHEDULE_TIME.fullmatch(value)
|
||||
for value in raw_at
|
||||
)
|
||||
or len(raw_at) != len(set(raw_at))
|
||||
):
|
||||
raise CatalogError(f"invalid schedule times for {workflow_id}: {raw_at!r}")
|
||||
at_times = tuple(raw_at)
|
||||
at = at_times[0]
|
||||
else:
|
||||
raise CatalogError(f"invalid schedule time for {workflow_id}: {raw_at!r}")
|
||||
raw_days = item.get("days", [])
|
||||
if not isinstance(raw_days, list) or not all(
|
||||
isinstance(day, str) for day in raw_days
|
||||
):
|
||||
raise CatalogError(f"invalid weekly days for {workflow_id}")
|
||||
days = tuple(raw_days)
|
||||
if kind == "weekly" and (
|
||||
not days
|
||||
or len(days) != len(set(days))
|
||||
or not set(days) <= _WEEKDAYS
|
||||
):
|
||||
raise CatalogError(f"invalid weekly days for {workflow_id}")
|
||||
day_of_month = item.get("day_of_month")
|
||||
if kind == "monthly" and (not isinstance(day_of_month, int) or not 1 <= day_of_month <= 31):
|
||||
if kind == "monthly" and (
|
||||
isinstance(day_of_month, bool)
|
||||
or not isinstance(day_of_month, int)
|
||||
or not 1 <= day_of_month <= 31
|
||||
):
|
||||
raise CatalogError(f"invalid day_of_month for {workflow_id}")
|
||||
every_days = item.get("every_days")
|
||||
if kind == "interval_days" and (not isinstance(every_days, int) or every_days < 1):
|
||||
if kind == "interval_days" and (
|
||||
isinstance(every_days, bool)
|
||||
or not isinstance(every_days, int)
|
||||
or every_days < 1
|
||||
):
|
||||
raise CatalogError(f"invalid every_days for {workflow_id}")
|
||||
anchor_date = item.get("anchor_date")
|
||||
if kind == "interval_days":
|
||||
try:
|
||||
parsed_anchor = date.fromisoformat(anchor_date)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise CatalogError(
|
||||
f"invalid anchor_date for {workflow_id}: {anchor_date!r}"
|
||||
) from exc
|
||||
if parsed_anchor.isoformat() != anchor_date:
|
||||
raise CatalogError(
|
||||
f"invalid anchor_date for {workflow_id}: {anchor_date!r}"
|
||||
)
|
||||
enabled = item.get("enabled", True)
|
||||
if not isinstance(enabled, bool):
|
||||
raise CatalogError(f"invalid enabled flag for {workflow_id}")
|
||||
business_date_offset_days = item.get("business_date_offset_days", 0)
|
||||
if (
|
||||
isinstance(business_date_offset_days, bool)
|
||||
or not isinstance(business_date_offset_days, int)
|
||||
or not -31 <= business_date_offset_days <= 31
|
||||
):
|
||||
raise CatalogError(f"invalid business date offset for {workflow_id}")
|
||||
return ScheduleEntry(
|
||||
workflow_id=workflow_id,
|
||||
kind=kind,
|
||||
@@ -205,7 +494,10 @@ def _parse_schedule(item: Any) -> ScheduleEntry:
|
||||
days=days,
|
||||
day_of_month=day_of_month,
|
||||
every_days=every_days,
|
||||
anchor_date=item.get("anchor_date"),
|
||||
anchor_date=anchor_date,
|
||||
enabled=enabled,
|
||||
business_date_offset_days=business_date_offset_days,
|
||||
at_times=at_times,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+282
-90
@@ -5,13 +5,17 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from collections.abc import Sequence
|
||||
from contextlib import suppress
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import TextIO
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from gyxx_flow.acceptance import build_acceptance_report
|
||||
from gyxx_flow.adapters.native import DeferredModuleCommandStep
|
||||
@@ -24,15 +28,14 @@ from gyxx_flow.core.locks import LockManager
|
||||
from gyxx_flow.core.records import RunJournal
|
||||
from gyxx_flow.diagnostics import run_doctor
|
||||
from gyxx_flow.modules import create_default_registry as create_module_registry
|
||||
from gyxx_flow.ops import EffectLedger, RunIndex
|
||||
from gyxx_flow.scheduler import (
|
||||
PowerShellCurrentTaskProvider,
|
||||
SchedulerConfig,
|
||||
build_schedule_plan,
|
||||
detect_schedule_drift,
|
||||
write_schedule_plan_bundle,
|
||||
from gyxx_flow.ops import EffectLedger, EffectStateAmbiguous, RunIndex
|
||||
from gyxx_flow.scheduler_service import (
|
||||
PythonScheduler,
|
||||
SchedulerInstanceLock,
|
||||
SubprocessWorkflowLauncher,
|
||||
)
|
||||
from gyxx_flow.script_catalog import ScriptCatalog, ScriptCatalogError
|
||||
from gyxx_flow.source_sync.cli import add_sources_parser, run_sources_command
|
||||
from gyxx_flow.workflow.engine import WorkflowEngine, WorkflowRunResult
|
||||
from gyxx_flow.workflow.model import StepDefinition, WorkflowDefinition
|
||||
from gyxx_flow.workflow.registry import WorkflowRegistry, WorkflowRegistryError
|
||||
@@ -64,11 +67,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
list_parser = commands.add_parser("list", help="list catalog workflows")
|
||||
add_sources_parser(commands)
|
||||
|
||||
list_parser = commands.add_parser("list", help="list scheduled workflows")
|
||||
list_parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
|
||||
scripts_parser = commands.add_parser(
|
||||
"scripts", help="discover and manually run project-owned module scripts"
|
||||
"scripts", help="list and manually run explicitly registered commands"
|
||||
)
|
||||
script_commands = scripts_parser.add_subparsers(dest="scripts_command", required=True)
|
||||
scripts_list_parser = script_commands.add_parser("list", help="list runnable scripts")
|
||||
@@ -76,9 +81,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
scripts_list_parser.add_argument("--json", action="store_true")
|
||||
scripts_run_parser = script_commands.add_parser("run", help="run one local script")
|
||||
scripts_run_parser.add_argument("script_id")
|
||||
scripts_run_date = scripts_run_parser.add_mutually_exclusive_group(required=True)
|
||||
scripts_run_date.add_argument("--date", help="business date (YYYY-MM-DD)")
|
||||
scripts_run_date.add_argument("--scheduled", action="store_true")
|
||||
scripts_run_parser.add_argument(
|
||||
"--date", required=True, help="business date (YYYY-MM-DD)"
|
||||
)
|
||||
scripts_run_parser.add_argument("--execute", action="store_true")
|
||||
scripts_run_parser.add_argument("--shadow", action="store_true")
|
||||
scripts_run_parser.add_argument(
|
||||
@@ -89,14 +94,35 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
help="one argument passed to the script; repeat for multiple arguments",
|
||||
)
|
||||
|
||||
effects_parser = commands.add_parser(
|
||||
"effects", help="inspect or reconcile guarded production effects"
|
||||
)
|
||||
effect_commands = effects_parser.add_subparsers(
|
||||
dest="effects_command", required=True
|
||||
)
|
||||
reconcile_parser = effect_commands.add_parser(
|
||||
"reconcile", help="resolve one verified ambiguous effect receipt"
|
||||
)
|
||||
reconcile_parser.add_argument("workflow_id")
|
||||
reconcile_parser.add_argument("--date", required=True, dest="business_date")
|
||||
reconcile_parser.add_argument("--step", required=True, dest="step_id")
|
||||
reconcile_parser.add_argument("--expected-run-id", required=True)
|
||||
reconcile_parser.add_argument(
|
||||
"--action", required=True, choices=("retry", "applied")
|
||||
)
|
||||
reconcile_parser.add_argument("--operator", required=True)
|
||||
reconcile_parser.add_argument("--reason", required=True)
|
||||
reconcile_parser.add_argument("--evidence", required=True)
|
||||
reconcile_parser.add_argument(
|
||||
"--execute",
|
||||
action="store_true",
|
||||
help="confirm the audited receipt mutation",
|
||||
)
|
||||
|
||||
run_parser = commands.add_parser("run", help="run one workflow business date")
|
||||
_add_execution_arguments(run_parser)
|
||||
run_date = run_parser.add_mutually_exclusive_group(required=True)
|
||||
run_date.add_argument("--date", help="business date (YYYY-MM-DD)")
|
||||
run_date.add_argument(
|
||||
"--scheduled",
|
||||
action="store_true",
|
||||
help="execute for today's Asia/Shanghai business date",
|
||||
run_parser.add_argument(
|
||||
"--date", required=True, help="business date (YYYY-MM-DD)"
|
||||
)
|
||||
|
||||
backfill_parser = commands.add_parser(
|
||||
@@ -110,16 +136,42 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
"--to", dest="to_date", required=True, help="last business date"
|
||||
)
|
||||
|
||||
schedule_parser = commands.add_parser("schedule", help="plan managed schedules")
|
||||
schedule_parser = commands.add_parser(
|
||||
"schedule", help="run the project-owned resident scheduler"
|
||||
)
|
||||
schedule_commands = schedule_parser.add_subparsers(
|
||||
dest="schedule_command", required=True
|
||||
)
|
||||
plan_parser = schedule_commands.add_parser(
|
||||
"plan", help="write an inert Task Scheduler bundle and drift report"
|
||||
run_schedule_parser = schedule_commands.add_parser(
|
||||
"run", help="run the cross-platform resident Python scheduler"
|
||||
)
|
||||
run_schedule_parser.add_argument("--python-executable", type=Path)
|
||||
run_schedule_parser.add_argument("--poll-seconds", type=float, default=15.0)
|
||||
run_schedule_parser.add_argument("--misfire-grace-seconds", type=int, default=21600)
|
||||
run_schedule_parser.add_argument("--shutdown-timeout-seconds", type=float, default=300.0)
|
||||
run_schedule_parser.add_argument("--once", action="store_true")
|
||||
run_schedule_parser.add_argument("--dry-run", action="store_true")
|
||||
run_schedule_parser.add_argument(
|
||||
"--env-file",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
help="explicit runtime environment file; repeatable, existing process values win",
|
||||
)
|
||||
schedule_commands.add_parser("status", help="show persisted Python scheduler state")
|
||||
|
||||
console_parser = commands.add_parser(
|
||||
"console", help="serve the workflow operator web console"
|
||||
)
|
||||
console_parser.add_argument("--host", default="127.0.0.1")
|
||||
console_parser.add_argument("--port", type=int, default=8765)
|
||||
console_parser.add_argument(
|
||||
"--env-file",
|
||||
action="append",
|
||||
default=[],
|
||||
type=Path,
|
||||
help="explicit runtime environment file; repeatable, existing process values win",
|
||||
)
|
||||
plan_parser.add_argument("--output", type=Path)
|
||||
plan_parser.add_argument("--start-date", help="trigger boundary date (YYYY-MM-DD)")
|
||||
plan_parser.add_argument("--python-executable", type=Path, required=True)
|
||||
|
||||
doctor_parser = commands.add_parser("doctor", help="run environment preflight checks")
|
||||
doctor_parser.add_argument("--json", action="store_true")
|
||||
@@ -167,7 +219,6 @@ def main(
|
||||
settings: Settings | None = None,
|
||||
stdout: TextIO | None = None,
|
||||
stderr: TextIO | None = None,
|
||||
current_task_provider: PowerShellCurrentTaskProvider | None = None,
|
||||
) -> int:
|
||||
"""Execute a CLI command and return a stable process exit code."""
|
||||
|
||||
@@ -179,10 +230,19 @@ def main(
|
||||
except SystemExit as exc:
|
||||
return int(exc.code)
|
||||
|
||||
resolved_settings = settings or Settings.from_env()
|
||||
try:
|
||||
_load_runtime_environment_files(arguments)
|
||||
resolved_settings = settings or Settings.from_env()
|
||||
if arguments.command == "doctor":
|
||||
return _doctor_command(arguments, resolved_settings, output)
|
||||
if arguments.command == "console":
|
||||
return _console_command(arguments, resolved_settings, output)
|
||||
if arguments.command == "sources":
|
||||
return run_sources_command(
|
||||
arguments,
|
||||
project_root=resolved_settings.project_root,
|
||||
output=output,
|
||||
)
|
||||
if (
|
||||
arguments.command == "acceptance"
|
||||
and arguments.acceptance_command == "status"
|
||||
@@ -192,6 +252,13 @@ def main(
|
||||
return _list_scripts(arguments, output)
|
||||
if arguments.command == "scripts" and arguments.scripts_command == "run":
|
||||
return _run_script(arguments, resolved_settings, output)
|
||||
if (
|
||||
arguments.command == "effects"
|
||||
and arguments.effects_command == "reconcile"
|
||||
):
|
||||
return _effects_reconcile_command(
|
||||
arguments, resolved_settings, output
|
||||
)
|
||||
resolved_registry = registry or build_default_registry(resolved_settings)
|
||||
if arguments.command == "list":
|
||||
return _list_workflows(arguments, resolved_registry, output)
|
||||
@@ -199,14 +266,10 @@ def main(
|
||||
return _run_command(arguments, resolved_registry, resolved_settings, output)
|
||||
if arguments.command == "backfill":
|
||||
return _backfill_command(arguments, resolved_registry, resolved_settings, output)
|
||||
if arguments.command == "schedule" and arguments.schedule_command == "plan":
|
||||
return _schedule_plan_command(
|
||||
arguments,
|
||||
resolved_registry.catalog,
|
||||
resolved_settings,
|
||||
output,
|
||||
current_task_provider=current_task_provider,
|
||||
)
|
||||
if arguments.command == "schedule" and arguments.schedule_command == "run":
|
||||
return _schedule_run_command(arguments, resolved_registry.catalog, resolved_settings, output)
|
||||
if arguments.command == "schedule" and arguments.schedule_command == "status":
|
||||
return _schedule_status_command(resolved_registry.catalog, resolved_settings, output)
|
||||
raise CliConfigurationError(f"unsupported command: {arguments.command}")
|
||||
except (
|
||||
CatalogError,
|
||||
@@ -222,6 +285,26 @@ def main(
|
||||
return EXIT_RUNTIME
|
||||
|
||||
|
||||
def _load_runtime_environment_files(arguments: argparse.Namespace) -> None:
|
||||
"""Load only explicitly named service environment files without leaking values."""
|
||||
|
||||
raw_paths = getattr(arguments, "env_file", ()) or ()
|
||||
for raw_path in raw_paths:
|
||||
path = Path(raw_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise CliConfigurationError(f"runtime environment file not found: {path}")
|
||||
try:
|
||||
values = dotenv_values(path, encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise CliConfigurationError(
|
||||
f"cannot read runtime environment file: {path}"
|
||||
) from exc
|
||||
for key, value in values.items():
|
||||
if not isinstance(key, str) or not key or value is None:
|
||||
continue
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
def _doctor_command(
|
||||
arguments: argparse.Namespace, settings: Settings, output: TextIO
|
||||
) -> int:
|
||||
@@ -235,6 +318,48 @@ def _doctor_command(
|
||||
return EXIT_SUCCESS if report.is_healthy else EXIT_RUNTIME
|
||||
|
||||
|
||||
def _effects_reconcile_command(
|
||||
arguments: argparse.Namespace,
|
||||
settings: Settings,
|
||||
output: TextIO,
|
||||
) -> int:
|
||||
if not arguments.execute:
|
||||
raise CliConfigurationError("effect reconciliation requires --execute")
|
||||
try:
|
||||
result = EffectLedger(settings.data_root).reconcile(
|
||||
workflow_id=arguments.workflow_id,
|
||||
business_date=arguments.business_date,
|
||||
step_id=arguments.step_id,
|
||||
expected_run_id=arguments.expected_run_id,
|
||||
action=arguments.action,
|
||||
operator=arguments.operator,
|
||||
reason=arguments.reason,
|
||||
evidence=arguments.evidence,
|
||||
)
|
||||
except EffectStateAmbiguous as exc:
|
||||
raise CliConfigurationError(str(exc)) from exc
|
||||
_write_json(output, result)
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
def _console_command(
|
||||
arguments: argparse.Namespace,
|
||||
settings: Settings,
|
||||
output: TextIO,
|
||||
) -> int:
|
||||
from gyxx_flow.console import serve_console
|
||||
|
||||
try:
|
||||
return serve_console(
|
||||
settings,
|
||||
host=arguments.host,
|
||||
port=arguments.port,
|
||||
output=output,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise CliRuntimeError("cannot start workflow console") from exc
|
||||
|
||||
|
||||
def _acceptance_status_command(
|
||||
arguments: argparse.Namespace, settings: Settings, output: TextIO
|
||||
) -> int:
|
||||
@@ -264,14 +389,19 @@ def _list_workflows(
|
||||
"module": entry.module,
|
||||
"registered": registry.is_registered(entry.workflow_id),
|
||||
"trigger": entry.trigger,
|
||||
"enabled": registry.catalog.schedule_for(entry.workflow_id).enabled,
|
||||
}
|
||||
for entry in registry.catalog.workflows
|
||||
for entry in registry.catalog.scheduled_workflows()
|
||||
]
|
||||
if arguments.json:
|
||||
_write_json(output, rows)
|
||||
else:
|
||||
for row in rows:
|
||||
state = "ready" if row["registered"] else "not-registered"
|
||||
state = (
|
||||
"not-registered"
|
||||
if not row["registered"]
|
||||
else "ready" if row["enabled"] else "disabled"
|
||||
)
|
||||
output.write(
|
||||
f"{row['id']}\t{row['module']}\t{row['trigger']}\t{state}\n"
|
||||
)
|
||||
@@ -279,13 +409,16 @@ def _list_workflows(
|
||||
|
||||
|
||||
def _list_scripts(arguments: argparse.Namespace, output: TextIO) -> int:
|
||||
catalog = ScriptCatalog.discover_default()
|
||||
catalog = ScriptCatalog.load_default()
|
||||
rows = [
|
||||
{
|
||||
"id": script.script_id,
|
||||
"id": script.command_id,
|
||||
"command_id": script.command_id,
|
||||
"legacy_script_id": script.legacy_script_id,
|
||||
"module": script.module,
|
||||
"entry": script.entry,
|
||||
"kind": script.kind,
|
||||
"default_args": list(script.default_args),
|
||||
}
|
||||
for script in catalog.scripts
|
||||
if arguments.module is None or script.module == arguments.module
|
||||
@@ -305,10 +438,19 @@ def _run_script(
|
||||
settings: Settings,
|
||||
output: TextIO,
|
||||
) -> int:
|
||||
script = ScriptCatalog.discover_default().get(arguments.script_id)
|
||||
suffix = hashlib.sha256(script.script_id.encode("utf-8")).hexdigest()[:12]
|
||||
script = ScriptCatalog.load_default().get(arguments.script_id)
|
||||
business_date = _parse_date(arguments.date)
|
||||
script_args = (
|
||||
*_render_script_default_args(script.default_args, business_date),
|
||||
*arguments.script_args,
|
||||
)
|
||||
invocation_identity = json.dumps(
|
||||
[script.command_id, *script_args],
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
suffix = hashlib.sha256(invocation_identity.encode("utf-8")).hexdigest()[:12]
|
||||
workflow_id = f"script.{script.module}.{suffix}"
|
||||
script_args = tuple(arguments.script_args)
|
||||
entry = WorkflowEntry(
|
||||
workflow_id=workflow_id,
|
||||
module=script.module,
|
||||
@@ -329,19 +471,28 @@ def _run_script(
|
||||
),
|
||||
),
|
||||
)
|
||||
business_date = _today_shanghai() if arguments.scheduled else _parse_date(arguments.date)
|
||||
exit_code, payload = _execute_once(
|
||||
workflow,
|
||||
business_date=business_date,
|
||||
shadow=arguments.shadow,
|
||||
dry_run=not (arguments.execute or arguments.scheduled),
|
||||
dry_run=not arguments.execute,
|
||||
settings=settings,
|
||||
)
|
||||
payload["script_id"] = script.script_id
|
||||
payload["command_id"] = script.command_id
|
||||
payload["script_id"] = script.legacy_script_id
|
||||
_write_json(output, payload)
|
||||
return exit_code
|
||||
|
||||
|
||||
def _render_script_default_args(
|
||||
arguments: tuple[str, ...], business_date: date
|
||||
) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
argument.replace("{business_date}", business_date.isoformat())
|
||||
for argument in arguments
|
||||
)
|
||||
|
||||
|
||||
def _run_command(
|
||||
arguments: argparse.Namespace,
|
||||
registry: WorkflowRegistry,
|
||||
@@ -350,8 +501,8 @@ def _run_command(
|
||||
) -> int:
|
||||
registered = registry.resolve(arguments.workflow_id)
|
||||
workflow = _select_workflow(registered.definition, arguments)
|
||||
business_date = _today_shanghai() if arguments.scheduled else _parse_date(arguments.date)
|
||||
dry_run = not (arguments.execute or arguments.scheduled)
|
||||
business_date = _parse_date(arguments.date)
|
||||
dry_run = not arguments.execute
|
||||
exit_code, payload = _execute_once(
|
||||
workflow,
|
||||
business_date=business_date,
|
||||
@@ -363,51 +514,91 @@ def _run_command(
|
||||
return exit_code
|
||||
|
||||
|
||||
def _schedule_plan_command(
|
||||
def _python_scheduler(
|
||||
catalog: WorkflowCatalog,
|
||||
settings: Settings,
|
||||
*,
|
||||
python_executable: Path | None = None,
|
||||
misfire_grace_seconds: int = 21600,
|
||||
dry_run: bool = False,
|
||||
) -> PythonScheduler:
|
||||
launcher = SubprocessWorkflowLauncher(
|
||||
settings.project_root, settings.data_root, python_executable=python_executable
|
||||
)
|
||||
return PythonScheduler(
|
||||
catalog,
|
||||
settings.data_root,
|
||||
launcher=launcher,
|
||||
misfire_grace_seconds=misfire_grace_seconds,
|
||||
dry_run=dry_run,
|
||||
catalog_loader=lambda: WorkflowCatalog.load(
|
||||
settings.project_root / "config"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _schedule_run_command(
|
||||
arguments: argparse.Namespace,
|
||||
catalog: WorkflowCatalog,
|
||||
settings: Settings,
|
||||
output: TextIO,
|
||||
*,
|
||||
current_task_provider: PowerShellCurrentTaskProvider | None,
|
||||
) -> int:
|
||||
start_date = (
|
||||
_parse_date(arguments.start_date)
|
||||
if arguments.start_date
|
||||
else _today_shanghai()
|
||||
)
|
||||
destination = arguments.output
|
||||
if destination is None:
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
destination = settings.data_root / "evidence" / "schedule-plans" / timestamp
|
||||
config = SchedulerConfig(
|
||||
if arguments.poll_seconds <= 0:
|
||||
raise CliConfigurationError("poll seconds must be positive")
|
||||
if arguments.shutdown_timeout_seconds < 0:
|
||||
raise CliConfigurationError("shutdown timeout must be non-negative")
|
||||
scheduler = _python_scheduler(
|
||||
catalog,
|
||||
settings,
|
||||
python_executable=arguments.python_executable,
|
||||
project_root=settings.project_root,
|
||||
misfire_grace_seconds=arguments.misfire_grace_seconds,
|
||||
dry_run=arguments.dry_run,
|
||||
)
|
||||
plan = build_schedule_plan(catalog, config, start_date=start_date)
|
||||
lock_path = settings.data_root / "state" / "scheduler" / "service.lock"
|
||||
try:
|
||||
bundle = write_schedule_plan_bundle(destination, plan)
|
||||
except FileExistsError as exc:
|
||||
raise CliConfigurationError(
|
||||
f"schedule plan destination already exists: {destination}"
|
||||
) from exc
|
||||
provider = current_task_provider or PowerShellCurrentTaskProvider()
|
||||
try:
|
||||
current = provider.current_tasks(config.task_path)
|
||||
except Exception as exc:
|
||||
raise CliRuntimeError("cannot read current managed scheduled tasks") from exc
|
||||
drift = detect_schedule_drift(plan, current)
|
||||
atomic_write_json(bundle / "drift.json", drift.as_dict())
|
||||
_write_json(
|
||||
output,
|
||||
{
|
||||
"path": str(bundle),
|
||||
"desired_count": len(plan.tasks),
|
||||
"drift_count": len(drift.items),
|
||||
"is_clean": drift.is_clean,
|
||||
"applied": False,
|
||||
},
|
||||
)
|
||||
with SchedulerInstanceLock(lock_path):
|
||||
first = scheduler.tick()
|
||||
if arguments.once or arguments.dry_run:
|
||||
if not arguments.dry_run:
|
||||
scheduler.wait_for_active(arguments.shutdown_timeout_seconds)
|
||||
_write_json(output, {
|
||||
"mode": "dry-run" if arguments.dry_run else "once",
|
||||
"due_or_started": first,
|
||||
"state": scheduler.status(),
|
||||
})
|
||||
return EXIT_SUCCESS
|
||||
|
||||
stop = threading.Event()
|
||||
previous_handlers: dict[int, object] = {}
|
||||
|
||||
def request_stop(_signum, _frame) -> None: # type: ignore[no-untyped-def]
|
||||
stop.set()
|
||||
|
||||
for signum in (signal.SIGINT, signal.SIGTERM):
|
||||
previous_handlers[signum] = signal.getsignal(signum)
|
||||
signal.signal(signum, request_stop)
|
||||
try:
|
||||
while not stop.wait(arguments.poll_seconds):
|
||||
scheduler.tick()
|
||||
finally:
|
||||
for signum, handler in previous_handlers.items():
|
||||
signal.signal(signum, handler)
|
||||
scheduler.wait_for_active(arguments.shutdown_timeout_seconds)
|
||||
_write_json(output, {"mode": "service", "state": scheduler.status()})
|
||||
return EXIT_SUCCESS
|
||||
except RuntimeError as exc:
|
||||
raise CliRuntimeError(str(exc)) from exc
|
||||
|
||||
|
||||
def _schedule_status_command(
|
||||
catalog: WorkflowCatalog, settings: Settings, output: TextIO
|
||||
) -> int:
|
||||
scheduler = _python_scheduler(catalog, settings)
|
||||
_write_json(output, {
|
||||
"timezone": catalog.timezone,
|
||||
"scheduled_count": len(catalog.scheduled_workflows()),
|
||||
"state": scheduler.status(),
|
||||
})
|
||||
return EXIT_SUCCESS
|
||||
|
||||
|
||||
@@ -481,7 +672,12 @@ def _execute_once(
|
||||
layout = DataLayout(settings.data_root)
|
||||
journal: RunJournal | None = None
|
||||
try:
|
||||
journal = RunJournal.create(layout, context)
|
||||
journal = RunJournal.create(
|
||||
layout,
|
||||
context,
|
||||
mode="dry_run" if dry_run else "execute",
|
||||
)
|
||||
RunIndex(settings.data_root).index_journal(journal)
|
||||
result = WorkflowEngine(
|
||||
LockManager(settings.data_root / "state" / "locks"),
|
||||
effect_ledger=EffectLedger(settings.data_root),
|
||||
@@ -536,10 +732,6 @@ def _parse_date(value: str) -> date:
|
||||
return parsed
|
||||
|
||||
|
||||
def _today_shanghai() -> date:
|
||||
return datetime.now(ZoneInfo("Asia/Shanghai")).date()
|
||||
|
||||
|
||||
def _write_json(output: TextIO, payload: object) -> None:
|
||||
json.dump(payload, output, ensure_ascii=False, sort_keys=True)
|
||||
output.write("\n")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
"""Reserved process exit codes shared by framework and runtime adapters."""
|
||||
|
||||
COOKIE_SKIP_EXIT_CODE = 75
|
||||
|
||||
__all__ = ["COOKIE_SKIP_EXIT_CODE"]
|
||||
+304
-17
@@ -2,23 +2,193 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import psutil
|
||||
|
||||
_RESOURCE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]*$")
|
||||
_OWNER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9:._-]*$")
|
||||
_PARTIAL_PID = re.compile(r'["\']pid["\']\s*:\s*(?P<pid>\d+)')
|
||||
_PARTIAL_PROCESS_STARTED_AT = re.compile(
|
||||
r'["\']process_started_at["\']\s*:\s*(?P<started>\d+(?:\.\d+)?)'
|
||||
)
|
||||
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
_STILL_ACTIVE = 259
|
||||
_ERROR_INVALID_PARAMETER = 87
|
||||
_MALFORMED_LOCK_GRACE_SECONDS = 60.0
|
||||
_PROCESS_START_TOLERANCE_SECONDS = 1.0
|
||||
_RELEASE_GUARD_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
class ResourceBusyError(RuntimeError):
|
||||
"""Raised when a named resource cannot be acquired before its deadline."""
|
||||
|
||||
|
||||
def _process_is_running(pid: int) -> bool | None:
|
||||
"""Return process liveness, or ``None`` when it cannot be proven safely."""
|
||||
|
||||
if pid <= 0:
|
||||
return None
|
||||
if os.name == "nt":
|
||||
from ctypes import wintypes
|
||||
|
||||
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
kernel32.OpenProcess.argtypes = (wintypes.DWORD, wintypes.BOOL, wintypes.DWORD)
|
||||
kernel32.OpenProcess.restype = wintypes.HANDLE
|
||||
kernel32.GetExitCodeProcess.argtypes = (
|
||||
wintypes.HANDLE,
|
||||
ctypes.POINTER(wintypes.DWORD),
|
||||
)
|
||||
kernel32.GetExitCodeProcess.restype = wintypes.BOOL
|
||||
kernel32.CloseHandle.argtypes = (wintypes.HANDLE,)
|
||||
kernel32.CloseHandle.restype = wintypes.BOOL
|
||||
handle = kernel32.OpenProcess(
|
||||
_PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
False,
|
||||
pid,
|
||||
)
|
||||
if not handle:
|
||||
return (
|
||||
False
|
||||
if ctypes.get_last_error() == _ERROR_INVALID_PARAMETER
|
||||
else None
|
||||
)
|
||||
try:
|
||||
exit_code = wintypes.DWORD()
|
||||
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||
return None
|
||||
return exit_code.value == _STILL_ACTIVE
|
||||
finally:
|
||||
kernel32.CloseHandle(handle)
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except (PermissionError, OSError):
|
||||
return None
|
||||
return True
|
||||
|
||||
|
||||
def _process_started_at(pid: int) -> float | None:
|
||||
try:
|
||||
return psutil.Process(pid).create_time()
|
||||
except (psutil.Error, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _owner_is_provably_stale(
|
||||
pid: int,
|
||||
process_started_at: float | None,
|
||||
) -> bool:
|
||||
running = _process_is_running(pid)
|
||||
if running is False:
|
||||
return True
|
||||
if running is not True or process_started_at is None:
|
||||
return False
|
||||
actual_started_at = _process_started_at(pid)
|
||||
if actual_started_at is None:
|
||||
return False
|
||||
return (
|
||||
abs(actual_started_at - process_started_at)
|
||||
> _PROCESS_START_TOLERANCE_SECONDS
|
||||
)
|
||||
|
||||
|
||||
def _coerce_process_started_at(value: object) -> float | None:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
return None
|
||||
started_at = float(value)
|
||||
return started_at if started_at > 0 else None
|
||||
|
||||
|
||||
def _partial_pid(text: str) -> int | None:
|
||||
match = _PARTIAL_PID.search(text)
|
||||
if match is None:
|
||||
return None
|
||||
pid = int(match.group("pid"))
|
||||
return pid if pid > 0 else None
|
||||
|
||||
|
||||
def _partial_process_started_at(text: str) -> float | None:
|
||||
match = _PARTIAL_PROCESS_STARTED_AT.search(text)
|
||||
if match is None:
|
||||
return None
|
||||
return _coerce_process_started_at(float(match.group("started")))
|
||||
|
||||
|
||||
def _file_identity(stat: os.stat_result) -> tuple[int, int, int, int]:
|
||||
return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
||||
|
||||
|
||||
def _try_lock_file(handle) -> bool: # type: ignore[no-untyped-def]
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
handle.seek(0, os.SEEK_END)
|
||||
if handle.tell() == 0:
|
||||
handle.write(b"\0")
|
||||
handle.flush()
|
||||
handle.seek(0)
|
||||
try:
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
import fcntl
|
||||
|
||||
try:
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _unlock_file(handle) -> None: # type: ignore[no-untyped-def]
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
handle.seek(0)
|
||||
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
||||
return
|
||||
|
||||
import fcntl
|
||||
|
||||
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _exclusive_reclaim_guard(
|
||||
path: Path,
|
||||
*,
|
||||
resource: str,
|
||||
deadline: float,
|
||||
poll_seconds: float,
|
||||
):
|
||||
"""Use an OS lock so a crashed reclaimer cannot strand the guard."""
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a+b") as handle:
|
||||
while not _try_lock_file(handle):
|
||||
if time.monotonic() >= deadline:
|
||||
raise ResourceBusyError(f"resource is busy: {resource}")
|
||||
time.sleep(poll_seconds)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_unlock_file(handle)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class NamedResourceLock:
|
||||
path: Path
|
||||
@@ -27,36 +197,153 @@ class NamedResourceLock:
|
||||
timeout_seconds: float
|
||||
poll_seconds: float
|
||||
_acquired: bool = False
|
||||
_lease_id: str | None = None
|
||||
_acquired_pid: int | None = None
|
||||
|
||||
def __enter__(self) -> "NamedResourceLock":
|
||||
deadline = time.monotonic() + max(0.0, self.timeout_seconds)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
reclaim_path = self.path.with_suffix(self.path.suffix + ".reclaim")
|
||||
while True:
|
||||
try:
|
||||
descriptor = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
||||
except FileExistsError:
|
||||
if time.monotonic() >= deadline:
|
||||
raise ResourceBusyError(f"resource is busy: {self.resource}")
|
||||
time.sleep(self.poll_seconds)
|
||||
with _exclusive_reclaim_guard(
|
||||
reclaim_path,
|
||||
resource=self.resource,
|
||||
deadline=deadline,
|
||||
poll_seconds=self.poll_seconds,
|
||||
):
|
||||
if self.path.exists():
|
||||
recovered = self._recover_stale_lock()
|
||||
else:
|
||||
recovered = False
|
||||
if self._publish_lock():
|
||||
self._acquired = True
|
||||
return self
|
||||
if recovered:
|
||||
continue
|
||||
metadata = {
|
||||
"resource": self.resource,
|
||||
"owner": self.owner,
|
||||
"acquired_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"pid": os.getpid(),
|
||||
}
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
||||
if time.monotonic() >= deadline:
|
||||
raise ResourceBusyError(f"resource is busy: {self.resource}")
|
||||
time.sleep(self.poll_seconds)
|
||||
|
||||
def _publish_lock(self) -> bool:
|
||||
lease_id = uuid.uuid4().hex
|
||||
pid = os.getpid()
|
||||
metadata: dict[str, object] = {
|
||||
"resource": self.resource,
|
||||
"owner": self.owner,
|
||||
"acquired_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"pid": pid,
|
||||
"lease_id": lease_id,
|
||||
}
|
||||
process_started_at = _process_started_at(pid)
|
||||
if process_started_at is not None:
|
||||
metadata["process_started_at"] = process_started_at
|
||||
|
||||
candidate = self.path.with_name(
|
||||
f".{self.path.name}.{pid}.{lease_id}.tmp"
|
||||
)
|
||||
try:
|
||||
with candidate.open("x", encoding="utf-8", newline="\n") as handle:
|
||||
json.dump(metadata, handle, ensure_ascii=False, indent=2, sort_keys=True)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
self._acquired = True
|
||||
return self
|
||||
try:
|
||||
os.link(candidate, self.path)
|
||||
except FileExistsError:
|
||||
return False
|
||||
finally:
|
||||
candidate.unlink(missing_ok=True)
|
||||
self._lease_id = lease_id
|
||||
self._acquired_pid = pid
|
||||
return True
|
||||
|
||||
def _recover_stale_lock(self) -> bool:
|
||||
"""Quarantine a lock only when its recorded owner PID is provably dead."""
|
||||
|
||||
try:
|
||||
snapshot = self.path.stat()
|
||||
text = self.path.read_text(encoding="utf-8")
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except (OSError, UnicodeError):
|
||||
return False
|
||||
|
||||
try:
|
||||
metadata = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
metadata = None
|
||||
|
||||
if isinstance(metadata, dict):
|
||||
pid = metadata.get("pid")
|
||||
if (
|
||||
metadata.get("resource") == self.resource
|
||||
and isinstance(pid, int)
|
||||
and not isinstance(pid, bool)
|
||||
):
|
||||
started_at = _coerce_process_started_at(
|
||||
metadata.get("process_started_at")
|
||||
)
|
||||
if not _owner_is_provably_stale(pid, started_at):
|
||||
return False
|
||||
return self._quarantine_if_unchanged(snapshot)
|
||||
|
||||
age_seconds = max(0.0, time.time() - snapshot.st_mtime)
|
||||
if age_seconds < _MALFORMED_LOCK_GRACE_SECONDS:
|
||||
return False
|
||||
partial_pid = _partial_pid(text)
|
||||
if partial_pid is not None:
|
||||
partial_started_at = _partial_process_started_at(text)
|
||||
if not _owner_is_provably_stale(partial_pid, partial_started_at):
|
||||
return False
|
||||
return self._quarantine_if_unchanged(snapshot)
|
||||
|
||||
def _quarantine_if_unchanged(self, snapshot: os.stat_result) -> bool:
|
||||
try:
|
||||
current = self.path.stat()
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
if _file_identity(current) != _file_identity(snapshot):
|
||||
return False
|
||||
|
||||
stale_root = self.path.parent / "stale"
|
||||
stale_root.mkdir(parents=True, exist_ok=True)
|
||||
stale_path = stale_root / (
|
||||
f"{self.path.stem}.{time.time_ns()}.{uuid.uuid4().hex}.lock"
|
||||
)
|
||||
try:
|
||||
os.replace(self.path, stale_path)
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
return True
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None: # type: ignore[no-untyped-def]
|
||||
if self._acquired:
|
||||
self.path.unlink(missing_ok=True)
|
||||
self._acquired = False
|
||||
reclaim_path = self.path.with_suffix(self.path.suffix + ".reclaim")
|
||||
deadline = time.monotonic() + _RELEASE_GUARD_TIMEOUT_SECONDS
|
||||
try:
|
||||
with _exclusive_reclaim_guard(
|
||||
reclaim_path,
|
||||
resource=self.resource,
|
||||
deadline=deadline,
|
||||
poll_seconds=self.poll_seconds,
|
||||
):
|
||||
try:
|
||||
metadata = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
except (FileNotFoundError, OSError, UnicodeError, json.JSONDecodeError):
|
||||
metadata = None
|
||||
if (
|
||||
isinstance(metadata, dict)
|
||||
and metadata.get("lease_id") == self._lease_id
|
||||
and metadata.get("pid") == self._acquired_pid
|
||||
and self._acquired_pid == os.getpid()
|
||||
):
|
||||
self.path.unlink(missing_ok=True)
|
||||
finally:
|
||||
self._acquired = False
|
||||
self._lease_id = None
|
||||
self._acquired_pid = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
from typing import Any, Literal
|
||||
|
||||
from .artifacts import atomic_write_json
|
||||
@@ -14,6 +16,7 @@ from .layout import DataLayout
|
||||
|
||||
StepStatus = Literal["success", "failed", "skipped"]
|
||||
RunStatus = Literal["success", "failed", "cancelled"]
|
||||
RunMode = Literal["execute", "dry_run", "unknown"]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
@@ -23,9 +26,18 @@ def _now() -> str:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RunJournal:
|
||||
path: Path
|
||||
_lock: Any = field(default_factory=RLock, compare=False, repr=False)
|
||||
|
||||
@classmethod
|
||||
def create(cls, layout: DataLayout, context: RunContext) -> "RunJournal":
|
||||
def create(
|
||||
cls,
|
||||
layout: DataLayout,
|
||||
context: RunContext,
|
||||
*,
|
||||
mode: RunMode = "unknown",
|
||||
) -> "RunJournal":
|
||||
if not isinstance(mode, str) or mode not in {"execute", "dry_run", "unknown"}:
|
||||
raise ValueError(f"invalid run mode: {mode!r}")
|
||||
run_dir = layout.run_dir(
|
||||
context.workflow_id,
|
||||
context.business_date.isoformat(),
|
||||
@@ -42,6 +54,7 @@ class RunJournal:
|
||||
"workflow_id": context.workflow_id,
|
||||
"run_id": context.run_id,
|
||||
"business_date": context.business_date.isoformat(),
|
||||
"mode": mode,
|
||||
"shadow": context.shadow,
|
||||
"started_at": context.started_at.isoformat(timespec="seconds"),
|
||||
"ended_at": None,
|
||||
@@ -66,19 +79,20 @@ class RunJournal:
|
||||
return json.loads(self.path.read_text(encoding="utf-8"))
|
||||
|
||||
def start_step(self, step_id: str, *, attempt: int) -> None:
|
||||
payload = self._read()
|
||||
previous = payload["steps"].get(step_id)
|
||||
if previous and previous["status"] == "running":
|
||||
raise ValueError(f"step {step_id!r} is already running")
|
||||
payload["steps"][step_id] = {
|
||||
"status": "running",
|
||||
"attempt": attempt,
|
||||
"started_at": _now(),
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"error": None,
|
||||
}
|
||||
atomic_write_json(self.path, payload)
|
||||
with self._lock:
|
||||
payload = self._read()
|
||||
previous = payload["steps"].get(step_id)
|
||||
if previous and previous["status"] == "running":
|
||||
raise ValueError(f"step {step_id!r} is already running")
|
||||
payload["steps"][step_id] = {
|
||||
"status": "running",
|
||||
"attempt": attempt,
|
||||
"started_at": _now(),
|
||||
"ended_at": None,
|
||||
"exit_code": None,
|
||||
"error": None,
|
||||
}
|
||||
atomic_write_json(self.path, payload)
|
||||
|
||||
def finish_step(
|
||||
self,
|
||||
@@ -90,26 +104,28 @@ class RunJournal:
|
||||
) -> None:
|
||||
if status not in {"success", "failed", "skipped"}:
|
||||
raise ValueError(f"invalid step status: {status!r}")
|
||||
payload = self._read()
|
||||
step = payload["steps"].get(step_id)
|
||||
if not step or step["status"] != "running":
|
||||
raise ValueError(f"step {step_id!r} is not running")
|
||||
step.update(
|
||||
{
|
||||
"status": status,
|
||||
"ended_at": _now(),
|
||||
"exit_code": exit_code,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
atomic_write_json(self.path, payload)
|
||||
with self._lock:
|
||||
payload = self._read()
|
||||
step = payload["steps"].get(step_id)
|
||||
if not step or step["status"] != "running":
|
||||
raise ValueError(f"step {step_id!r} is not running")
|
||||
step.update(
|
||||
{
|
||||
"status": status,
|
||||
"ended_at": _now(),
|
||||
"exit_code": exit_code,
|
||||
"error": error,
|
||||
}
|
||||
)
|
||||
atomic_write_json(self.path, payload)
|
||||
|
||||
def finalize(self, status: RunStatus, *, error: str | None = None) -> None:
|
||||
if status not in {"success", "failed", "cancelled"}:
|
||||
raise ValueError(f"invalid run status: {status!r}")
|
||||
payload = self._read()
|
||||
payload.update({"status": status, "ended_at": _now(), "error": error})
|
||||
atomic_write_json(self.path, payload)
|
||||
with self._lock:
|
||||
payload = self._read()
|
||||
payload.update({"status": status, "ended_at": _now(), "error": error})
|
||||
atomic_write_json(self.path, payload)
|
||||
|
||||
def record_input(self, reference: str) -> None:
|
||||
self._record_trace("inputs", reference)
|
||||
@@ -120,6 +136,41 @@ class RunJournal:
|
||||
def record_external_write(self, reference: str) -> None:
|
||||
self._record_trace("external_writes", reference)
|
||||
|
||||
def normalize_step_order(self, step_ids: Sequence[str]) -> None:
|
||||
"""Make concurrent graph journal collections deterministic by DAG order."""
|
||||
|
||||
ordered = tuple(step_ids)
|
||||
if len(set(ordered)) != len(ordered) or any(not value for value in ordered):
|
||||
raise ValueError("step_ids must be unique non-empty strings")
|
||||
ranks = {step_id: index for index, step_id in enumerate(ordered)}
|
||||
|
||||
def rank(reference: str, original_index: int) -> tuple[int, int]:
|
||||
if reference.startswith("step:"):
|
||||
step_id = reference.split(":", 2)[1]
|
||||
else:
|
||||
step_id = reference.split(".attempt-", 1)[0]
|
||||
return ranks.get(step_id, len(ranks)), original_index
|
||||
|
||||
with self._lock:
|
||||
payload = self._read()
|
||||
step_items = list(payload["steps"].items())
|
||||
payload["steps"] = dict(
|
||||
sorted(
|
||||
step_items,
|
||||
key=lambda item: rank(item[0], step_items.index(item)),
|
||||
)
|
||||
)
|
||||
for category in ("inputs", "outputs", "external_writes"):
|
||||
references = payload["trace"][category]
|
||||
payload["trace"][category] = [
|
||||
reference
|
||||
for _, reference in sorted(
|
||||
enumerate(references),
|
||||
key=lambda item: rank(item[1], item[0]),
|
||||
)
|
||||
]
|
||||
atomic_write_json(self.path, payload)
|
||||
|
||||
def _record_trace(self, category: str, reference: str) -> None:
|
||||
if (
|
||||
not isinstance(reference, str)
|
||||
@@ -128,8 +179,9 @@ class RunJournal:
|
||||
or any(ord(character) < 32 for character in reference)
|
||||
):
|
||||
raise ValueError("invalid trace reference")
|
||||
payload = self._read()
|
||||
references = payload["trace"][category]
|
||||
if reference not in references:
|
||||
references.append(reference)
|
||||
atomic_write_json(self.path, payload)
|
||||
with self._lock:
|
||||
payload = self._read()
|
||||
references = payload["trace"][category]
|
||||
if reference not in references:
|
||||
references.append(reference)
|
||||
atomic_write_json(self.path, payload)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Small text helpers shared by runtime and presentation boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
_OMISSION_MARKER = "\n...[middle output omitted]...\n"
|
||||
|
||||
|
||||
def bounded_head_tail(value: str, limit: int) -> str:
|
||||
"""Bound text while retaining both its context and final diagnostic lines."""
|
||||
|
||||
if limit < 1:
|
||||
raise ValueError("limit must be positive")
|
||||
if len(value) <= limit:
|
||||
return value
|
||||
if limit <= len(_OMISSION_MARKER):
|
||||
return value[-limit:]
|
||||
|
||||
available = limit - len(_OMISSION_MARKER)
|
||||
head_length = max(1, available // 4)
|
||||
tail_length = available - head_length
|
||||
return (
|
||||
value[:head_length]
|
||||
+ _OMISSION_MARKER
|
||||
+ value[-tail_length:]
|
||||
)
|
||||
@@ -54,7 +54,7 @@ def run_doctor(settings: Settings) -> DoctorReport:
|
||||
]
|
||||
try:
|
||||
catalog = WorkflowCatalog.load(settings.project_root / "config")
|
||||
catalog_ok = len(catalog.scheduled_workflows()) == 21
|
||||
catalog_ok = len(catalog.scheduled_workflows()) == 23
|
||||
catalog_message = f"catalog has {len(catalog.scheduled_workflows())} scheduled workflows"
|
||||
except Exception:
|
||||
catalog_ok = False
|
||||
@@ -91,4 +91,3 @@ def _probe_write_permission(path: Path) -> tuple[bool, str]:
|
||||
except OSError:
|
||||
return False, "data-root parent is not writable"
|
||||
return True, "data-root parent write probe passed"
|
||||
|
||||
|
||||
@@ -5,29 +5,25 @@ from __future__ import annotations
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
|
||||
from gyxx_flow.adapters.native import DeferredModuleCommandStep, ModuleCommandFactory
|
||||
from gyxx_flow.workflow import StepDefinition, WorkflowDefinition
|
||||
from gyxx_flow.adapters.native import ModuleCommandFactory
|
||||
from gyxx_flow.workflow import WorkflowDefinition
|
||||
from gyxx_flow.workflow.factory import build_catalog_workflow
|
||||
|
||||
CONTENT_SCHEDULED_WORKFLOW_IDS = (
|
||||
"content.metrics.daily",
|
||||
"content.marketing_report.daily",
|
||||
"content.relogin.weekly",
|
||||
"content.self_operated.weekly",
|
||||
"content.creator_report.monthly",
|
||||
"content.summary.monthly",
|
||||
"content.cooperations.daily",
|
||||
"content.comments.weekly",
|
||||
"content.summary.weekly",
|
||||
)
|
||||
CONTENT_MANUAL_WORKFLOW_IDS = (
|
||||
"content.mapping.refresh",
|
||||
"content.retry_failed",
|
||||
"content.metrics.backfill",
|
||||
)
|
||||
CONTENT_WORKFLOW_IDS = (*CONTENT_SCHEDULED_WORKFLOW_IDS, *CONTENT_MANUAL_WORKFLOW_IDS)
|
||||
CONTENT_WORKFLOW_IDS = CONTENT_SCHEDULED_WORKFLOW_IDS
|
||||
CONTENT_TIMEOUT_SECONDS = 4 * 60 * 60
|
||||
CONTENT_RESOURCE = "module:content_marketing"
|
||||
_RELOGIN_WORKFLOW_ID = "content.relogin.weekly"
|
||||
_PARALLEL_COMMENT_WORKFLOW_ID = "content.comments.weekly"
|
||||
|
||||
|
||||
class ContentMarketingModule:
|
||||
@@ -54,7 +50,7 @@ class ContentMarketingModule:
|
||||
entries = {
|
||||
entry.workflow_id: entry
|
||||
for entry in catalog.workflows
|
||||
if entry.module == cls.module_id and entry.trigger in {"scheduled", "manual"}
|
||||
if entry.module == cls.module_id and entry.trigger == "scheduled"
|
||||
}
|
||||
expected = set(CONTENT_WORKFLOW_IDS)
|
||||
if set(entries) != expected:
|
||||
@@ -65,25 +61,16 @@ class ContentMarketingModule:
|
||||
definitions: list[WorkflowDefinition] = []
|
||||
for workflow_id in CONTENT_WORKFLOW_IDS:
|
||||
entry = entries[workflow_id]
|
||||
action = (
|
||||
DeferredModuleCommandStep(entry)
|
||||
if command_factory is None
|
||||
else DeferredModuleCommandStep(entry, command_factory=command_factory)
|
||||
)
|
||||
definitions.append(
|
||||
WorkflowDefinition(
|
||||
workflow_id,
|
||||
(
|
||||
StepDefinition(
|
||||
"module_run",
|
||||
action,
|
||||
timeout_seconds=CONTENT_TIMEOUT_SECONDS,
|
||||
max_attempts=1,
|
||||
resources=(CONTENT_RESOURCE,),
|
||||
production_sink=True,
|
||||
official_notification=workflow_id
|
||||
== _RELOGIN_WORKFLOW_ID,
|
||||
),
|
||||
build_catalog_workflow(
|
||||
entry,
|
||||
default_step_id="module_run",
|
||||
timeout_seconds=CONTENT_TIMEOUT_SECONDS,
|
||||
resource=CONTENT_RESOURCE,
|
||||
command_factory=command_factory,
|
||||
official_notification=workflow_id == _RELOGIN_WORKFLOW_ID,
|
||||
independent_step_resources=(
|
||||
workflow_id == _PARALLEL_COMMENT_WORKFLOW_ID
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -95,7 +82,6 @@ class ContentMarketingModule:
|
||||
|
||||
__all__ = [
|
||||
"CONTENT_WORKFLOW_IDS",
|
||||
"CONTENT_MANUAL_WORKFLOW_IDS",
|
||||
"CONTENT_SCHEDULED_WORKFLOW_IDS",
|
||||
"CONTENT_RESOURCE",
|
||||
"CONTENT_TIMEOUT_SECONDS",
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
|
||||
+98
-13
@@ -48,19 +48,22 @@ import sys
|
||||
import time
|
||||
from datetime import datetime, date
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import (
|
||||
PATHS,
|
||||
resolve_layer_output,
|
||||
)
|
||||
|
||||
import requests
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.collection_completeness import (
|
||||
from gyxx_flow.modules.content_marketing.collection_completeness import (
|
||||
add_repeatable_style_argument,
|
||||
atomic_write_json,
|
||||
merge_global_summaries,
|
||||
select_requested_styles,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.daily_creator_exposure_scope import (
|
||||
classify_publish_scope,
|
||||
)
|
||||
|
||||
BASE_DIR = PATHS.module_root
|
||||
DEFAULT_DATA_DIR = PATHS.normalized_root
|
||||
@@ -217,8 +220,12 @@ def call_lark_json(args: list[str]) -> dict:
|
||||
|
||||
def load_mapping(data_dir: Path, self_operated: bool = False) -> dict:
|
||||
# 动态:从「合作达人」/「自营达人」多维表格地址表读取款式→各表地址,实时拉字段重建对照表
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
|
||||
return feishu_mapping.load_mapping(None, self_operated=self_operated)
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
mapping = feishu_mapping.load_mapping(None, self_operated=self_operated)
|
||||
if self_operated:
|
||||
for style in mapping.get("tables", []):
|
||||
style.get("field_map", {}).pop("daily_exposure", None)
|
||||
return mapping
|
||||
|
||||
|
||||
def list_all_records(base_token: str, table_id: str) -> list[dict]:
|
||||
@@ -266,6 +273,14 @@ def write_record(base_token: str, table_id: str, record_id: str,
|
||||
if dry_run:
|
||||
log(f" [DRY-RUN] 写入: {json.dumps(fields, ensure_ascii=False)[:200]}")
|
||||
return True
|
||||
from gyxx_flow.adapters.acceptance_policy import skip_feishu_table_write
|
||||
|
||||
if skip_feishu_table_write(
|
||||
"content_marketing.bilibili.record-upsert",
|
||||
details={"table_id": table_id, "record_id": record_id},
|
||||
):
|
||||
log(f" [ACCEPTANCE-SKIP] record_id={record_id}")
|
||||
return True
|
||||
pf = _write_payload_to_cwd(fields)
|
||||
cmd = [LARK_CLI, "base", "+record-upsert",
|
||||
"--base-token", base_token,
|
||||
@@ -305,6 +320,14 @@ def create_record(base_token: str, table_id: str,
|
||||
if dry_run:
|
||||
log(f" [DRY-RUN] 新建: {json.dumps(fields, ensure_ascii=False)[:200]}")
|
||||
return "rec_dryrun"
|
||||
from gyxx_flow.adapters.acceptance_policy import skip_feishu_table_write
|
||||
|
||||
if skip_feishu_table_write(
|
||||
"content_marketing.bilibili.record-create",
|
||||
details={"table_id": table_id},
|
||||
):
|
||||
log(" [ACCEPTANCE-SKIP] create record")
|
||||
return "acceptance-skipped"
|
||||
pf = _write_payload_to_cwd(fields)
|
||||
cmd = [LARK_CLI, "base", "+record-upsert",
|
||||
"--base-token", base_token,
|
||||
@@ -549,7 +572,9 @@ def save_state(data_dir: Path, state: dict) -> None:
|
||||
def process_style(style: dict, only_record_ids: set[str] | None,
|
||||
dry_run: bool, delay: float, session: requests.Session,
|
||||
state: dict, force_today: date | None,
|
||||
first_run: bool = False) -> dict:
|
||||
first_run: bool = False,
|
||||
published_from: date | None = None,
|
||||
max_age_days: int | None = None) -> dict:
|
||||
base_token = style["base_token"]
|
||||
table_id = style["table_id"]
|
||||
fmap = style.get("field_map", {})
|
||||
@@ -562,6 +587,10 @@ def process_style(style: dict, only_record_ids: set[str] | None,
|
||||
fid_month = fmap.get(KEY_MONTH, {}).get("field_id", "")
|
||||
fid_parent = fmap.get(KEY_PARENT, {}).get("field_id", "")
|
||||
fid_week = fmap.get(KEY_WEEK, {}).get("field_id", "")
|
||||
daily_info = fmap.get("daily_exposure") or {}
|
||||
daily_fid = daily_info.get("field_id", "")
|
||||
daily_field_name = daily_info.get("field_name", "")
|
||||
use_daily_exposure = bool(daily_fid)
|
||||
|
||||
# 5 档槽位的 field_id 列表
|
||||
slot_fids = [get_slot_fid(fmap, ln) or "" for ln, _ in SLOTS]
|
||||
@@ -573,7 +602,12 @@ def process_style(style: dict, only_record_ids: set[str] | None,
|
||||
missing_mapping.append("platform")
|
||||
if not fid_url:
|
||||
missing_mapping.append("note_url")
|
||||
if first_run:
|
||||
if (published_from is not None or max_age_days is not None) and not fid_pubtime:
|
||||
missing_mapping.append("publish_time")
|
||||
if use_daily_exposure:
|
||||
if not daily_fid:
|
||||
missing_mapping.append("daily_exposure")
|
||||
elif first_run:
|
||||
if not slot_fids[0]:
|
||||
missing_mapping.append("read_count_7d")
|
||||
else:
|
||||
@@ -617,12 +651,35 @@ def process_style(style: dict, only_record_ids: set[str] | None,
|
||||
log(f" 发布链接过滤: 待采 {len(b_records)} 条,未触发 {skipped_no_url} 条")
|
||||
|
||||
today = force_today or date.today()
|
||||
scope_skip_reasons: dict[str, int] = {}
|
||||
if published_from is not None or max_age_days is not None:
|
||||
scoped_records = []
|
||||
for record in b_records:
|
||||
publish_date = parse_pub_date(record.get(fid_pubtime))
|
||||
reason = classify_publish_scope(
|
||||
publish_date,
|
||||
today,
|
||||
published_from=published_from,
|
||||
max_age_days=max_age_days,
|
||||
)
|
||||
if reason:
|
||||
scope_skip_reasons[reason] = scope_skip_reasons.get(reason, 0) + 1
|
||||
else:
|
||||
scoped_records.append(record)
|
||||
b_records = scoped_records
|
||||
if scope_skip_reasons:
|
||||
log(
|
||||
f" 每日范围过滤: 跳过 {sum(scope_skip_reasons.values())} 条 "
|
||||
f"{scope_skip_reasons}"
|
||||
)
|
||||
style_state = state.setdefault(style["name"], {})
|
||||
summary = {
|
||||
"style": style["name"], "index": style["index"],
|
||||
"run_started_at": time.time(),
|
||||
"source_b_records": source_b_records,
|
||||
"skipped_no_url": skipped_no_url,
|
||||
"skipped_publish_scope": sum(scope_skip_reasons.values()),
|
||||
"publish_scope_skip_reasons": scope_skip_reasons,
|
||||
"total_b_records": len(b_records), "updated": 0, "skipped": 0,
|
||||
"details": [],
|
||||
}
|
||||
@@ -637,7 +694,7 @@ def process_style(style: dict, only_record_ids: set[str] | None,
|
||||
|
||||
# 解析发布时间
|
||||
pub_date = parse_pub_date(pubtime_raw)
|
||||
if pub_date is None and not first_run:
|
||||
if pub_date is None and not first_run and not use_daily_exposure:
|
||||
log(f" [SKIP] {rid[:10]}.. {creator} 发布时间为空,无法判断槽位")
|
||||
summary["skipped"] += 1
|
||||
summary["details"].append({
|
||||
@@ -648,13 +705,20 @@ def process_style(style: dict, only_record_ids: set[str] | None,
|
||||
continue
|
||||
|
||||
# 算槽位: (今天 - 发布日) 天数差
|
||||
if first_run:
|
||||
if use_daily_exposure:
|
||||
if pub_date is not None and pub_date > today:
|
||||
target_slot_fid, target_slot_key = None, None
|
||||
else:
|
||||
target_slot_fid, target_slot_key = daily_fid, daily_field_name
|
||||
elif first_run:
|
||||
target_slot_fid, target_slot_key = slot_fids[0] or None, SLOTS[0][1] if slot_fids[0] else None
|
||||
else:
|
||||
days_elapsed = (today - pub_date).days
|
||||
target_slot_fid, target_slot_key = pick_slot_by_days(days_elapsed, slot_fids)
|
||||
if target_slot_fid is None:
|
||||
if first_run:
|
||||
if use_daily_exposure:
|
||||
log(f" [SKIP] {rid[:10]}.. {creator} 发布时间 {pub_date} 在 {today} 之后")
|
||||
elif first_run:
|
||||
log(f" [SKIP] {rid[:10]}.. {creator} 7天曝光量字段缺失")
|
||||
else:
|
||||
log(f" [SKIP] {rid[:10]}.. {creator} 对应槽位字段缺失或发布时间 {pub_date} 在 {today} 之后")
|
||||
@@ -681,9 +745,10 @@ def process_style(style: dict, only_record_ids: set[str] | None,
|
||||
continue
|
||||
|
||||
# 覆盖写入 (同槽覆盖语义,数字更新)
|
||||
action = "first_run_fill" if first_run else "update_slot"
|
||||
action = "daily_snapshot" if use_daily_exposure else ("first_run_fill" if first_run else "update_slot")
|
||||
target_label = str(target_slot_key) if use_daily_exposure else f"{target_slot_key}天曝光量"
|
||||
log(f" {rid[:10]}.. {creator} 发布={pub_date} 距今={(today - pub_date).days if pub_date else '-'}天"
|
||||
f" → 写 {target_slot_key}天曝光量={play}")
|
||||
f" → 写 {target_label}={play}")
|
||||
ok = write_record(base_token, table_id, rid,
|
||||
{target_slot_fid: play}, dry_run)
|
||||
if ok:
|
||||
@@ -693,7 +758,8 @@ def process_style(style: dict, only_record_ids: set[str] | None,
|
||||
"record_id": rid, "creator": creator,
|
||||
"publish_date": str(pub_date) if pub_date else None,
|
||||
"days_elapsed": (today - pub_date).days if pub_date else None,
|
||||
"slot": target_slot_key, "play_count": play,
|
||||
"slot": "daily_exposure" if use_daily_exposure else target_slot_key,
|
||||
"field_name": target_label, "play_count": play,
|
||||
"action": action, "ok": ok,
|
||||
"matched": True,
|
||||
"write_ok": ok if not dry_run else None,
|
||||
@@ -771,6 +837,12 @@ def main() -> int:
|
||||
help="首次回填: 所有 B 站记录全部填 7天曝光量,跳过周次逻辑")
|
||||
parser.add_argument("--self-operated", action="store_true",
|
||||
help="抓取自营达人表格(而非合作达人)")
|
||||
parser.add_argument("--published-from", type=date.fromisoformat,
|
||||
help="仅采集此日期及以后发布的笔记,格式 YYYY-MM-DD")
|
||||
parser.add_argument("--max-age-days", type=int,
|
||||
help="发布满此天数后停止采集")
|
||||
parser.add_argument("--no-retry", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--skip-field-prepare", action="store_true", help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
data_dir = resolve_layer_output(
|
||||
@@ -789,6 +861,17 @@ def main() -> int:
|
||||
if args.today:
|
||||
force_today = date.fromisoformat(args.today)
|
||||
|
||||
if not args.self_operated and not args.skip_field_prepare:
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
prepared = feishu_mapping.ensure_daily_exposure_fields(
|
||||
mapping, target_date=force_today, dry_run=args.dry_run,
|
||||
write=not args.dry_run,
|
||||
selected_indices={style["index"] for style in styles},
|
||||
)
|
||||
if not prepared["ok"]:
|
||||
log(f"[ERROR] 当天曝光字段准备失败: {prepared['errors']}")
|
||||
return 1
|
||||
|
||||
state_path = BILIBILI_CHECKPOINT_DIR / STATE_FILENAME
|
||||
if args.reset_state and state_path.exists():
|
||||
state_path.unlink()
|
||||
@@ -806,7 +889,9 @@ def main() -> int:
|
||||
final = out_dir / f"_all_summaries{self_sfx}_bilibili.json"
|
||||
for s in styles:
|
||||
summary = process_style(s, only_rids, args.dry_run, args.delay,
|
||||
session, state, force_today, args.first_run)
|
||||
session, state, force_today, args.first_run,
|
||||
published_from=args.published_from,
|
||||
max_age_days=args.max_age_days)
|
||||
# 传了 --record 但本款式没匹配到 → 跳过 (不落空盘,不计入总汇总)
|
||||
if only_rids and summary["total_b_records"] == 0 and summary["updated"] == 0 \
|
||||
and summary["skipped"] == 0:
|
||||
+414
-55
@@ -5,30 +5,34 @@
|
||||
依赖:pip install selenium webdriver-manager
|
||||
"""
|
||||
|
||||
import sys
|
||||
import io
|
||||
import time
|
||||
import os
|
||||
import csv
|
||||
import re
|
||||
import json
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
|
||||
# 修复 Windows 控制台输出编码
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||||
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
# 避免替换并关闭 pytest、服务管理器等宿主提供的捕获流。
|
||||
for _stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
except (AttributeError, OSError, ValueError):
|
||||
pass
|
||||
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
||||
from selenium.webdriver.chrome.options import Options
|
||||
from selenium.common.exceptions import TimeoutException, NoSuchElementException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
HAS_SELENIUM = True
|
||||
except ImportError:
|
||||
HAS_SELENIUM = False
|
||||
@@ -52,6 +56,8 @@ TARGET_URLS = [
|
||||
DOWNLOAD_DIR = str(PATHS.raw_root / "chanmama")
|
||||
COOKIE_FILE = str(PATHS.browser_cookie_file)
|
||||
COOKIE_MAX_AGE = 7 * 24 * 3600 # cookie 有效期 7 天(秒)
|
||||
REFRESH_WAIT_SECONDS = int(os.getenv("CHANMAMA_REFRESH_WAIT_SECONDS", "600"))
|
||||
REFRESH_MAX_PAGES = 50
|
||||
# ================================================
|
||||
|
||||
|
||||
@@ -193,7 +199,7 @@ def load_cookies(driver):
|
||||
return False
|
||||
|
||||
|
||||
def handle_captcha(driver):
|
||||
def handle_captcha(driver) -> bool:
|
||||
"""处理登录时的验证码(滑块/图形/短信等)
|
||||
|
||||
点击登录后轮询等待:检测到验证码时提示用户在浏览器手动完成,
|
||||
@@ -241,12 +247,29 @@ def handle_captcha(driver):
|
||||
if not _has_captcha():
|
||||
if _logged_in_ok():
|
||||
print("✅ 登录成功,无需验证码")
|
||||
return
|
||||
return True
|
||||
# 可能验证码延迟出现,再观察一下
|
||||
time.sleep(3)
|
||||
if not _has_captcha() and _logged_in_ok():
|
||||
print("✅ 登录成功,无需验证码")
|
||||
return
|
||||
return True
|
||||
|
||||
from gyxx_flow.adapters.acceptance_policy import current_acceptance_policy
|
||||
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
reason = (
|
||||
"captcha or interactive verification is required"
|
||||
if _has_captcha()
|
||||
else "credential login did not complete without interaction"
|
||||
)
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.chanmama.login",
|
||||
details={"status": "SKIPPED_COOKIE", "reason": reason},
|
||||
)
|
||||
print(f"[SKIPPED_COOKIE] {reason}", flush=True)
|
||||
return False
|
||||
|
||||
if _has_captcha():
|
||||
print("\n" + "=" * 50)
|
||||
@@ -262,7 +285,7 @@ def handle_captcha(driver):
|
||||
while waited < max_wait:
|
||||
if _logged_in_ok():
|
||||
print("✅ 检测到登录成功,继续后续流程...")
|
||||
return
|
||||
return True
|
||||
# 还在登录页且仍有验证码 -> 继续等用户操作
|
||||
waited += interval
|
||||
remaining = max_wait - waited
|
||||
@@ -271,6 +294,7 @@ def handle_captcha(driver):
|
||||
time.sleep(interval)
|
||||
|
||||
print("⚠️ 等待验证码超时(180s),继续尝试后续步骤...")
|
||||
return False
|
||||
|
||||
|
||||
def verify_login(driver):
|
||||
@@ -387,7 +411,6 @@ def login(driver):
|
||||
|
||||
# ② 我已阅读并同意 → 是一个自定义 div.cursor-pointer(不是 checkbox!)
|
||||
# 必须点击这个 div 本体才生效
|
||||
agree_clicked = False
|
||||
try:
|
||||
agree_div = driver.find_element(
|
||||
By.XPATH,
|
||||
@@ -396,7 +419,6 @@ def login(driver):
|
||||
driver.execute_script("arguments[0].click();", agree_div)
|
||||
time.sleep(0.3)
|
||||
# 验证:点击后该 div 或其内部是否出现勾选标记(class 变化或新增元素)
|
||||
agree_clicked = True
|
||||
print("✅ 已点击【我已阅读并同意】协议区域")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 协议 div 定位失败: {e},尝试备用选择器")
|
||||
@@ -406,7 +428,6 @@ def login(driver):
|
||||
"//div[contains(.,'我已阅读并同意') and contains(@class,'cursor-pointer')]"
|
||||
)
|
||||
driver.execute_script("arguments[0].click();", agree_div)
|
||||
agree_clicked = True
|
||||
print("✅ 已点击【我已阅读并同意】协议区域(备用)")
|
||||
except Exception as e2:
|
||||
print(f"⚠️ 协议勾选全部失败: {e2}")
|
||||
@@ -422,7 +443,8 @@ def login(driver):
|
||||
print("✅ 已点击【登录】按钮 (备用)")
|
||||
|
||||
# 步骤6:处理验证码(如有):等待用户在浏览器中手动完成
|
||||
handle_captcha(driver)
|
||||
if not handle_captcha(driver):
|
||||
return False
|
||||
|
||||
# 等待登录跳转完成
|
||||
print("⏳ 等待登录完成...")
|
||||
@@ -451,6 +473,334 @@ def navigate_to_target(driver, url):
|
||||
return True
|
||||
|
||||
|
||||
def _classify_exposure_text(text):
|
||||
"""正数曝光视为就绪;零值或任意非空状态文字需要刷新。"""
|
||||
normalized = re.sub(r"\s+", "", "" if text is None else str(text))
|
||||
if not normalized:
|
||||
return "ready"
|
||||
numeric = normalized.lower().replace(",", "")
|
||||
match = re.fullmatch(r"(\d+(?:\.\d+)?)(?:万|w)?\+?", numeric)
|
||||
if match:
|
||||
return "refresh" if float(match.group(1)) == 0 else "ready"
|
||||
return "refresh"
|
||||
|
||||
|
||||
def _pagination_element_disabled(element):
|
||||
try:
|
||||
if not element.is_enabled():
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
css = str(element.get_attribute("class") or "").lower()
|
||||
aria = str(element.get_attribute("aria-disabled") or "").lower()
|
||||
disabled = str(element.get_attribute("disabled") or "").lower()
|
||||
return "disabled" in css or aria == "true" or disabled in {"true", "disabled"}
|
||||
|
||||
|
||||
def _find_video_table(driver):
|
||||
def locate(current_driver):
|
||||
for table in current_driver.find_elements(By.CSS_SELECTOR, "table"):
|
||||
try:
|
||||
headers = table.find_elements(By.CSS_SELECTOR, "thead th, thead td")
|
||||
if table.is_displayed() and "预估曝光" in "|".join(h.text or "" for h in headers):
|
||||
return table
|
||||
except Exception:
|
||||
continue
|
||||
return False
|
||||
return WebDriverWait(driver, 15).until(locate)
|
||||
|
||||
|
||||
def _exposure_column_index(table):
|
||||
for index, header in enumerate(table.find_elements(By.CSS_SELECTOR, "thead th, thead td")):
|
||||
if "预估曝光" in (header.text or "").replace("\n", ""):
|
||||
return index
|
||||
return 2
|
||||
|
||||
|
||||
def _page_signature(driver):
|
||||
parts = []
|
||||
for selector in (".el-pagination .el-pager li.active", ".el-pagination .el-pager li.is-active", ".el-pager li.active"):
|
||||
try:
|
||||
active = driver.find_elements(By.CSS_SELECTOR, selector)
|
||||
if active:
|
||||
parts.append(active[0].text.strip())
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
parts.extend((row.text or "").strip() for row in _find_video_table(driver).find_elements(By.CSS_SELECTOR, "tbody tr")[:3])
|
||||
except Exception:
|
||||
pass
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
def _find_next_page_button(driver):
|
||||
selectors = [
|
||||
(By.CSS_SELECTOR, ".el-pagination .btn-next"),
|
||||
(By.CSS_SELECTOR, "button.btn-next"),
|
||||
(By.CSS_SELECTOR, "li.next"),
|
||||
(By.CSS_SELECTOR, "button[aria-label*='下一页']"),
|
||||
(By.CSS_SELECTOR, "[title='下一页']"),
|
||||
(By.XPATH, "//button[contains(.,'下一页')]"),
|
||||
(By.XPATH, "//span[normalize-space()='下一页']/ancestor::button[1]"),
|
||||
]
|
||||
for by, selector in selectors:
|
||||
try:
|
||||
for element in driver.find_elements(by, selector):
|
||||
if element.is_displayed():
|
||||
return element
|
||||
except Exception:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _find_get_data_targets(driver):
|
||||
"""查找当前页真正绑定点击事件的“获取数据”节点。"""
|
||||
targets = []
|
||||
seen = set()
|
||||
try:
|
||||
candidates = driver.find_elements(By.CSS_SELECTOR, "td .cursor-pointer")
|
||||
except Exception:
|
||||
candidates = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
if not candidate.is_displayed():
|
||||
continue
|
||||
css_classes = str(candidate.get_attribute("class") or "").split()
|
||||
if "cursor-pointer" not in css_classes:
|
||||
continue
|
||||
if re.sub(r"\s+", "", candidate.text or "") != "获取数据":
|
||||
continue
|
||||
key = getattr(candidate, "id", None) or id(candidate)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
targets.append(candidate)
|
||||
except Exception:
|
||||
continue
|
||||
return targets
|
||||
|
||||
|
||||
def _get_data_target_gone(target):
|
||||
"""点击后节点消失、隐藏或文字变化,才视为页面已接受操作。"""
|
||||
try:
|
||||
return (
|
||||
not target.is_displayed()
|
||||
or re.sub(r"\s+", "", target.text or "") != "获取数据"
|
||||
)
|
||||
except Exception:
|
||||
return True
|
||||
|
||||
|
||||
def _click_and_confirm_get_data(driver, target, timeout=8):
|
||||
"""点击真实 cursor-pointer,并验证“获取数据”状态已经变化。"""
|
||||
before_count = max(len(_find_get_data_targets(driver)), 1)
|
||||
|
||||
def click_confirmed():
|
||||
if not _get_data_target_gone(target):
|
||||
return False
|
||||
return len(_find_get_data_targets(driver)) < before_count
|
||||
|
||||
try:
|
||||
driver.execute_script(
|
||||
"arguments[0].scrollIntoView({block:'center'});", target
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
target.click()
|
||||
except Exception:
|
||||
try:
|
||||
driver.execute_script("arguments[0].click();", target)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
deadline = time.monotonic() + max(timeout, 0)
|
||||
js_retried = False
|
||||
while time.monotonic() <= deadline:
|
||||
if click_confirmed():
|
||||
return True
|
||||
if not js_retried and time.monotonic() >= deadline - max(timeout - 2, 0):
|
||||
try:
|
||||
driver.execute_script("arguments[0].click();", target)
|
||||
except Exception:
|
||||
pass
|
||||
js_retried = True
|
||||
time.sleep(0.25)
|
||||
return click_confirmed()
|
||||
|
||||
|
||||
def _count_pending_exposures(driver):
|
||||
"""统计当前页已经进入“更新中”的曝光单元格。"""
|
||||
try:
|
||||
cells = driver.find_elements(
|
||||
By.XPATH,
|
||||
"//td[contains(normalize-space(.),'更新中')]",
|
||||
)
|
||||
except Exception:
|
||||
return 0
|
||||
count = 0
|
||||
for cell in cells:
|
||||
try:
|
||||
if cell.is_displayed() and "更新中" in re.sub(r"\s+", "", cell.text or ""):
|
||||
count += 1
|
||||
except Exception:
|
||||
continue
|
||||
return count
|
||||
|
||||
|
||||
def _refresh_current_page(driver):
|
||||
"""只点击当前页“预估曝光”列中的“获取数据”,返回本页统计。"""
|
||||
result = {
|
||||
"clicked": 0,
|
||||
"pending": 0,
|
||||
"zero_without_button": 0,
|
||||
"unconfirmed": 0,
|
||||
}
|
||||
attempted = set()
|
||||
while True:
|
||||
targets = []
|
||||
for target in _find_get_data_targets(driver):
|
||||
key = getattr(target, "id", None) or id(target)
|
||||
if key not in attempted:
|
||||
targets.append((key, target))
|
||||
if not targets:
|
||||
break
|
||||
key, target = targets[0]
|
||||
if _click_and_confirm_get_data(driver, target):
|
||||
result["clicked"] += 1
|
||||
else:
|
||||
attempted.add(key)
|
||||
result["unconfirmed"] += 1
|
||||
print(" ⚠️ 已点击“获取数据”,但页面状态未变化,不计为成功")
|
||||
result["pending"] = _count_pending_exposures(driver)
|
||||
return result
|
||||
|
||||
|
||||
def refresh_zero_exposure_videos(driver, max_pages=REFRESH_MAX_PAGES):
|
||||
"""遍历当前博主的所有分页,触发零曝光/待更新记录的数据更新。"""
|
||||
total = {
|
||||
"clicked": 0,
|
||||
"pending": 0,
|
||||
"zero_without_button": 0,
|
||||
"unconfirmed": 0,
|
||||
"pages": 0,
|
||||
}
|
||||
seen_signatures = set()
|
||||
|
||||
for page_number in range(1, max_pages + 1):
|
||||
try:
|
||||
before = _page_signature(driver)
|
||||
if before and before in seen_signatures:
|
||||
print("📄 检测到重复页面,停止翻页")
|
||||
break
|
||||
if before:
|
||||
seen_signatures.add(before)
|
||||
|
||||
page_result = _refresh_current_page(driver)
|
||||
total["pages"] += 1
|
||||
for key in (
|
||||
"clicked",
|
||||
"pending",
|
||||
"zero_without_button",
|
||||
"unconfirmed",
|
||||
):
|
||||
total[key] += page_result.get(key, 0)
|
||||
print(
|
||||
f"📄 第 {page_number} 页: 点击“获取数据” {page_result['clicked']} 条, "
|
||||
f"更新中 {page_result['pending']} 条, "
|
||||
f"点击未确认 {page_result.get('unconfirmed', 0)} 条"
|
||||
)
|
||||
|
||||
next_button = _find_next_page_button(driver)
|
||||
if next_button is None or _pagination_element_disabled(next_button):
|
||||
print("📄 已到最后一页")
|
||||
break
|
||||
driver.execute_script("arguments[0].click();", next_button)
|
||||
|
||||
changed = False
|
||||
for _ in range(20):
|
||||
time.sleep(0.5)
|
||||
after = _page_signature(driver)
|
||||
if after and after != before:
|
||||
changed = True
|
||||
break
|
||||
if not changed:
|
||||
print("⚠️ 点击下一页后内容未变化,停止翻页以避免死循环")
|
||||
break
|
||||
except Exception as exc:
|
||||
print(f"⚠️ 第 {page_number} 页刷新扫描失败: {exc}")
|
||||
break
|
||||
|
||||
print(
|
||||
f"🔄 刷新扫描完成: {total['pages']} 页, 点击“获取数据” {total['clicked']} 条, "
|
||||
f"更新中 {total['pending']} 条, "
|
||||
f"点击未确认 {total['unconfirmed']} 条"
|
||||
)
|
||||
return total
|
||||
|
||||
|
||||
def _max_excel_mtime():
|
||||
root = Path(DOWNLOAD_DIR)
|
||||
if not root.exists():
|
||||
return 0.0
|
||||
return max((p.stat().st_mtime for p in root.glob("*.xlsx") if not p.name.startswith("~$")), default=0.0)
|
||||
|
||||
|
||||
def refresh_then_export_accounts(driver, urls, wait_seconds=REFRESH_WAIT_SECONDS):
|
||||
"""两阶段执行:先刷新所有账号全部分页,再等待并重新导出。"""
|
||||
refresh_summaries = []
|
||||
for index, url in enumerate(urls, 1):
|
||||
print(f"\n{'=' * 50}")
|
||||
print(f"🔄 刷新阶段 博主 {index}/{len(urls)}: {url}")
|
||||
print(f"{'=' * 50}")
|
||||
try:
|
||||
navigate_to_target(driver, url)
|
||||
summary = refresh_zero_exposure_videos(driver)
|
||||
except Exception as exc:
|
||||
print(f"⚠️ 博主 {index} 刷新扫描异常: {exc},继续下一个")
|
||||
summary = {
|
||||
"clicked": 0,
|
||||
"pending": 0,
|
||||
"zero_without_button": 0,
|
||||
"unconfirmed": 0,
|
||||
"pages": 0,
|
||||
"error": str(exc),
|
||||
}
|
||||
refresh_summaries.append(summary)
|
||||
|
||||
should_wait = any(
|
||||
item.get("clicked", 0) > 0 or item.get("pending", 0) > 0
|
||||
for item in refresh_summaries
|
||||
)
|
||||
if should_wait and wait_seconds > 0:
|
||||
print(f"\n⏳ 已触发曝光更新,等待 {wait_seconds} 秒后刷新导出...")
|
||||
time.sleep(wait_seconds)
|
||||
else:
|
||||
print("\n✅ 没有待更新或更新中的曝光,直接进入导出阶段")
|
||||
|
||||
all_records = []
|
||||
for index, url in enumerate(urls, 1):
|
||||
print(f"\n{'=' * 50}")
|
||||
print(f"📌 导出阶段 博主 {index}/{len(urls)}: {url}")
|
||||
print(f"{'=' * 50}")
|
||||
try:
|
||||
navigate_to_target(driver, url)
|
||||
pre_mtime = _max_excel_mtime()
|
||||
excel_path = export_video_data(driver, pre_mtime)
|
||||
if not excel_path:
|
||||
print(f"⚠️ 博主 {index} 导出失败,跳过")
|
||||
continue
|
||||
records, _ = parse_chanmama_excel(excel_path)
|
||||
print(f" 解析出 {len(records)} 条视频记录")
|
||||
all_records.extend(records)
|
||||
except Exception as exc:
|
||||
print(f"⚠️ 博主 {index} 导出异常: {exc},跳过")
|
||||
|
||||
return all_records, refresh_summaries
|
||||
|
||||
|
||||
def export_video_data(driver, pre_mtime: float = 0):
|
||||
"""导出视频记录数据,返回下载的 Excel 绝对路径。
|
||||
pre_mtime: 进入导出前 DOWNLOAD_DIR 中已有文件的最大 mtime,用于识别本次新下载的文件。
|
||||
@@ -554,8 +904,6 @@ def scrape_table_data(driver):
|
||||
"""备用方案:直接从页面抓取表格数据保存为CSV"""
|
||||
print("\n📋 正在抓取页面表格数据...")
|
||||
|
||||
rows_data = []
|
||||
|
||||
try:
|
||||
# 等待表格加载
|
||||
table = WebDriverWait(driver, 10).until(
|
||||
@@ -633,7 +981,7 @@ def scrape_table_data(driver):
|
||||
writer.writerow(headers + ["链接"])
|
||||
writer.writerows(all_rows)
|
||||
|
||||
print(f"\n✅ 数据抓取完成!")
|
||||
print("\n✅ 数据抓取完成!")
|
||||
print(f" 📁 文件路径: {csv_path}")
|
||||
print(f" 📊 共计 {len(all_rows)} 条记录")
|
||||
return csv_path
|
||||
@@ -853,6 +1201,14 @@ def _list_records_by_table(base_token, table_id):
|
||||
|
||||
|
||||
def _write_back(base_token, table_id, record_id, field_id, value):
|
||||
from gyxx_flow.adapters.acceptance_policy import skip_feishu_table_write
|
||||
|
||||
if skip_feishu_table_write(
|
||||
"content_marketing.chanmama.record-upsert",
|
||||
details={"table_id": table_id, "record_id": record_id, "field_id": field_id},
|
||||
):
|
||||
print(f" [ACCEPTANCE-SKIP] record_id={record_id}")
|
||||
return True
|
||||
payload = {field_id: value}
|
||||
resp = _call_lark_json([
|
||||
"base", "+record-upsert",
|
||||
@@ -952,7 +1308,7 @@ def _title_match(haystack, needle):
|
||||
|
||||
def backfill_self_tables(excel_records, dry_run=False, only_style=None):
|
||||
"""遍历自营 mapping,用 excel_records 回填飞书自营表(抖音平台)"""
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
try:
|
||||
mapping = feishu_mapping.load_mapping(self_operated=True)
|
||||
except Exception as exc:
|
||||
@@ -1221,9 +1577,12 @@ def main():
|
||||
print("\n🌐 启动浏览器...")
|
||||
driver = create_driver()
|
||||
|
||||
# 2. 优先用 cookie 登录(避免每次都触发密码登录的验证码)
|
||||
logged_in = False
|
||||
if load_cookies(driver):
|
||||
# 2. 优先复用独占 Chrome Profile,再尝试显式 cookie 文件。
|
||||
logged_in = verify_login(driver)
|
||||
if logged_in:
|
||||
save_cookies(driver)
|
||||
print("✨ 使用已复制的浏览器 Profile 登录成功,跳过手动登录!")
|
||||
elif load_cookies(driver):
|
||||
print("🔍 正在验证 cookie 有效性...")
|
||||
if verify_login(driver):
|
||||
logged_in = True
|
||||
@@ -1231,37 +1590,31 @@ def main():
|
||||
|
||||
# 3. cookie 无效或过期时,回退到账号密码登录
|
||||
if not logged_in:
|
||||
from gyxx_flow.adapters.acceptance_policy import current_acceptance_policy
|
||||
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled and (not ACCOUNT or not PASSWORD):
|
||||
reason = "profile/cookie session is invalid and credential fallback is unavailable"
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.chanmama.login",
|
||||
details={"status": "SKIPPED_COOKIE", "reason": reason},
|
||||
)
|
||||
print(f"[SKIPPED_COOKIE] {reason}", flush=True)
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
print("\n🔐 cookie 不可用,执行账号密码登录...")
|
||||
success = login(driver)
|
||||
if success:
|
||||
logged_in = True
|
||||
elif acceptance_policy.enabled:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
else:
|
||||
print("⚠️ 登录可能未成功,但继续尝试后续步骤...")
|
||||
|
||||
# 4. 循环每个博主 URL 导出 Excel
|
||||
for i, url in enumerate(TARGET_URLS, 1):
|
||||
print(f"\n{'='*50}")
|
||||
print(f"📌 博主 {i}/{len(TARGET_URLS)}: {url}")
|
||||
print(f"{'='*50}")
|
||||
# 记录导出前已有文件的最大 mtime,用于识别本次新下载的文件
|
||||
pre_mtime = 0
|
||||
if os.path.exists(DOWNLOAD_DIR):
|
||||
for f in os.listdir(DOWNLOAD_DIR):
|
||||
if f.lower().endswith('.xlsx') and not f.startswith('~$'):
|
||||
pre_mtime = max(pre_mtime,
|
||||
os.path.getmtime(os.path.join(DOWNLOAD_DIR, f)))
|
||||
try:
|
||||
navigate_to_target(driver, url)
|
||||
excel_path = export_video_data(driver, pre_mtime)
|
||||
if not excel_path:
|
||||
print(f"⚠️ 博主 {i} 导出失败,跳过")
|
||||
continue
|
||||
records, _ = parse_chanmama_excel(excel_path)
|
||||
print(f" 解析出 {len(records)} 条视频记录")
|
||||
all_records.extend(records)
|
||||
except Exception as exc:
|
||||
print(f"⚠️ 博主 {i} 异常: {exc},跳过")
|
||||
continue
|
||||
# 4. 先刷新两个账号的全部分页,再统一等待并重新进入页面导出。
|
||||
all_records, _refresh_summaries = refresh_then_export_accounts(
|
||||
driver, TARGET_URLS, wait_seconds=REFRESH_WAIT_SECONDS
|
||||
)
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
print(f"🎉 导出完成!共 {len(all_records)} 条 records")
|
||||
@@ -1289,5 +1642,11 @@ def main():
|
||||
return 0
|
||||
|
||||
|
||||
def cli() -> None:
|
||||
"""Run the script and preserve the business exit code for the workflow engine."""
|
||||
|
||||
raise SystemExit(main())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
cli()
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Business scope for the scheduled collaborator exposure collection."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from datetime import date
|
||||
|
||||
DEFAULT_PUBLISHED_FROM = date(2026, 7, 1)
|
||||
DEFAULT_MAX_AGE_DAYS = 30
|
||||
EXCLUDED_STYLE_NAMES = frozenset({"盖亚微单", "逐星GT", "晨星2", "觅光"})
|
||||
|
||||
|
||||
def select_daily_styles(
|
||||
styles: Iterable[dict],
|
||||
requested_indices: Iterable[int] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return requested styles after applying the daily exclusion list."""
|
||||
requested = (
|
||||
{int(index) for index in requested_indices}
|
||||
if requested_indices is not None
|
||||
else None
|
||||
)
|
||||
return [
|
||||
style
|
||||
for style in styles
|
||||
if (requested is None or int(style["index"]) in requested)
|
||||
and str(style.get("name") or "").strip() not in EXCLUDED_STYLE_NAMES
|
||||
]
|
||||
|
||||
|
||||
def classify_publish_scope(
|
||||
published_on: date | None,
|
||||
collected_on: date,
|
||||
*,
|
||||
published_from: date | None = None,
|
||||
max_age_days: int | None = None,
|
||||
) -> str | None:
|
||||
"""Return a skip reason, or ``None`` when a note should be collected.
|
||||
|
||||
A 30-day window means ages 0 through 29 are collected. Once the note is
|
||||
30 days old it has completed 30 daily snapshots and leaves the queue.
|
||||
"""
|
||||
scoped = published_from is not None or max_age_days is not None
|
||||
if not scoped:
|
||||
return None
|
||||
if published_on is None:
|
||||
return "publish_time_missing"
|
||||
if published_from is not None and published_on < published_from:
|
||||
return "before_publish_cutoff"
|
||||
age_days = (collected_on - published_on).days
|
||||
if age_days < 0:
|
||||
return "publish_time_in_future"
|
||||
if max_age_days is not None and age_days >= max_age_days:
|
||||
return "collection_window_complete"
|
||||
return None
|
||||
+6
-6
@@ -10,7 +10,7 @@ from collections import Counter
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
@@ -20,13 +20,13 @@ if hasattr(sys.stderr, "reconfigure"):
|
||||
ROOT = PATHS.module_root
|
||||
TOOLS_DIR = PATHS.tools_root
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import ( # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools.analyze_comments import ( # noqa: E402
|
||||
call_hermes_analyzer,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_report_charts import generate_dashboard # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_report_card import send_daily_report_cards # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_dashboard_analytics import ( # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_conn # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools.daily_report_charts import generate_dashboard # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools.daily_report_card import send_daily_report_cards # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools.daily_dashboard_analytics import ( # noqa: E402
|
||||
build_dashboard_facts,
|
||||
load_creator_connections,
|
||||
load_style_categories,
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
# PG 连接信息模板
|
||||
# 复制为 db.env 并填真实值 (db.env 已被 .gitignore 排除)
|
||||
|
||||
PG_HOST=8.148.185.119
|
||||
PG_HOST=127.0.0.1
|
||||
PG_PORT=5432
|
||||
PG_DB=data_hub
|
||||
PG_USER=data_hub
|
||||
PG_PASSWORD=${GYXX_PG_PASSWORD}
|
||||
PG_DB=gyxx_super_data
|
||||
PG_USER=gyxx_flow
|
||||
PG_PASSWORD=
|
||||
+2548
-4648
File diff suppressed because it is too large
Load Diff
+1545
-1207
File diff suppressed because it is too large
Load Diff
+16
-3
@@ -20,7 +20,6 @@ import concurrent.futures
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
from gyxx_flow.adapters import RuntimeServicePolicy
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
@@ -30,7 +29,9 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
|
||||
|
||||
from gyxx_flow.adapters import RuntimeServicePolicy
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import (
|
||||
PATHS,
|
||||
resolve_layer_output,
|
||||
)
|
||||
@@ -377,8 +378,17 @@ def build_batch_report(style_results: list[tuple[str, str, int, int]]) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hermes 分析端调用
|
||||
# ---------------------------------------------------------------------------
|
||||
def _chat_completions_url(configured: str) -> str:
|
||||
"""Accept either an OpenAI API base URL or the full completions URL."""
|
||||
|
||||
normalized = configured.rstrip("/")
|
||||
if normalized.endswith("/chat/completions"):
|
||||
return normalized
|
||||
return normalized + "/chat/completions"
|
||||
|
||||
|
||||
def call_hermes_analyzer(system_prompt: str, user_content: str) -> str:
|
||||
url = HERMES_ANALYZER_URL.rstrip("/") + "/chat/completions"
|
||||
url = _chat_completions_url(HERMES_ANALYZER_URL)
|
||||
payload = {
|
||||
"model": HERMES_ANALYZER_MODEL,
|
||||
"messages": [
|
||||
@@ -733,6 +743,9 @@ def write_summary(summary: str, path: Path) -> Path:
|
||||
|
||||
def send_feishu_summary(summary: str, open_id: str) -> dict[str, Any]:
|
||||
"""通过 lark-cli --profile hermes-analyzer 以分析端应用身份发送汇总。"""
|
||||
from gyxx_flow.adapters.acceptance_policy import resolve_notification_recipients
|
||||
|
||||
open_id = resolve_notification_recipients((open_id,))[0]
|
||||
text = summary
|
||||
if len(text) > FEISHU_TEXT_LIMIT:
|
||||
text = text[:FEISHU_TEXT_LIMIT] + "\n\n...(内容过长,已截断,完整内容见项目 data/summary 汇总文件)"
|
||||
+6
-3
@@ -17,7 +17,7 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import (
|
||||
PATHS,
|
||||
resolve_layer_output,
|
||||
)
|
||||
@@ -26,7 +26,7 @@ from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import (
|
||||
from gyxx_flow.modules.content_marketing.data.tools.analyze_comments import (
|
||||
HERMES_ANALYZER_MODEL,
|
||||
HERMES_ANALYZER_TOKEN,
|
||||
HERMES_ANALYZER_URL,
|
||||
@@ -40,7 +40,7 @@ from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments imp
|
||||
call_hermes_analyzer,
|
||||
)
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.data.tools import db
|
||||
|
||||
PROJECT_ROOT = _PROJECT_ROOT
|
||||
DATA_DIR = PATHS.raw_root
|
||||
@@ -677,6 +677,9 @@ def send_feishu_report(
|
||||
Windows 下直接走 lark-cli.cmd 会撞到命令行长度上限且对 &|<>^% 转义敏感,
|
||||
改为调 node + run.js,args 用 list 传,由 node 自己处理。
|
||||
"""
|
||||
from gyxx_flow.adapters.acceptance_policy import resolve_notification_recipients
|
||||
|
||||
open_id = resolve_notification_recipients((open_id,))[0]
|
||||
card = build_card_payload(
|
||||
info, metrics, distribution,
|
||||
raw_comments, filtered_comments, analysis,
|
||||
+22
-5
@@ -9,15 +9,15 @@ Updates:
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
|
||||
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE, current_acceptance_policy
|
||||
from gyxx_flow.modules.content_marketing import bilibili_comment_scraper as scraper
|
||||
from gyxx_flow.modules.content_marketing.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.runtime import bilibili_comment_scraper as scraper
|
||||
|
||||
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_bilibili.py"
|
||||
|
||||
try:
|
||||
@@ -83,6 +83,21 @@ def comment_replace_policy(comments: list[dict], stats: dict, metrics: dict) ->
|
||||
|
||||
def do_relogin() -> bool:
|
||||
"""Run relogin_bilibili.py (headed, waits for QR scan). Returns True if successful."""
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.batch_rescrape_bilibili.relogin",
|
||||
details={
|
||||
"status": "SKIPPED_COOKIE",
|
||||
"reason": "cookie is invalid and QR relogin is disabled for acceptance",
|
||||
},
|
||||
)
|
||||
log(
|
||||
" [SKIPPED_COOKIE] Cookie invalid; acceptance mode will not "
|
||||
"launch relogin or wait for a QR scan"
|
||||
)
|
||||
return False
|
||||
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
@@ -142,6 +157,8 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
relogin_used += 1
|
||||
consecutive_fail = 0
|
||||
continue
|
||||
if current_acceptance_policy().enabled:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
continue
|
||||
|
||||
consecutive_fail = 0
|
||||
+23
-7
@@ -9,16 +9,16 @@ Updates:
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
|
||||
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE, current_acceptance_policy
|
||||
from gyxx_flow.modules.content_marketing import douyin_comment_scraper as scraper
|
||||
from gyxx_flow.modules.content_marketing.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
# Path setup: db.py is in data/tools/, scrapers are in project root
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.runtime import douyin_comment_scraper as scraper
|
||||
|
||||
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_douyin.py"
|
||||
|
||||
try:
|
||||
@@ -99,6 +99,21 @@ def result_logged_in(result: dict | None) -> bool:
|
||||
|
||||
def do_relogin() -> bool:
|
||||
"""Run relogin_douyin.py (headed, waits for QR scan). Returns True if successful."""
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.batch_rescrape_douyin.relogin",
|
||||
details={
|
||||
"status": "SKIPPED_COOKIE",
|
||||
"reason": "cookie is invalid and QR relogin is disabled for acceptance",
|
||||
},
|
||||
)
|
||||
log(
|
||||
" [SKIPPED_COOKIE] Cookie invalid; acceptance mode will not "
|
||||
"launch relogin or wait for a QR scan"
|
||||
)
|
||||
return False
|
||||
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
@@ -176,8 +191,9 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
consecutive_fail = 0
|
||||
continue # retry the current URL in next iteration
|
||||
else:
|
||||
# 重登失败,走冷却逻辑
|
||||
pass
|
||||
# 重登失败,走冷却逻辑;验收模式必须直接跳过,不能等待。
|
||||
if current_acceptance_policy().enabled:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
if consecutive_fail >= MAX_CONSECUTIVE_FAIL:
|
||||
if cooldown_used < MAX_COOLDOWNS:
|
||||
+22
-5
@@ -9,15 +9,15 @@ Updates:
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
|
||||
from gyxx_flow.adapters import COOKIE_SKIP_EXIT_CODE, current_acceptance_policy
|
||||
from gyxx_flow.modules.content_marketing import xiaohongshu_comment_scraper as scraper
|
||||
from gyxx_flow.modules.content_marketing.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.runtime import xiaohongshu_comment_scraper as scraper
|
||||
|
||||
RELOGIN_SCRIPT = _TOOLS_DIR / "relogin_xiaohongshu.py"
|
||||
|
||||
try:
|
||||
@@ -82,6 +82,21 @@ def comment_replace_policy(comments: list[dict], stats: dict, metrics: dict) ->
|
||||
|
||||
def do_relogin() -> bool:
|
||||
"""Run relogin_xiaohongshu.py (headed, waits for QR scan). Returns True if successful."""
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.batch_rescrape_xiaohongshu.relogin",
|
||||
details={
|
||||
"status": "SKIPPED_COOKIE",
|
||||
"reason": "cookie is invalid and QR relogin is disabled for acceptance",
|
||||
},
|
||||
)
|
||||
log(
|
||||
" [SKIPPED_COOKIE] Cookie invalid; acceptance mode will not "
|
||||
"launch relogin or wait for a QR scan"
|
||||
)
|
||||
return False
|
||||
log(" Cookie 可能过期,触发自动重登 (请扫码)...")
|
||||
try:
|
||||
rc = subprocess.call(
|
||||
@@ -138,6 +153,8 @@ def main(only_ids: list[int] | None = None, dry_run: bool = False) -> int:
|
||||
relogin_used += 1
|
||||
consecutive_fail = 0
|
||||
continue
|
||||
if current_acceptance_policy().enabled:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
# No relogin or relogin failed → skip this note
|
||||
continue
|
||||
|
||||
+2
-2
@@ -17,13 +17,13 @@ import datetime as dt
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
V2_DIR = PATHS.normalized_root / "v2_results"
|
||||
|
||||
# 共享工具
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.v2_filename import parse_v2_filename # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools.v2_filename import parse_v2_filename # noqa: E402
|
||||
|
||||
PLATFORMS = {
|
||||
"bili": {"name": "B 站", "label": "bili"},
|
||||
+5
-5
@@ -4,7 +4,7 @@ import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import (
|
||||
PATHS,
|
||||
resolve_layer_output,
|
||||
)
|
||||
@@ -15,10 +15,10 @@ _PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
from scrapling.fetchers import DynamicSession
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime import bilibili_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.runtime import douyin_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.runtime import xiaohongshu_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.feishu_comment_batch import detect_platform, normalize_note_url
|
||||
from gyxx_flow.modules.content_marketing import bilibili_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing import douyin_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing import xiaohongshu_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.data.tools.feishu_comment_batch import detect_platform, normalize_note_url
|
||||
|
||||
|
||||
def unique_note_urls(source_csv: Path, platform: str) -> list[str]:
|
||||
+2
-2
@@ -9,12 +9,12 @@ from datetime import date, timedelta
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
try:
|
||||
from .db import get_conn
|
||||
except ImportError:
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_conn
|
||||
|
||||
|
||||
MIN_CONVERSION_RANK_VISITORS = 30
|
||||
+4
-1
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from PIL import Image
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
|
||||
LARK_PROFILE = "hermes-analyzer"
|
||||
@@ -273,6 +273,9 @@ def send_card_to_recipients(
|
||||
*,
|
||||
message_kind: str = "combined",
|
||||
) -> list[dict[str, Any]]:
|
||||
from gyxx_flow.adapters.acceptance_policy import resolve_notification_recipients
|
||||
|
||||
recipients = resolve_notification_recipients(tuple(recipients))
|
||||
content = json.dumps(card, ensure_ascii=False, separators=(",", ":"))
|
||||
content_digest = hashlib.sha256(content.encode("utf-8")).hexdigest()[:10]
|
||||
results = []
|
||||
+1
-1
@@ -9,7 +9,7 @@ from PIL import Image, ImageDraw, ImageFont, ImageOps
|
||||
try:
|
||||
from .daily_dashboard_analytics import build_dashboard_facts
|
||||
except ImportError:
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_dashboard_analytics import build_dashboard_facts
|
||||
from gyxx_flow.modules.content_marketing.data.tools.daily_dashboard_analytics import build_dashboard_facts
|
||||
|
||||
|
||||
WIDTH, HEIGHT = 1600, 2500
|
||||
+6
-8
@@ -4,7 +4,7 @@ db.py - PostgreSQL 连接池 + cmt_* 表 CRUD
|
||||
统一使用 psycopg3,合并了原 yingxiaoyunying 诊断功能 + comment-data-collector 的完整 CRUD。
|
||||
|
||||
用法:
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import make_pool, get_conn, get_dict_conn
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import make_pool, get_conn, get_dict_conn
|
||||
python db.py ping # 测试连接
|
||||
python db.py info # 显示 cmt_* 表行数
|
||||
"""
|
||||
@@ -14,7 +14,7 @@ from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterator, Mapping
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
from gyxx_flow.adapters import RuntimeServicePolicy
|
||||
|
||||
try:
|
||||
@@ -39,16 +39,14 @@ ENV_EXAMPLE = PATHS.config_root / "db.env.example"
|
||||
|
||||
|
||||
def load_env() -> None:
|
||||
if not ENV_PATH.exists():
|
||||
print(f"[FATAL] 找不到 {ENV_PATH}", file=sys.stderr)
|
||||
print(f" 请复制 {ENV_EXAMPLE} -> {ENV_PATH} 并填连接信息", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
load_dotenv(ENV_PATH, override=False)
|
||||
"""Load optional secrets, then enforce the shared local-service policy."""
|
||||
if ENV_PATH.exists():
|
||||
load_dotenv(ENV_PATH, override=False)
|
||||
os.environ.update(RuntimeServicePolicy().apply(os.environ))
|
||||
|
||||
|
||||
def database_config_from_env(environ: Mapping[str, str]) -> dict[str, Any]:
|
||||
"""从环境变量构造连接配置;缺项立即失败,绝不回退到本地库。"""
|
||||
"""从已标准化的环境变量构造连接配置;缺项立即失败。"""
|
||||
required = ("PG_HOST", "PG_PORT", "PG_DB", "PG_USER", "PG_PASSWORD")
|
||||
missing = [key for key in required if not str(environ.get(key, "")).strip()]
|
||||
if missing:
|
||||
+5
-5
@@ -13,16 +13,16 @@ from multiprocessing import Pool
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
# Path setup: db.py is in data/tools/, scrapers are in project root
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime import bilibili_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.runtime import douyin_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.runtime import xiaohongshu_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing import bilibili_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing import douyin_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing import xiaohongshu_comment_scraper
|
||||
|
||||
|
||||
BASE_DIR = PATHS.module_root
|
||||
+1
-1
@@ -18,7 +18,7 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
|
||||
BASE_DIR = PATHS.module_root
|
||||
+80
-20
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
@@ -12,13 +11,20 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import psutil
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.adapters import (
|
||||
COOKIE_SKIP_EXIT_CODE,
|
||||
binding_from_environment,
|
||||
current_acceptance_policy,
|
||||
environment_for_child_script,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
ANALYZER_DIR = PATHS.tmp_root / "relogin"
|
||||
@@ -30,30 +36,29 @@ TECHNICAL_RECIPIENT = "ou_7ad5fc8012e2f741afc5346e05ffd447"
|
||||
class Platform:
|
||||
label: str
|
||||
command: tuple[str, ...]
|
||||
cookie_file: Path
|
||||
required_cookie: str
|
||||
|
||||
|
||||
PLATFORMS = {
|
||||
"douyin": Platform(
|
||||
"\u6296\u97f3", ("data/tools/relogin_douyin.py",),
|
||||
PATHS.state_root / "cookies/douyin_cookies.json", "sessionid",
|
||||
"sessionid",
|
||||
),
|
||||
"bilibili": Platform(
|
||||
"B\u7ad9", ("data/tools/relogin_bilibili.py",),
|
||||
PATHS.state_root / "cookies/bilibili_cookies.json", "SESSDATA",
|
||||
"SESSDATA",
|
||||
),
|
||||
"pgy": Platform(
|
||||
"\u84b2\u516c\u82f1", ("data/tools/relogin_pgy.py",),
|
||||
PATHS.state_root / "cookies/pgy_cookies.json", "",
|
||||
"",
|
||||
),
|
||||
"xingtu": Platform(
|
||||
"\u661f\u56fe", ("data/tools/relogin_xingtu.py", "--force"),
|
||||
PATHS.state_root / "cookies/xingtu_cookies.json", "sessionid",
|
||||
"sessionid",
|
||||
),
|
||||
"xiaohongshu": Platform(
|
||||
"\u5c0f\u7ea2\u4e66", ("data/tools/relogin_xiaohongshu.py",),
|
||||
PATHS.state_root / "cookies/xiaohongshu_cookies.json", "web_session",
|
||||
"web_session",
|
||||
),
|
||||
}
|
||||
|
||||
@@ -66,14 +71,33 @@ DEFAULT_RECIPIENTS = {
|
||||
}
|
||||
|
||||
|
||||
def resolve_platform_names(requested: list[str] | None) -> list[str]:
|
||||
"""Resolve an optional manual platform subset while preserving order."""
|
||||
if not requested:
|
||||
return list(PLATFORMS)
|
||||
names: list[str] = []
|
||||
for name in requested:
|
||||
if name not in PLATFORMS:
|
||||
raise ValueError(f"unknown platform: {name}")
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
return names
|
||||
|
||||
|
||||
def recipient_for_platform(platform_name: str, override: str | None = None) -> str:
|
||||
"""Return the responsible recipient, unless a manual all-platform override is set."""
|
||||
from gyxx_flow.adapters.acceptance_policy import resolve_notification_recipients
|
||||
|
||||
if override:
|
||||
return override
|
||||
try:
|
||||
return DEFAULT_RECIPIENTS[platform_name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"no Lark recipient configured for platform: {platform_name}") from exc
|
||||
recipient = override
|
||||
else:
|
||||
try:
|
||||
recipient = DEFAULT_RECIPIENTS[platform_name]
|
||||
except KeyError as exc:
|
||||
raise ValueError(
|
||||
f"no Lark recipient configured for platform: {platform_name}"
|
||||
) from exc
|
||||
return resolve_notification_recipients((recipient,))[0]
|
||||
|
||||
|
||||
def group_platforms_by_recipient(
|
||||
@@ -103,14 +127,14 @@ def run_with_retries(
|
||||
return status
|
||||
|
||||
|
||||
def _valid_cookie(platform: Platform) -> bool:
|
||||
if not platform.cookie_file.is_file():
|
||||
def _valid_cookie(platform: Platform, cookie_file: Path) -> bool:
|
||||
if not cookie_file.is_file():
|
||||
return False
|
||||
if not platform.required_cookie:
|
||||
return platform.cookie_file.stat().st_size > 10
|
||||
return cookie_file.stat().st_size > 10
|
||||
try:
|
||||
import json
|
||||
values = json.loads(platform.cookie_file.read_text(encoding="utf-8"))
|
||||
values = json.loads(cookie_file.read_text(encoding="utf-8"))
|
||||
return any(
|
||||
item.get("name") == platform.required_cookie and item.get("value")
|
||||
for item in values if isinstance(item, dict)
|
||||
@@ -272,6 +296,9 @@ def _tile_windows(handles: list[int]) -> None:
|
||||
|
||||
def _send_lark(recipient: str, text: str | None = None, image: Path | None = None) -> None:
|
||||
"""Send a notification through the locally installed Lark CLI."""
|
||||
from gyxx_flow.adapters.acceptance_policy import resolve_notification_recipients
|
||||
|
||||
recipient = resolve_notification_recipients((recipient,))[0]
|
||||
lark_env = {
|
||||
key: value for key, value in os.environ.items()
|
||||
if not key.upper().startswith("HERMES_")
|
||||
@@ -316,11 +343,19 @@ def _run_round_factory(args):
|
||||
|
||||
def run_round(names: list[str], attempt: int) -> dict[str, bool]:
|
||||
started: dict[str, subprocess.Popen] = {}
|
||||
cookie_files: dict[str, Path] = {}
|
||||
stamp = dt.datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
for name in names:
|
||||
platform = PLATFORMS[name]
|
||||
cmd = [python, *platform.command, "--login-timeout", str(args.round_timeout)]
|
||||
started[name] = subprocess.Popen(cmd, cwd=PROJECT_DIR)
|
||||
target = PROJECT_DIR / platform.command[0]
|
||||
environment = environment_for_child_script(target, os.environ)
|
||||
cookie_files[name] = binding_from_environment(environment).cookie_file
|
||||
started[name] = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=PROJECT_DIR,
|
||||
env=environment,
|
||||
)
|
||||
|
||||
time.sleep(args.screenshot_delay)
|
||||
title_windows = _login_windows()
|
||||
@@ -365,7 +400,10 @@ def _run_round_factory(args):
|
||||
except subprocess.TimeoutExpired:
|
||||
_terminate_process_tree(process)
|
||||
rc = 1
|
||||
results[name] = rc == 0 and _valid_cookie(PLATFORMS[name])
|
||||
results[name] = rc == 0 and _valid_cookie(
|
||||
PLATFORMS[name],
|
||||
cookie_files[name],
|
||||
)
|
||||
print(f"[{name}] round={attempt} exit={rc} success={results[name]}", flush=True)
|
||||
return results
|
||||
|
||||
@@ -373,6 +411,23 @@ def _run_round_factory(args):
|
||||
|
||||
|
||||
def main() -> int:
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.relogin.weekly",
|
||||
details={
|
||||
"status": "SKIPPED_COOKIE",
|
||||
"reason": "acceptance runs never launch QR relogin workflows",
|
||||
},
|
||||
)
|
||||
print(
|
||||
"[SKIPPED_COOKIE] Acceptance mode does not launch login windows, "
|
||||
"reset cookies/profiles, or wait for QR scans.",
|
||||
flush=True,
|
||||
)
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
parser = argparse.ArgumentParser(description="Friday five-platform parallel QR relogin")
|
||||
parser.add_argument(
|
||||
"--recipient", default=None,
|
||||
@@ -382,10 +437,15 @@ def main() -> int:
|
||||
parser.add_argument("--round-timeout", type=int, default=300)
|
||||
parser.add_argument("--screenshot-delay", type=int, default=60)
|
||||
parser.add_argument("--no-send", action="store_true", help="Do not send Lark messages")
|
||||
parser.add_argument(
|
||||
"--platform", action="append", choices=sorted(PLATFORMS),
|
||||
help="Only relogin the selected platform; may be repeated",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
platform_names = resolve_platform_names(args.platform)
|
||||
status = run_with_retries(
|
||||
list(PLATFORMS), max_attempts=args.max_attempts,
|
||||
platform_names, max_attempts=args.max_attempts,
|
||||
run_round=_run_round_factory(args),
|
||||
)
|
||||
failed = [name for name, ok in status.items() if not ok]
|
||||
+4
-6
@@ -18,7 +18,7 @@ import sys
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import (
|
||||
PATHS,
|
||||
resolve_layer_output,
|
||||
)
|
||||
@@ -29,9 +29,7 @@ from dotenv import load_dotenv
|
||||
BASE_DIR = PATHS.tools_root
|
||||
DATA_DIR = PATHS.exports_root
|
||||
load_dotenv(PATHS.state_root / "config/db.env")
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_db_config # noqa: E402
|
||||
|
||||
DB_CONFIG = get_db_config()
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_db_config # noqa: E402
|
||||
|
||||
LARK = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
|
||||
if not os.path.exists(LARK):
|
||||
@@ -111,7 +109,7 @@ def one_line_profile(stats: dict) -> str:
|
||||
|
||||
|
||||
def generate_report(style_filter: str | None = None) -> str:
|
||||
conn = psycopg.connect(**DB_CONFIG)
|
||||
conn = psycopg.connect(**get_db_config())
|
||||
cur = conn.cursor()
|
||||
|
||||
where = "WHERE c.name IS NOT NULL AND c.name != ''"
|
||||
@@ -812,7 +810,7 @@ def save_to_db(report_md: str, rows: list, creator_stats: dict,
|
||||
doc_title = f"达人合作数据筛选与报价分析报告({time_label})"
|
||||
|
||||
try:
|
||||
conn = psycopg.connect(**DB_CONFIG)
|
||||
conn = psycopg.connect(**get_db_config())
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
INSERT INTO cmt_creator_report
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
"""Initialize gyxx_super_data tables from schema_gyxx_super_data.sql."""
|
||||
"""Initialize content-marketing tables in the configured local PostgreSQL."""
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -6,7 +6,7 @@ from pathlib import Path
|
||||
# Path setup
|
||||
_TOOLS_DIR = Path(__file__).resolve().parent
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.data.tools import db
|
||||
|
||||
|
||||
def main() -> int:
|
||||
+1
-1
@@ -8,7 +8,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
TABLES = [
|
||||
(1, "UkbabpqRYanmD7sk7ksceVuhnwf", "tbl2BdhIaohZNkOD", "盖亚斜挎"),
|
||||
+2
-2
@@ -16,10 +16,10 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping # noqa: E402
|
||||
|
||||
MAPPING_PATH = feishu_mapping.MAPPING_PATH
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Refresh the Feishu field mapping used by self-operated scrapers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
|
||||
|
||||
def main() -> int:
|
||||
mapping = feishu_mapping.refresh_mapping(
|
||||
force=True,
|
||||
url_field=feishu_mapping.INDEX_SELF_URL_FIELD,
|
||||
)
|
||||
tables = (mapping or {}).get("tables", [])
|
||||
if not tables:
|
||||
print("[ERROR] self-operated Feishu mapping refresh returned no tables")
|
||||
return 1
|
||||
print(f"self-operated Feishu mapping refreshed: {len(tables)} styles")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+2
-2
@@ -14,9 +14,9 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.relogin_transaction import begin, commit, rollback
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_transaction import begin, commit, rollback
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
COOKIE_FILE = PATHS.state_root / "cookies/bilibili_cookies.json"
|
||||
+2
-2
@@ -8,12 +8,12 @@ import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
try:
|
||||
from .relogin_transaction import begin, commit, rollback
|
||||
except ImportError: # Direct script execution from data/tools.
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.relogin_transaction import begin, commit, rollback
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_transaction import begin, commit, rollback
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
COOKIE_FILE = PATHS.state_root / "cookies/douyin_cookies.json"
|
||||
+1
-1
@@ -14,7 +14,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
COOKIE_FILE = PATHS.state_root / "cookies/pgy_cookies.json"
|
||||
+2
-2
@@ -13,9 +13,9 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.relogin_transaction import begin, commit, rollback
|
||||
from gyxx_flow.modules.content_marketing.data.tools.relogin_transaction import begin, commit, rollback
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
COOKIE_FILE = PATHS.state_root / "cookies/xiaohongshu_cookies.json"
|
||||
+1
-1
@@ -22,7 +22,7 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
import psutil
|
||||
|
||||
+11
-4
@@ -23,11 +23,14 @@ HALF 默认开启(因为只补失败的单条,不再昂贵);
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
|
||||
from gyxx_flow.adapters import environment_for_child_script
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
PROJECT_DIR = PATHS.module_root
|
||||
V2_DIR = PATHS.normalized_root / "v2_results"
|
||||
@@ -344,13 +347,17 @@ def run_one_style(platform_key: str, idx: int, max_attempts: int,
|
||||
for rid in record_ids or []:
|
||||
cmd += ["--record", rid]
|
||||
label = f"[{platform_key}{'-self' if self_operated else ''}] 款式 {idx:>2}"
|
||||
desc = f"整款" if not record_ids else f"{len(record_ids)} 条"
|
||||
desc = "整款" if not record_ids else f"{len(record_ids)} 条"
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
log(f" {label} 尝试 {attempt}/{max_attempts} ({desc}): {info['script']} --style {idx}"
|
||||
+ (f" --record x{len(record_ids)}" if record_ids else ""))
|
||||
before_mtime_ns = result_path.stat().st_mtime_ns if result_path.exists() else None
|
||||
try:
|
||||
rc = subprocess.call(cmd, cwd=str(PROJECT_DIR))
|
||||
rc = subprocess.call(
|
||||
cmd,
|
||||
cwd=str(PROJECT_DIR),
|
||||
env=environment_for_child_script(PROJECT_DIR / info["script"], os.environ),
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -491,7 +498,7 @@ def main() -> int:
|
||||
print(f"\n{'=' * 78}")
|
||||
print(f" {round_label} 补跑汇总: 总 {grand_total} 成功 {grand_ok} 失败 {grand_fail}")
|
||||
if grand_failed_list:
|
||||
print(f" 仍失败的:")
|
||||
print(" 仍失败的:")
|
||||
for k, idx in grand_failed_list:
|
||||
print(f" - [{k}] 款式 {idx}")
|
||||
print(f"{'=' * 78}\n")
|
||||
+2
-6
@@ -1,11 +1,7 @@
|
||||
-- 数据库:gyxx_super_data
|
||||
-- 内容营销模块 PostgreSQL schema
|
||||
-- 表结构:cmt_styles / cmt_creators / cmt_style_creators / cmt_notes / cmt_comments
|
||||
-- 适用于 PostgreSQL 14+
|
||||
|
||||
-- 如果数据库还没创建,取消下面一行的注释并执行:
|
||||
-- CREATE DATABASE gyxx_super_data WITH ENCODING = 'UTF8' LC_COLLATE = 'zh_CN.UTF-8' LC_CTYPE = 'zh_CN.UTF-8';
|
||||
|
||||
\c gyxx_super_data;
|
||||
-- 在当前已连接数据库中执行;项目默认数据库为本地 gyxx_flow
|
||||
|
||||
-- 款式表
|
||||
CREATE TABLE IF NOT EXISTS cmt_styles (
|
||||
+2
-2
@@ -13,13 +13,13 @@ import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
# Path setup
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
_PROJECT_ROOT = PATHS.module_root
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import (
|
||||
from gyxx_flow.modules.content_marketing.data.tools.analyze_comments import (
|
||||
ANALYSIS_SYSTEM_PROMPT,
|
||||
call_hermes_analyzer,
|
||||
)
|
||||
+4
-6
@@ -20,7 +20,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
import psycopg
|
||||
from dotenv import load_dotenv
|
||||
@@ -28,10 +28,8 @@ from dotenv import load_dotenv
|
||||
BASE_DIR = PATHS.tools_root
|
||||
DATA_DIR = PATHS.raw_root
|
||||
load_dotenv(PATHS.state_root / "config/db.env")
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_db_config # noqa: E402
|
||||
|
||||
DB_CONFIG = get_db_config()
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_db_config # noqa: E402
|
||||
|
||||
PLATFORM_MAP = {
|
||||
"小红书": "xiaohongshu",
|
||||
@@ -428,7 +426,7 @@ def main():
|
||||
|
||||
_log(f"共 {len(tables)} 个款式待同步")
|
||||
|
||||
conn = psycopg.connect(**DB_CONFIG)
|
||||
conn = psycopg.connect(**get_db_config())
|
||||
cur = conn.cursor()
|
||||
|
||||
total_creators = 0
|
||||
+5
-8
@@ -34,7 +34,7 @@ import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
import psycopg
|
||||
from dotenv import load_dotenv
|
||||
@@ -57,12 +57,9 @@ BASE_DIR = PATHS.tools_root
|
||||
DATA_DIR = PATHS.raw_root
|
||||
V2_DIR = PATHS.normalized_root / "v2_results"
|
||||
load_dotenv(PATHS.state_root / "config/db.env")
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.v2_filename import parse_v2_filename # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_db_config # noqa: E402
|
||||
|
||||
|
||||
DB_CONFIG = get_db_config()
|
||||
from gyxx_flow.modules.content_marketing.data.tools.v2_filename import parse_v2_filename # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_db_config # noqa: E402
|
||||
|
||||
|
||||
def parse_int(v) -> int | None:
|
||||
@@ -84,7 +81,7 @@ def parse_int(v) -> int | None:
|
||||
|
||||
|
||||
def get_conn():
|
||||
return psycopg.connect(**DB_CONFIG)
|
||||
return psycopg.connect(**get_db_config())
|
||||
|
||||
|
||||
# ============================================================
|
||||
+2
-2
@@ -15,9 +15,9 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_conn
|
||||
except ModuleNotFoundError: # direct execution: python data/tools/sync_style_categories.py
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_conn
|
||||
|
||||
|
||||
BASE_TOKEN = "TH1JbxhfUaxetis6F4pcoqvWncd"
|
||||
+1
-1
@@ -14,7 +14,7 @@ import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
LARK = os.path.expandvars(r"%APPDATA%\npm\lark-cli.cmd")
|
||||
if not os.path.exists(LARK):
|
||||
+6
-6
@@ -17,7 +17,7 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
# Path setup
|
||||
_TOOLS_DIR = PATHS.tools_root
|
||||
@@ -29,12 +29,12 @@ try:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import db # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools import db # noqa: E402
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import feishu_doc_writer as fdw # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import analyze_note # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import analyze_comments # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import style_analyzer # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools import feishu_doc_writer as fdw # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools import analyze_note # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools import analyze_comments # noqa: E402
|
||||
from gyxx_flow.modules.content_marketing.data.tools import style_analyzer # noqa: E402
|
||||
|
||||
|
||||
# Serializes state mutation + state.json writes across worker threads.
|
||||
+1
-1
@@ -7,7 +7,7 @@ import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
+188
-9
@@ -29,11 +29,11 @@ import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import (
|
||||
PATHS,
|
||||
resolve_layer_output,
|
||||
)
|
||||
@@ -60,6 +60,7 @@ INDEX_SELF_URL_FIELD = "自营合作达人表格地址"
|
||||
|
||||
# 缓存新鲜阈值(秒),默认 6 小时
|
||||
CACHE_TTL = 6 * 3600
|
||||
DAILY_EXPOSURE_LOGICAL = "daily_exposure"
|
||||
|
||||
# 标准字段名 → 逻辑名 (与 rebuild_mapping.py 保持一致)
|
||||
STD = {
|
||||
@@ -138,6 +139,7 @@ def _field_list(base_token: str, table_id: str) -> list[dict]:
|
||||
"base", "+field-list",
|
||||
"--base-token", base_token,
|
||||
"--table-id", table_id,
|
||||
"--as", "user",
|
||||
"--format", "json",
|
||||
])
|
||||
if not resp.get("ok"):
|
||||
@@ -208,15 +210,26 @@ def parse_style_url(raw) -> tuple[str | None, str | None, str]:
|
||||
return bt, tid, url
|
||||
|
||||
|
||||
def build_field_map(fields: list[dict]) -> tuple[dict, list[str]]:
|
||||
def daily_exposure_field_name(target_date: date | datetime | None = None) -> str:
|
||||
target_date = target_date or date.today()
|
||||
if isinstance(target_date, datetime):
|
||||
target_date = target_date.date()
|
||||
return f"{target_date.isoformat()}曝光量"
|
||||
|
||||
|
||||
def build_field_map(
|
||||
fields: list[dict], target_date: date | datetime | None = None
|
||||
) -> tuple[dict, list[str]]:
|
||||
field_map = {}
|
||||
all_field_names = []
|
||||
daily_name = daily_exposure_field_name(target_date)
|
||||
for f in fields:
|
||||
fid = f.get("id")
|
||||
fname = f.get("name")
|
||||
all_field_names.append(fname)
|
||||
clean_name = fname.rstrip("?").rstrip() if fname else ""
|
||||
logical = STD.get(fname) or STD.get(clean_name)
|
||||
logical = DAILY_EXPOSURE_LOGICAL if clean_name == daily_name else None
|
||||
logical = logical or STD.get(fname) or STD.get(clean_name)
|
||||
if not logical and clean_name:
|
||||
for k, v in STD.items():
|
||||
# 只做正向匹配: 字段名以已知 key 开头(如 "7天曝光量(自动)" 以 "7天曝光量" 开头)
|
||||
@@ -229,6 +242,137 @@ def build_field_map(fields: list[dict]) -> tuple[dict, list[str]]:
|
||||
return field_map, all_field_names
|
||||
|
||||
|
||||
def _atomic_write_mapping(mapping: dict, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(mapping, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
os.replace(temporary, path)
|
||||
|
||||
|
||||
def ensure_daily_exposure_fields(
|
||||
mapping: dict,
|
||||
target_date: date | datetime | None = None,
|
||||
dry_run: bool = False,
|
||||
write: bool = True,
|
||||
mapping_path: Path | None = None,
|
||||
selected_indices: set[int] | list[int] | tuple[int, ...] | None = None,
|
||||
retry_delays: tuple[int, ...] = (0, 2, 4, 8),
|
||||
) -> dict:
|
||||
"""确保合作达人选定款式具有采集日对应的数字曝光字段。"""
|
||||
field_name = daily_exposure_field_name(target_date)
|
||||
selected = {int(value) for value in selected_indices} if selected_indices is not None else None
|
||||
created: list[str] = []
|
||||
skipped_creations: list[str] = []
|
||||
ensured: list[str] = []
|
||||
errors: list[dict] = []
|
||||
pending: list[dict] = []
|
||||
|
||||
from gyxx_flow.adapters.acceptance_policy import current_acceptance_policy
|
||||
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
|
||||
def apply_found(style: dict, found: dict, attempted_create: bool) -> None:
|
||||
index = style.get("index")
|
||||
label = style.get("name") or str(index)
|
||||
style.setdefault("field_map", {})[DAILY_EXPOSURE_LOGICAL] = {
|
||||
"field_id": found["id"], "field_name": field_name,
|
||||
}
|
||||
names = style.setdefault("all_field_names", [])
|
||||
if field_name not in names:
|
||||
names.append(field_name)
|
||||
ensured.append(label)
|
||||
if attempted_create:
|
||||
created.append(label)
|
||||
_log(f" [{index:02d}] {label}: 已创建 {field_name}")
|
||||
|
||||
for style in mapping.get("tables", []):
|
||||
index = style.get("index")
|
||||
if selected is not None and index not in selected:
|
||||
continue
|
||||
current = style.setdefault("field_map", {}).get(DAILY_EXPOSURE_LOGICAL)
|
||||
if current and current.get("field_name") == field_name and current.get("field_id"):
|
||||
ensured.append(style.get("name") or str(index))
|
||||
continue
|
||||
if dry_run:
|
||||
apply_found(style, {"id": f"dryrun:{field_name}"}, False)
|
||||
continue
|
||||
base_token, table_id = style.get("base_token"), style.get("table_id")
|
||||
if not base_token or not table_id:
|
||||
errors.append({"style": style.get("name"), "index": index, "error": "missing base_token/table_id"})
|
||||
continue
|
||||
fields = _field_list(base_token, table_id)
|
||||
found = next((field for field in fields if field.get("name") == field_name), None)
|
||||
if found and found.get("id"):
|
||||
apply_found(style, found, False)
|
||||
continue
|
||||
if acceptance_policy.skip_feishu_write(
|
||||
"content.mapping.daily-exposure-field-create",
|
||||
details={
|
||||
"style_index": index,
|
||||
"field_name": field_name,
|
||||
},
|
||||
):
|
||||
label = style.get("name") or str(index)
|
||||
skipped_creations.append(label)
|
||||
apply_found(
|
||||
style,
|
||||
{
|
||||
"id": f"acceptance-skipped:{index}:{field_name}",
|
||||
"name": field_name,
|
||||
},
|
||||
False,
|
||||
)
|
||||
continue
|
||||
payload = json.dumps({
|
||||
"type": "number", "name": field_name,
|
||||
"style": {"type": "plain", "precision": 0, "percentage": False, "thousands_separator": True},
|
||||
}, ensure_ascii=False)
|
||||
response = call_lark_json([
|
||||
"base", "+field-create", "--base-token", base_token,
|
||||
"--table-id", table_id, "--json", payload, "--as", "user", "--format", "json",
|
||||
])
|
||||
pending.append({"style": style, "create_error": None if response.get("ok") else response.get("error")})
|
||||
|
||||
for attempt, delay in enumerate(retry_delays, start=1):
|
||||
if not pending:
|
||||
break
|
||||
if delay:
|
||||
_log(
|
||||
f" 等待 {delay}s 后复查 {len(pending)} 个日期字段 "
|
||||
f"({attempt}/{len(retry_delays)})"
|
||||
)
|
||||
time.sleep(delay)
|
||||
remaining = []
|
||||
for item in pending:
|
||||
style = item["style"]
|
||||
fields = _field_list(style["base_token"], style["table_id"])
|
||||
found = next((field for field in fields if field.get("name") == field_name), None)
|
||||
if found and found.get("id"):
|
||||
apply_found(style, found, True)
|
||||
else:
|
||||
remaining.append(item)
|
||||
pending = remaining
|
||||
for item in pending:
|
||||
style = item["style"]
|
||||
errors.append({
|
||||
"style": style.get("name"), "index": style.get("index"),
|
||||
"error": item.get("create_error") or f"field not visible after retries: {field_name}",
|
||||
})
|
||||
if write and not skipped_creations:
|
||||
destination = resolve_layer_output(
|
||||
mapping_path or MAPPING_PATH, layer_root=PATHS.normalized_root, field="mapping_path"
|
||||
)
|
||||
_atomic_write_mapping(mapping, destination)
|
||||
return {
|
||||
"ok": not errors,
|
||||
"field_name": field_name,
|
||||
"ensured": ensured,
|
||||
"created": created,
|
||||
"skipped_creations": skipped_creations,
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def _norm_url(base_token: str, table_id: str, url: str) -> str:
|
||||
if url:
|
||||
return url
|
||||
@@ -386,6 +530,32 @@ def refresh_mapping(force: bool = True, ttl: float = CACHE_TTL,
|
||||
return mapping
|
||||
|
||||
|
||||
def _hydrate_acceptance_daily_field(mapping: dict, *, self_operated: bool) -> dict:
|
||||
"""Supply an in-memory target field while acceptance blocks Base writes."""
|
||||
|
||||
if self_operated:
|
||||
return mapping
|
||||
from gyxx_flow.adapters.acceptance_policy import current_acceptance_policy
|
||||
|
||||
if not current_acceptance_policy().enabled:
|
||||
return mapping
|
||||
field_name = daily_exposure_field_name()
|
||||
for style in mapping.get("tables", []):
|
||||
field_map = style.setdefault("field_map", {})
|
||||
current = field_map.get(DAILY_EXPOSURE_LOGICAL)
|
||||
if current and current.get("field_name") == field_name and current.get("field_id"):
|
||||
continue
|
||||
index = style.get("index")
|
||||
field_map[DAILY_EXPOSURE_LOGICAL] = {
|
||||
"field_id": f"acceptance-skipped:{index}:{field_name}",
|
||||
"field_name": field_name,
|
||||
}
|
||||
names = style.setdefault("all_field_names", [])
|
||||
if field_name not in names:
|
||||
names.append(field_name)
|
||||
return mapping
|
||||
|
||||
|
||||
def load_mapping(data_dir: Path | None = None,
|
||||
force_refresh: bool = False,
|
||||
ttl: float = CACHE_TTL,
|
||||
@@ -399,19 +569,28 @@ def load_mapping(data_dir: Path | None = None,
|
||||
# 缓存是否新鲜
|
||||
fresh = path.exists() and (time.time() - path.stat().st_mtime) < ttl
|
||||
if not force_refresh and fresh:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return _hydrate_acceptance_daily_field(
|
||||
json.loads(path.read_text(encoding="utf-8")),
|
||||
self_operated=self_operated,
|
||||
)
|
||||
|
||||
url_field = INDEX_SELF_URL_FIELD if self_operated else INDEX_URL_FIELD
|
||||
new = refresh_mapping(force=True, ttl=0, mapping_path=path, url_field=url_field)
|
||||
if new is not None:
|
||||
return new
|
||||
return _hydrate_acceptance_daily_field(new, self_operated=self_operated)
|
||||
if not path.exists() and seed_path.exists():
|
||||
_log(f"dynamic refresh failed; using read-only seed: {seed_path}")
|
||||
return json.loads(seed_path.read_text(encoding="utf-8"))
|
||||
return _hydrate_acceptance_daily_field(
|
||||
json.loads(seed_path.read_text(encoding="utf-8")),
|
||||
self_operated=self_operated,
|
||||
)
|
||||
# 刷新失败 → 退回缓存(无论新旧)
|
||||
if path.exists():
|
||||
_log(f"动态刷新失败,使用缓存: {path}")
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return _hydrate_acceptance_daily_field(
|
||||
json.loads(path.read_text(encoding="utf-8")),
|
||||
self_operated=self_operated,
|
||||
)
|
||||
_log("[FATAL] 动态刷新失败且无缓存可用")
|
||||
raise RuntimeError("无法获取款式对照表(动态读取失败且无缓存)")
|
||||
|
||||
+3
-3
@@ -11,9 +11,9 @@ from typing import Any
|
||||
from playwright.sync_api import Page
|
||||
from scrapling.fetchers import DynamicSession
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime import bilibili_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.runtime import douyin_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing.runtime import xiaohongshu_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing import bilibili_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing import douyin_comment_scraper
|
||||
from gyxx_flow.modules.content_marketing import xiaohongshu_comment_scraper
|
||||
|
||||
|
||||
def log(message: str) -> None:
|
||||
+7
-7
@@ -25,18 +25,18 @@ import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
sys.stdout.reconfigure(encoding='utf-8')
|
||||
|
||||
# 复用 weekly_summary_all 的工具函数
|
||||
from gyxx_flow.modules.content_marketing.runtime.weekly_summary_all import (
|
||||
from gyxx_flow.modules.content_marketing.weekly_summary_all import (
|
||||
LARK, DATA_DIR, REPORTS_DIR, dry_run_global,
|
||||
URL_RE, log, call_lark_json, parse_url, parse_bt_tid,
|
||||
load_index_styles, find_target_fields, read_report,
|
||||
run_single_note_analysis, create_feishu_doc, write_to_target_table,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.analyze_comments import call_hermes_analyzer
|
||||
from gyxx_flow.modules.content_marketing.data.tools.analyze_comments import call_hermes_analyzer
|
||||
|
||||
# ============================================================
|
||||
# 月时间范围:动态算上月 1 号到月底
|
||||
@@ -61,7 +61,7 @@ TIME_LABEL = f"{MONTH_START}~{MONTH_END[5:]}"
|
||||
# ============================================================
|
||||
def get_last_month_notes(style_name):
|
||||
"""从 PG 拿上月该款的所有笔记(按 publish_time 过滤)"""
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
@@ -214,7 +214,7 @@ def save_to_db_monthly(style, notes, summary, doc_url, doc_title, status):
|
||||
"""把月度汇总结果写入 PG cmt_monthly_summary(UNIQUE(style_id, month_start))"""
|
||||
if dry_run_global:
|
||||
return False
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_conn
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
@@ -372,7 +372,7 @@ def main():
|
||||
|
||||
global dry_run_global
|
||||
# 引用 weekly 的全局变量(改它会影响 weekly 模块,但 dry_run_global 在 weekly 顶部定义)
|
||||
from gyxx_flow.modules.content_marketing.runtime import weekly_summary_all
|
||||
from gyxx_flow.modules.content_marketing import weekly_summary_all
|
||||
weekly_summary_all.dry_run_global = args.dry_run
|
||||
# 本模块的 dry_run_global 是 import 时的副本,需要同步
|
||||
globals()['dry_run_global'] = args.dry_run
|
||||
@@ -383,7 +383,7 @@ def main():
|
||||
if not index_styles:
|
||||
log("索引表无有效数据,退出")
|
||||
return 1
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.db import get_conn
|
||||
from gyxx_flow.modules.content_marketing.data.tools.db import get_conn
|
||||
with get_conn() as conn:
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
+1
-1
@@ -7,7 +7,7 @@ import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
|
||||
+195
-55
@@ -26,18 +26,14 @@ import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import (
|
||||
PATHS,
|
||||
resolve_layer_output,
|
||||
)
|
||||
from datetime import date, datetime
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.creator_task_grouping import (
|
||||
chunk_creator_groups,
|
||||
group_tasks_by_creator as _group_tasks_by_creator,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime.collection_completeness import (
|
||||
from playwright.sync_api import Page
|
||||
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
|
||||
from scrapling.fetchers import DynamicSession
|
||||
|
||||
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
|
||||
from gyxx_flow.modules.content_marketing.collection_completeness import (
|
||||
BLOCKED_INPUT,
|
||||
RETRYABLE_FAILURE,
|
||||
SUCCESS,
|
||||
@@ -50,15 +46,26 @@ from gyxx_flow.modules.content_marketing.runtime.collection_completeness import
|
||||
match_tasks_to_cards,
|
||||
merge_global_summaries,
|
||||
merge_style_summary,
|
||||
normalize_title as normalize_match_title,
|
||||
select_requested_styles,
|
||||
title_similarity,
|
||||
upsert_result,
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
from playwright.sync_api import Page, TimeoutError as PlaywrightTimeoutError
|
||||
from scrapling.fetchers import DynamicSession
|
||||
from gyxx_flow.modules.content_marketing.collection_completeness import (
|
||||
normalize_title as normalize_match_title,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.creator_task_grouping import (
|
||||
chunk_creator_groups,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.creator_task_grouping import (
|
||||
group_tasks_by_creator as _group_tasks_by_creator,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.daily_creator_exposure_scope import (
|
||||
classify_publish_scope,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import (
|
||||
PATHS,
|
||||
resolve_layer_output,
|
||||
)
|
||||
|
||||
LOGIN_URL = "https://pgy.xiaohongshu.com/"
|
||||
HOME_URL = "https://pgy.xiaohongshu.com/solar/pre-trade/home"
|
||||
@@ -84,6 +91,10 @@ def log(msg: str) -> None:
|
||||
print(f"[{datetime.now().strftime('%H:%M:%S')}] {safe}", flush=True)
|
||||
|
||||
|
||||
class AcceptanceCookieSkip(RuntimeError):
|
||||
"""Raised when acceptance cannot reuse a live non-interactive session."""
|
||||
|
||||
|
||||
def wait_with_heartbeat(total_seconds: int, interval_seconds: int = 600) -> None:
|
||||
"""等待 total_seconds,每 interval_seconds 记录一次进度。"""
|
||||
elapsed = 0
|
||||
@@ -120,8 +131,12 @@ def call_lark_json(args: list[str]) -> dict:
|
||||
|
||||
def load_mapping(self_operated: bool = False) -> dict:
|
||||
# 动态:从「合作达人」/「自营达人」多维表格地址表读取款式→各表地址,实时拉字段重建对照表
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
|
||||
return feishu_mapping.load_mapping(DATA_DIR, self_operated=self_operated)
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
mapping = feishu_mapping.load_mapping(DATA_DIR, self_operated=self_operated)
|
||||
if self_operated:
|
||||
for style in mapping.get("tables", []):
|
||||
style.get("field_map", {}).pop("daily_exposure", None)
|
||||
return mapping
|
||||
|
||||
|
||||
def list_records_by_table(base_token: str, table_id: str, field_id: str | None = None) -> list[dict]:
|
||||
@@ -160,6 +175,14 @@ def list_records_by_table(base_token: str, table_id: str, field_id: str | None =
|
||||
|
||||
def write_back(base_token: str, table_id: str, record_id: str, field_id: str, value) -> bool:
|
||||
"""把 value 写回指定记录的指定字段"""
|
||||
from gyxx_flow.adapters.acceptance_policy import skip_feishu_table_write
|
||||
|
||||
if skip_feishu_table_write(
|
||||
"content_marketing.pgy_xhs.record-upsert",
|
||||
details={"table_id": table_id, "record_id": record_id, "field_id": field_id},
|
||||
):
|
||||
log(f" [ACCEPTANCE-SKIP] record_id={record_id}")
|
||||
return True
|
||||
payload = {field_id: value}
|
||||
resp = call_lark_json([
|
||||
"base", "+record-upsert",
|
||||
@@ -218,12 +241,15 @@ def pick_read_field(fmap: dict, pub_time: datetime | None,
|
||||
29+ 天 -> 月底曝光量
|
||||
未来时间(days<0)也返回 None,调用方应区分"无发布时间"和"未来时间"。
|
||||
"""
|
||||
if pub_time is None:
|
||||
return None # 缺发布时间就不写,让调用方 skip
|
||||
today = today or datetime.now()
|
||||
days = (today.date() - pub_time.date()).days
|
||||
if days < 0:
|
||||
if pub_time is not None and (today.date() - pub_time.date()).days < 0:
|
||||
return "FUTURE" # type: ignore[return-value] # sentinel: 未来时间不写
|
||||
daily = fmap.get("daily_exposure")
|
||||
if daily and daily.get("field_id"):
|
||||
return ("daily_exposure", daily["field_id"], daily["field_name"])
|
||||
if pub_time is None:
|
||||
return None
|
||||
days = (today.date() - pub_time.date()).days
|
||||
if days <= 7:
|
||||
logical = "read_count_7d"
|
||||
elif days <= 14:
|
||||
@@ -330,6 +356,18 @@ def ensure_login(page: Page, login_timeout: int) -> None:
|
||||
log("Already logged in.")
|
||||
save_state(page)
|
||||
return
|
||||
from gyxx_flow.adapters.acceptance_policy import current_acceptance_policy
|
||||
|
||||
acceptance_policy = current_acceptance_policy()
|
||||
if acceptance_policy.enabled:
|
||||
reason = "persisted Pugongying session is invalid; QR login is disabled"
|
||||
acceptance_policy.record(
|
||||
"cookie_skipped",
|
||||
operation="content.pugongying.login",
|
||||
details={"status": "SKIPPED_COOKIE", "reason": reason},
|
||||
)
|
||||
log(f"[SKIPPED_COOKIE] {reason}")
|
||||
raise AcceptanceCookieSkip(reason)
|
||||
page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=60000)
|
||||
page.wait_for_timeout(1500)
|
||||
wait_for_scan_login(page, login_timeout)
|
||||
@@ -402,7 +440,12 @@ def _ensure_search_results(
|
||||
return False
|
||||
|
||||
|
||||
def search_blogger(page: Page, blogger: str, creator_id: str | None = None) -> None:
|
||||
def search_blogger(
|
||||
page: Page,
|
||||
blogger: str,
|
||||
creator_id: str | None = None,
|
||||
no_retry: bool = False,
|
||||
) -> None:
|
||||
"""搜索博主/达人。
|
||||
优先用 creator_id 精确搜(更稳),没有则用 name 模糊搜。
|
||||
"""
|
||||
@@ -486,7 +529,12 @@ def search_blogger(page: Page, blogger: str, creator_id: str | None = None) -> N
|
||||
"""
|
||||
)
|
||||
|
||||
_ensure_search_results(page, query=query, result_name=blogger)
|
||||
_ensure_search_results(
|
||||
page,
|
||||
query=query,
|
||||
result_name=blogger,
|
||||
max_retries=1 if no_retry else 3,
|
||||
)
|
||||
|
||||
|
||||
def dismiss_onboarding(page: Page) -> None:
|
||||
@@ -948,7 +996,10 @@ def find_notes_for_tasks(
|
||||
# ===== 主流程 =====
|
||||
def extract_target_tasks(style: dict, only_record_ids: set[str] | None = None,
|
||||
platform_filter: str = "小红书",
|
||||
require_all: bool = True) -> list[dict]:
|
||||
require_all: bool = True,
|
||||
published_from: date | None = None,
|
||||
max_age_days: int | None = None,
|
||||
collection_date: date | None = None) -> list[dict]:
|
||||
"""
|
||||
根据款式对照表,从多维表格拉出本款式所有待抓任务。
|
||||
|
||||
@@ -971,12 +1022,15 @@ def extract_target_tasks(style: dict, only_record_ids: set[str] | None = None,
|
||||
missing_fields = []
|
||||
if not (name_fid or id_fid):
|
||||
missing_fields.append("creator_name/creator_id")
|
||||
for logical, field_id in (
|
||||
required_mappings = [
|
||||
("note_title", title_fid),
|
||||
("publish_time", pub_fid),
|
||||
("note_url", url_fid),
|
||||
("platform", platform_fid),
|
||||
):
|
||||
]
|
||||
scope_requires_publish_time = published_from is not None or max_age_days is not None
|
||||
if "daily_exposure" not in fmap or scope_requires_publish_time:
|
||||
required_mappings.append(("publish_time", pub_fid))
|
||||
for logical, field_id in required_mappings:
|
||||
if not field_id:
|
||||
missing_fields.append(logical)
|
||||
if missing_fields:
|
||||
@@ -1014,9 +1068,12 @@ def extract_target_tasks(style: dict, only_record_ids: set[str] | None = None,
|
||||
return True
|
||||
|
||||
tasks = []
|
||||
require_publish_time = "daily_exposure" not in fmap or scope_requires_publish_time
|
||||
selected_scope_records = 0
|
||||
skipped_platform = 0
|
||||
skipped_incomplete = 0
|
||||
scope_skip_reasons: dict[str, int] = {}
|
||||
collected_on = collection_date or date.today()
|
||||
for rec in all_records:
|
||||
rid = rec["record_id"]
|
||||
if only_record_ids and rid not in only_record_ids:
|
||||
@@ -1041,7 +1098,8 @@ def extract_target_tasks(style: dict, only_record_ids: set[str] | None = None,
|
||||
if require_all:
|
||||
name_or_id = is_nonempty(name) or is_nonempty(creator_id_val)
|
||||
title_or_id = is_nonempty(title) or bool(note_id)
|
||||
if not (name_or_id and title_or_id and is_nonempty(pub) and is_nonempty(note_url)):
|
||||
publish_ready = is_nonempty(pub) or not require_publish_time
|
||||
if not (name_or_id and title_or_id and publish_ready and is_nonempty(note_url)):
|
||||
skipped_incomplete += 1
|
||||
continue
|
||||
|
||||
@@ -1050,6 +1108,15 @@ def extract_target_tasks(style: dict, only_record_ids: set[str] | None = None,
|
||||
|
||||
existing = rec.get(read7_fid) if read7_fid else None
|
||||
pub_dt = parse_publish_time(pub)
|
||||
scope_reason = classify_publish_scope(
|
||||
pub_dt.date() if pub_dt else None,
|
||||
collected_on,
|
||||
published_from=published_from,
|
||||
max_age_days=max_age_days,
|
||||
)
|
||||
if scope_reason:
|
||||
scope_skip_reasons[scope_reason] = scope_skip_reasons.get(scope_reason, 0) + 1
|
||||
continue
|
||||
tasks.append({
|
||||
"record_id": rid,
|
||||
"creator_name": name if isinstance(name, str) else (str(name) if name else ""),
|
||||
@@ -1065,13 +1132,22 @@ def extract_target_tasks(style: dict, only_record_ids: set[str] | None = None,
|
||||
})
|
||||
|
||||
log(f" 平台过滤: 跳过 {skipped_platform} 条非 {platform_filter} 记录")
|
||||
log(f" 完整性过滤: 跳过 {skipped_incomplete} 条不完整记录(4 字段有空)")
|
||||
required_label = (
|
||||
"达人/标题或ID/发布链接"
|
||||
if not require_publish_time
|
||||
else "达人/标题或ID/发布时间/发布链接"
|
||||
)
|
||||
log(f" 完整性过滤: 跳过 {skipped_incomplete} 条不完整记录(必填={required_label})")
|
||||
if scope_skip_reasons:
|
||||
log(f" 每日范围过滤: 跳过 {sum(scope_skip_reasons.values())} 条 {scope_skip_reasons}")
|
||||
log(f" 实际待抓: {len(tasks)} 条")
|
||||
style["_extract_stats"] = {
|
||||
"source_records": len(all_records),
|
||||
"selected_scope_records": selected_scope_records,
|
||||
"skipped_platform": skipped_platform,
|
||||
"skipped_incomplete": skipped_incomplete,
|
||||
"skipped_publish_scope": sum(scope_skip_reasons.values()),
|
||||
"publish_scope_skip_reasons": scope_skip_reasons,
|
||||
}
|
||||
return tasks
|
||||
|
||||
@@ -1081,12 +1157,24 @@ def group_tasks_by_creator(tasks: list[dict]) -> list[dict]:
|
||||
|
||||
|
||||
def collect_tasks_across_styles(styles: list[dict],
|
||||
only_record_ids: set[str] | None) -> tuple[list[dict], dict[int, dict]]:
|
||||
only_record_ids: set[str] | None,
|
||||
published_from: date | None = None,
|
||||
max_age_days: int | None = None,
|
||||
collection_date: date | None = None) -> tuple[list[dict], dict[int, dict]]:
|
||||
"""Read every selected style first and attach exact write-back context."""
|
||||
all_tasks: list[dict] = []
|
||||
summaries: dict[int, dict] = {}
|
||||
for style in styles:
|
||||
tasks = extract_target_tasks(style, only_record_ids)
|
||||
if published_from is None and max_age_days is None:
|
||||
tasks = extract_target_tasks(style, only_record_ids)
|
||||
else:
|
||||
tasks = extract_target_tasks(
|
||||
style,
|
||||
only_record_ids,
|
||||
published_from=published_from,
|
||||
max_age_days=max_age_days,
|
||||
collection_date=collection_date,
|
||||
)
|
||||
summary = {
|
||||
"style": style["name"], "index": style["index"], "total": len(tasks),
|
||||
"run_started_at": time.time(),
|
||||
@@ -1180,9 +1268,19 @@ def scrape_styles(styles: list[dict], login_timeout: int, headless: bool,
|
||||
only_record_ids: set[str] | None, dry_run: bool,
|
||||
batch_size: int = DEFAULT_BATCH_SIZE,
|
||||
batch_wait: int = DEFAULT_BATCH_WAIT,
|
||||
checkpoint_callback=None) -> list[dict]:
|
||||
checkpoint_callback=None,
|
||||
published_from: date | None = None,
|
||||
max_age_days: int | None = None,
|
||||
collection_date: date | None = None,
|
||||
no_retry: bool = False) -> list[dict]:
|
||||
"""Collect all styles, then search every creator only once in this run."""
|
||||
tasks, summaries = collect_tasks_across_styles(styles, only_record_ids)
|
||||
tasks, summaries = collect_tasks_across_styles(
|
||||
styles,
|
||||
only_record_ids,
|
||||
published_from=published_from,
|
||||
max_age_days=max_age_days,
|
||||
collection_date=collection_date,
|
||||
)
|
||||
if not tasks:
|
||||
log(" 所有款式均无待抓任务,跳过")
|
||||
return [summaries[s["index"]] for s in styles]
|
||||
@@ -1229,8 +1327,10 @@ def scrape_styles(styles: list[dict], login_timeout: int, headless: bool,
|
||||
main_page.bring_to_front()
|
||||
for p in list(main_page.context.pages):
|
||||
if p is not main_page:
|
||||
try: p.close()
|
||||
except: pass
|
||||
try:
|
||||
p.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
main_page.goto(HOME_URL, wait_until="domcontentloaded", timeout=30000)
|
||||
main_page.wait_for_timeout(800)
|
||||
@@ -1243,20 +1343,25 @@ def scrape_styles(styles: list[dict], login_timeout: int, headless: bool,
|
||||
for text in ("跳过", "不再提示", "我知道了"):
|
||||
try:
|
||||
b = main_page.get_by_text(text, exact=True).first
|
||||
if b.count(): b.click(timeout=1500); main_page.wait_for_timeout(300)
|
||||
except: pass
|
||||
if b.count():
|
||||
b.click(timeout=1500)
|
||||
main_page.wait_for_timeout(300)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Search creator
|
||||
search_blogger(main_page, creator_name, creator_id)
|
||||
search_blogger(
|
||||
main_page, creator_name, creator_id, no_retry=no_retry
|
||||
)
|
||||
detail_page = open_blogger_detail(main_page, creator_name, creator_id)
|
||||
if detail_page is None and creator_id:
|
||||
if not no_retry and detail_page is None and creator_id:
|
||||
log(f" [FALLBACK] creator_id 搜不到,改用 name='{creator_name}' 重试")
|
||||
search_blogger(main_page, creator_name, None)
|
||||
detail_page = open_blogger_detail(main_page, creator_name, None)
|
||||
|
||||
# A transient popup/router failure must not fail every note
|
||||
# belonging to this creator after a single attempt.
|
||||
if detail_page is None:
|
||||
if not no_retry and detail_page is None:
|
||||
for retry_no in range(1, 3):
|
||||
log(f" [RETRY] 第 {retry_no}/2 次重新按昵称打开达人详情")
|
||||
search_blogger(main_page, creator_name, None)
|
||||
@@ -1340,8 +1445,10 @@ def scrape_styles(styles: list[dict], login_timeout: int, headless: bool,
|
||||
"error": str(exc),
|
||||
})
|
||||
finally:
|
||||
try: save_state(detail_page)
|
||||
except: pass
|
||||
try:
|
||||
save_state(detail_page)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if detail_page is not None and detail_page is not page and not detail_page.is_closed():
|
||||
detail_page.close()
|
||||
@@ -1367,7 +1474,8 @@ def scrape_styles(styles: list[dict], login_timeout: int, headless: bool,
|
||||
pending.append(group)
|
||||
return pending
|
||||
|
||||
for session_attempt in range(1, 4):
|
||||
max_session_attempts = 1 if no_retry else 3
|
||||
for session_attempt in range(1, max_session_attempts + 1):
|
||||
if not pending_groups():
|
||||
break
|
||||
session = None
|
||||
@@ -1389,15 +1497,21 @@ def scrape_styles(styles: list[dict], login_timeout: int, headless: bool,
|
||||
except Exception as exc:
|
||||
if not is_browser_session_lost(exc):
|
||||
raise
|
||||
log(f" [WARN] 浏览器会话丢失,准备重建 ({session_attempt}/3): {exc}")
|
||||
log(
|
||||
f" [WARN] 浏览器会话丢失 "
|
||||
f"({session_attempt}/{max_session_attempts}): {exc}"
|
||||
)
|
||||
finally:
|
||||
_force_cleanup_session(session)
|
||||
if pending_groups() and session_attempt < 3:
|
||||
if pending_groups() and session_attempt < max_session_attempts:
|
||||
log(f" [RECOVER] 新建浏览器继续剩余 {len(pending_groups())} 个达人")
|
||||
|
||||
remaining = pending_groups()
|
||||
if remaining:
|
||||
log(f" [ERROR] 浏览器重建 3 次后仍有 {len(remaining)} 个达人未处理")
|
||||
log(
|
||||
f" [ERROR] 浏览器会话尝试 {max_session_attempts} 次后"
|
||||
f"仍有 {len(remaining)} 个达人未处理"
|
||||
)
|
||||
for group in remaining:
|
||||
for task in group["tasks"]:
|
||||
summary = summaries[task["style_context"]["index"]]
|
||||
@@ -1547,10 +1661,21 @@ def main() -> int:
|
||||
help="每个批次处理的达人数(默认 0=不分批)")
|
||||
parser.add_argument("--batch-wait", type=int, default=DEFAULT_BATCH_WAIT,
|
||||
help="批次间等待秒数(默认 0)")
|
||||
parser.add_argument("--published-from", type=date.fromisoformat,
|
||||
help="仅采集此日期及以后发布的笔记,格式 YYYY-MM-DD")
|
||||
parser.add_argument("--max-age-days", type=int,
|
||||
help="发布满此天数后停止采集")
|
||||
parser.add_argument("--no-retry", action="store_true",
|
||||
help="每个达人和浏览器会话只尝试一次")
|
||||
parser.add_argument("--skip-field-prepare", action="store_true",
|
||||
help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.login_only:
|
||||
return login_only(args.login_timeout)
|
||||
try:
|
||||
return login_only(args.login_timeout)
|
||||
except AcceptanceCookieSkip:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
|
||||
mapping = load_mapping(self_operated=args.self_operated)
|
||||
styles = mapping["tables"]
|
||||
@@ -1561,6 +1686,16 @@ def main() -> int:
|
||||
print(str(exc))
|
||||
return 1
|
||||
|
||||
if not args.self_operated and not args.skip_field_prepare:
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
prepared = feishu_mapping.ensure_daily_exposure_fields(
|
||||
mapping, dry_run=args.dry_run, write=not args.dry_run,
|
||||
selected_indices={style["index"] for style in styles},
|
||||
)
|
||||
if not prepared["ok"]:
|
||||
log(f"[ERROR] 当天曝光字段准备失败: {prepared['errors']}")
|
||||
return 1
|
||||
|
||||
only_rids = set(args.record) if args.record else None
|
||||
|
||||
data_dir = resolve_layer_output(
|
||||
@@ -1633,11 +1768,16 @@ def main() -> int:
|
||||
|
||||
if pending_styles:
|
||||
log(f"\n========== 全局采集 {len(pending_styles)} 个款式 ==========")
|
||||
new_summaries = scrape_styles(
|
||||
pending_styles, args.login_timeout, args.headless, only_rids, args.dry_run,
|
||||
batch_size=args.batch_size, batch_wait=args.batch_wait,
|
||||
checkpoint_callback=persist_summaries,
|
||||
)
|
||||
try:
|
||||
new_summaries = scrape_styles(
|
||||
pending_styles, args.login_timeout, args.headless, only_rids,
|
||||
args.dry_run, batch_size=args.batch_size,
|
||||
batch_wait=args.batch_wait, checkpoint_callback=persist_summaries,
|
||||
published_from=args.published_from,
|
||||
max_age_days=args.max_age_days, no_retry=args.no_retry,
|
||||
)
|
||||
except AcceptanceCookieSkip:
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
persist_summaries(new_summaries)
|
||||
for s in pending_styles:
|
||||
summary = summaries_by_index[s["index"]]
|
||||
@@ -1654,7 +1794,7 @@ def main() -> int:
|
||||
]
|
||||
|
||||
# 输出总报告
|
||||
log(f"\n========== 24 款式全量汇总 ==========")
|
||||
log("\n========== 24 款式全量汇总 ==========")
|
||||
total_tasks = sum(s["total"] for s in all_summaries)
|
||||
total_matched = sum(s["matched"] for s in all_summaries)
|
||||
total_filled = sum(s["filled"] for s in all_summaries)
|
||||
@@ -1662,7 +1802,7 @@ def main() -> int:
|
||||
log(f" 任务总数: {total_tasks}")
|
||||
log(f" 命中: {total_matched} ({total_matched/max(total_tasks,1)*100:.1f}%)")
|
||||
log(f" 回填: {total_filled}")
|
||||
log(f"\n每款统计:")
|
||||
log("\n每款统计:")
|
||||
for s in all_summaries:
|
||||
rate = s['matched']/max(s['total'],1)*100
|
||||
name = s.get('name') or s.get('style', '?')
|
||||
+178
-23
@@ -29,7 +29,16 @@ import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from gyxx_flow.modules.content_marketing.runtime.runtime_paths import PATHS
|
||||
|
||||
from gyxx_flow.adapters import environment_for_child_script
|
||||
from gyxx_flow.core.exit_codes import COOKIE_SKIP_EXIT_CODE
|
||||
from gyxx_flow.modules.content_marketing.daily_creator_exposure_scope import (
|
||||
DEFAULT_MAX_AGE_DAYS,
|
||||
DEFAULT_PUBLISHED_FROM,
|
||||
EXCLUDED_STYLE_NAMES,
|
||||
select_daily_styles,
|
||||
)
|
||||
from gyxx_flow.modules.content_marketing.runtime_paths import PATHS
|
||||
|
||||
BASE_DIR = PATHS.module_root
|
||||
V2_DIR = PATHS.normalized_root / "v2_results"
|
||||
@@ -82,6 +91,17 @@ SELF_SCRIPTS = {
|
||||
}
|
||||
|
||||
|
||||
def build_child_environment(
|
||||
target: Path | str,
|
||||
base_environment: dict[str, str] | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Bind one nested collector to its own browser and persisted session state."""
|
||||
environment = dict(os.environ if base_environment is None else base_environment)
|
||||
environment["PYTHONIOENCODING"] = "utf-8"
|
||||
environment["PYTHONUNBUFFERED"] = "1"
|
||||
return environment_for_child_script(target, environment)
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
safe = msg.encode("gbk", errors="replace").decode("gbk", errors="replace")
|
||||
@@ -100,17 +120,40 @@ def build_cmd(key: str, args: argparse.Namespace) -> list[str]:
|
||||
return cmd
|
||||
|
||||
|
||||
def start_process(key: str, args: argparse.Namespace,
|
||||
self_operated: bool = False) -> tuple[subprocess.Popen, Path, Path]:
|
||||
"""启动一个抓取进程,返回 (process, stdout_log_path, stderr_log_path)"""
|
||||
def build_process_command(
|
||||
key: str,
|
||||
args: argparse.Namespace,
|
||||
self_operated: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build one child command without starting it."""
|
||||
info = SELF_SCRIPTS[key] if self_operated else SCRIPTS[key]
|
||||
cmd = [sys.executable, str(BASE_DIR / info["script"])]
|
||||
if args.dry_run:
|
||||
cmd.append("--dry-run")
|
||||
if args.style:
|
||||
for s in args.style:
|
||||
cmd += ["--style", str(s)]
|
||||
for style_index in args.style:
|
||||
cmd += ["--style", str(style_index)]
|
||||
cmd.extend(info.get("extra_args", []))
|
||||
if not self_operated:
|
||||
cmd.append("--skip-field-prepare")
|
||||
if getattr(args, "daily_scope", False):
|
||||
cmd += [
|
||||
"--published-from",
|
||||
DEFAULT_PUBLISHED_FROM.isoformat(),
|
||||
"--max-age-days",
|
||||
str(DEFAULT_MAX_AGE_DAYS),
|
||||
]
|
||||
if getattr(args, "daily_scope", False) or getattr(args, "no_retry", False):
|
||||
cmd.append("--no-retry")
|
||||
return cmd
|
||||
|
||||
|
||||
def start_process(key: str, args: argparse.Namespace,
|
||||
self_operated: bool = False) -> tuple[subprocess.Popen, Path, Path]:
|
||||
"""启动一个抓取进程,返回 (process, stdout_log_path, stderr_log_path)"""
|
||||
info = SELF_SCRIPTS[key] if self_operated else SCRIPTS[key]
|
||||
cmd = build_process_command(key, args, self_operated=self_operated)
|
||||
env = build_child_environment(cmd[1])
|
||||
|
||||
stdout_path = LOG_DIR / f"{key}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.stdout.log"
|
||||
stderr_path = LOG_DIR / f"{key}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.stderr.log"
|
||||
@@ -122,12 +165,6 @@ def start_process(key: str, args: argparse.Namespace,
|
||||
if sys.platform == "win32":
|
||||
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
|
||||
env = {
|
||||
**os.environ,
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUNBUFFERED": "1",
|
||||
}
|
||||
|
||||
log(f"启动 [{key}] ({info['name']}): {info['script']} 日志: {stdout_path.name}")
|
||||
p = subprocess.Popen(
|
||||
cmd,
|
||||
@@ -448,7 +485,7 @@ def _expected_style_indices(args: argparse.Namespace,
|
||||
if args.style:
|
||||
return {int(idx) for idx in args.style}
|
||||
try:
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
mapping = feishu_mapping.load_mapping(self_operated=self_operated)
|
||||
return {
|
||||
int(style["index"])
|
||||
@@ -460,6 +497,18 @@ def _expected_style_indices(args: argparse.Namespace,
|
||||
return set()
|
||||
|
||||
|
||||
def styles_after_field_prepare(
|
||||
requested: set[int],
|
||||
failed_indices: set[int],
|
||||
*,
|
||||
preserve_failed: bool = False,
|
||||
) -> list[int]:
|
||||
"""Select run styles after current-date field preparation."""
|
||||
if preserve_failed:
|
||||
return sorted(requested)
|
||||
return sorted(requested - failed_indices)
|
||||
|
||||
|
||||
def _atomic_write_json(path: Path, payload) -> None:
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
@@ -482,11 +531,11 @@ def run_round(args: argparse.Namespace, active: list[str],
|
||||
print(f" 款式: {','.join(str(s) for s in args.style)}")
|
||||
else:
|
||||
try:
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping as _fm
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping as _fm
|
||||
n = len(_fm.load_mapping(self_operated=self_operated).get("tables", []))
|
||||
print(f" 款式: 全部 {n} 款")
|
||||
except Exception:
|
||||
print(f" 款式: 全部")
|
||||
print(" 款式: 全部")
|
||||
print(f" 平台: {','.join(SCRIPTS[k]['name'] for k in active)}")
|
||||
print("=" * 70)
|
||||
|
||||
@@ -563,6 +612,16 @@ def main() -> int:
|
||||
help="同时跑自营达人(默认只跑合作达人)")
|
||||
parser.add_argument("--self-operated-only", action="store_true",
|
||||
help="只跑自营达人")
|
||||
parser.add_argument(
|
||||
"--daily-scope",
|
||||
action="store_true",
|
||||
help="定时日报范围: 排除停采款,仅采 2026-07-01 后发布且未满 30 天的笔记,不自动重试",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-retry",
|
||||
action="store_true",
|
||||
help="不限制业务范围,但关闭平台内部自动重试",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
skip_set = set(s.strip() for s in args.skip.split(",") if s.strip())
|
||||
@@ -579,38 +638,134 @@ def main() -> int:
|
||||
# 启动并行子进程前,先刷新款式对照表,
|
||||
# 避免 3 个子进程各自刷新时重复打 lark-cli 并抢写缓存文件。
|
||||
run_self = args.include_self_operated or args.self_operated_only
|
||||
try:
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
|
||||
m = feishu_mapping.refresh_mapping(force=True)
|
||||
if m:
|
||||
original_style_selection = list(args.style) if args.style else None
|
||||
collaboration_style_selection = original_style_selection
|
||||
daily_field_partial = False
|
||||
if not args.self_operated_only:
|
||||
try:
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
m = feishu_mapping.refresh_mapping(force=True)
|
||||
if m is None:
|
||||
m = feishu_mapping.load_mapping()
|
||||
log(f"合作达人款式对照表已动态刷新: {len(m.get('tables', []))} 个款式")
|
||||
except Exception as exc:
|
||||
log(f"[WARN] 动态刷新合作达人款式对照表失败(将退回缓存): {exc}")
|
||||
if args.daily_scope:
|
||||
scoped_styles = select_daily_styles(
|
||||
m.get("tables", []), original_style_selection
|
||||
)
|
||||
collaboration_style_selection = [
|
||||
int(style["index"]) for style in scoped_styles
|
||||
]
|
||||
requested = (
|
||||
set(original_style_selection)
|
||||
if original_style_selection is not None
|
||||
else None
|
||||
)
|
||||
excluded = sorted(
|
||||
str(style.get("name") or "")
|
||||
for style in m.get("tables", [])
|
||||
if str(style.get("name") or "").strip() in EXCLUDED_STYLE_NAMES
|
||||
and (requested is None or int(style["index"]) in requested)
|
||||
)
|
||||
log(
|
||||
"每日达人曝光范围: "
|
||||
f"启用 {len(scoped_styles)} 款,停采 {excluded or '无'},"
|
||||
f"发布时间>={DEFAULT_PUBLISHED_FROM},"
|
||||
f"采集窗口={DEFAULT_MAX_AGE_DAYS}天,自动重试=关闭"
|
||||
)
|
||||
if not collaboration_style_selection:
|
||||
log("每日范围内没有需要采集的款式")
|
||||
return 0
|
||||
prepared = feishu_mapping.ensure_daily_exposure_fields(
|
||||
m,
|
||||
dry_run=args.dry_run,
|
||||
write=not args.dry_run,
|
||||
selected_indices=(
|
||||
set(collaboration_style_selection)
|
||||
if collaboration_style_selection is not None
|
||||
else None
|
||||
),
|
||||
retry_delays=(0,) if (args.daily_scope or args.no_retry) else (0, 2, 4, 8),
|
||||
)
|
||||
if not prepared["ok"]:
|
||||
failed = {
|
||||
int(row["index"])
|
||||
for row in prepared["errors"]
|
||||
if row.get("index") is not None
|
||||
}
|
||||
requested = (
|
||||
{int(value) for value in collaboration_style_selection}
|
||||
if collaboration_style_selection is not None
|
||||
else {int(style["index"]) for style in m.get("tables", [])}
|
||||
)
|
||||
preserve_failed = args.no_retry and not args.daily_scope
|
||||
successful = styles_after_field_prepare(
|
||||
requested,
|
||||
failed,
|
||||
preserve_failed=preserve_failed,
|
||||
)
|
||||
if not successful:
|
||||
log(f"[ERROR] 当天曝光字段全部准备失败: {prepared['errors']}")
|
||||
return 1
|
||||
collaboration_style_selection = successful
|
||||
if preserve_failed:
|
||||
log(
|
||||
"[WARN] 以下款式无法准备当天曝光字段,但本次全量仍会进入采集,"
|
||||
f"并使用现有曝光字段尝试写回: {prepared['errors']}"
|
||||
)
|
||||
else:
|
||||
log(f"[WARN] 跳过无法准备当天曝光字段的款式: {sorted(failed)}")
|
||||
daily_field_partial = True
|
||||
log(
|
||||
f"合作达人当天曝光字段已准备: {prepared['field_name']} "
|
||||
f"(新建 {len(prepared['created'])} 款)"
|
||||
)
|
||||
except Exception as exc:
|
||||
log(f"[ERROR] 无法准备合作达人当天曝光字段: {exc}")
|
||||
return 1
|
||||
if run_self:
|
||||
try:
|
||||
from gyxx_flow.modules.content_marketing.runtime import feishu_mapping
|
||||
from gyxx_flow.modules.content_marketing import feishu_mapping
|
||||
m = feishu_mapping.refresh_mapping(force=True, url_field=feishu_mapping.INDEX_SELF_URL_FIELD)
|
||||
if m:
|
||||
log(f"自营达人款式对照表已动态刷新: {len(m.get('tables', []))} 个款式")
|
||||
except Exception as exc:
|
||||
log(f"[WARN] 动态刷新自营达人款式对照表失败(将退回缓存): {exc}")
|
||||
|
||||
any_error = False
|
||||
any_error = daily_field_partial
|
||||
non_zero_exit_codes: list[int] = []
|
||||
|
||||
# 第一轮: 合作达人(除非 --self-operated-only)
|
||||
if not args.self_operated_only:
|
||||
args.style = collaboration_style_selection
|
||||
agg = run_round(args, active, self_operated=False)
|
||||
non_zero_exit_codes.extend(
|
||||
code for code in agg.get("exit_codes", {}).values() if code != 0
|
||||
)
|
||||
if agg.get("_non_zero_platforms"):
|
||||
log(f"[WARN] 合作达人: 以下平台 exit != 0: {agg['_non_zero_platforms']}")
|
||||
any_error = True
|
||||
|
||||
# 第二轮: 自营达人(仅 --include-self-operated 或 --self-operated-only 时跑)
|
||||
if args.include_self_operated or args.self_operated_only:
|
||||
args.style = original_style_selection
|
||||
agg = run_round(args, active, self_operated=True)
|
||||
non_zero_exit_codes.extend(
|
||||
code for code in agg.get("exit_codes", {}).values() if code != 0
|
||||
)
|
||||
if agg.get("_non_zero_platforms"):
|
||||
log(f"[WARN] 自营达人: 以下平台 exit != 0: {agg['_non_zero_platforms']}")
|
||||
any_error = True
|
||||
|
||||
return aggregate_exit_code(any_error, non_zero_exit_codes)
|
||||
|
||||
|
||||
def aggregate_exit_code(any_error: bool, non_zero_exit_codes: list[int]) -> int:
|
||||
"""Preserve a clean cookie-skip result across parallel platform children."""
|
||||
|
||||
if any_error and non_zero_exit_codes and all(
|
||||
code == COOKIE_SKIP_EXIT_CODE for code in non_zero_exit_codes
|
||||
):
|
||||
return COOKIE_SKIP_EXIT_CODE
|
||||
return 1 if any_error else 0
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Backward-compatible imports for the former content runtime package.
|
||||
|
||||
Production sources now live directly in :mod:`gyxx_flow.modules.content_marketing`.
|
||||
Keeping this package path lets existing integrations migrate without changing their
|
||||
dotted imports immediately.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_MODULE_ROOT = Path(__file__).resolve().parent.parent
|
||||
__path__ = [str(_MODULE_ROOT)]
|
||||
@@ -1,57 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Daily SKU marketing operations report: Hermes analysis + Feishu dashboard and full report
|
||||
REM Register: schtasks /Create /SC DAILY /TN YingxiaoYunying_DailyMarketingReport /TR %PROJECT_DIR%\data\tools\daily_marketing_report.bat /ST 10:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
where %PYTHON% >nul 2>&1 || set PYTHON=python
|
||||
|
||||
set PYTHONIOENCODING=utf-8
|
||||
set LARK_CLI_NO_PROXY=1
|
||||
set LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1
|
||||
set LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
set LOG_FILE=%LOG_DIR%\daily_marketing_report_%TS%.log
|
||||
|
||||
echo === Daily marketing report started at %date% %time% === > "%LOG_FILE%"
|
||||
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
|
||||
|
||||
REM Wait for today's daily_run to finish (poll every 60s, max 90 min), so that
|
||||
REM cmt_notes is synced before the report reads yesterday's new notes.
|
||||
set TODAY=%date:~0,4%%date:~5,2%%date:~8,2%
|
||||
set /a WAITED=0
|
||||
:wait_daily_run
|
||||
set DAILY_DONE=
|
||||
for %%F in ("%LOG_DIR%\daily_run_%TODAY%_*.log") do findstr /C:"Daily run finished" /C:"Daily run skipped" "%%F" >nul 2>&1 && set DAILY_DONE=1
|
||||
if defined DAILY_DONE goto daily_run_ready
|
||||
if %WAITED% GEQ 5400 goto daily_run_timeout
|
||||
timeout /t 60 /nobreak >nul
|
||||
set /a WAITED+=60
|
||||
goto wait_daily_run
|
||||
:daily_run_timeout
|
||||
echo WARN: daily_run not finished after 90 min wait, generating report anyway >> "%LOG_FILE%"
|
||||
:daily_run_ready
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
call %PYTHON% -u -X utf8 daily_marketing_report.py --send >> "%LOG_FILE%" 2>&1
|
||||
set RC=%ERRORLEVEL%
|
||||
|
||||
echo daily_marketing_report exit=%RC% >> "%LOG_FILE%"
|
||||
echo === Daily marketing report finished at %date% %time% (rc=%RC%) === >> "%LOG_FILE%"
|
||||
|
||||
endlocal & exit /b %RC%
|
||||
@@ -1,61 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Daily task: run 3-platform V2 scrape + sync metrics to cmt_notes (INSERT missing notes every day)
|
||||
REM Register: schtasks /Create /SC DAILY /TN YingxiaoYunying_DailyRun /TR %PROJECT_DIR%\data\tools\daily_run.bat /ST 06:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
REM Task Scheduler (SYSTEM account) has no user PATH -> lock to venv python
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
where %PYTHON% >nul 2>&1 || set PYTHON=python
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
|
||||
set LOG_FILE=%LOG_DIR%\daily_run_%TS%.log
|
||||
|
||||
echo === Daily run started at %date% %time% === > "%LOG_FILE%"
|
||||
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
|
||||
echo --- [step 1] run_all.py (includes feishu_mapping refresh) --- >> "%LOG_FILE%"
|
||||
call %PYTHON% run_all.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_RUN=%ERRORLEVEL%
|
||||
echo run_all exit=%RC_RUN% >> "%LOG_FILE%"
|
||||
|
||||
echo --- [step 2] sync_metrics_to_cmt_notes.py (主采集后首次落库) --- >> "%LOG_FILE%"
|
||||
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_SYNC_PRE=%ERRORLEVEL%
|
||||
echo sync_pre exit=%RC_SYNC_PRE% >> "%LOG_FILE%"
|
||||
|
||||
echo --- [step 3] retry_failed.py (B站 + 蒲公英; 星图每日只搜索一轮) --- >> "%LOG_FILE%"
|
||||
call %PYTHON% data\tools\retry_failed.py --platform bili,pgy --max-attempts 1 --stale-hours 4 >> "%LOG_FILE%" 2>&1
|
||||
set RC_RETRY=%ERRORLEVEL%
|
||||
echo retry_failed exit=%RC_RETRY% >> "%LOG_FILE%"
|
||||
|
||||
echo --- [step 4] sync_metrics_to_cmt_notes.py (补跑后再次落库) --- >> "%LOG_FILE%"
|
||||
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_SYNC_POST=%ERRORLEVEL%
|
||||
echo sync_post exit=%RC_SYNC_POST% >> "%LOG_FILE%"
|
||||
|
||||
set FINAL_RC=0
|
||||
if not "%RC_RUN%"=="0" set FINAL_RC=1
|
||||
if not "%RC_SYNC_PRE%"=="0" set FINAL_RC=1
|
||||
if not "%RC_RETRY%"=="0" set FINAL_RC=1
|
||||
if not "%RC_SYNC_POST%"=="0" set FINAL_RC=1
|
||||
|
||||
echo === Daily run finished at %date% %time% (run=%RC_RUN%, sync_pre=%RC_SYNC_PRE%, retry=%RC_RETRY%, sync_post=%RC_SYNC_POST%, final=%FINAL_RC%) === >> "%LOG_FILE%"
|
||||
|
||||
endlocal & exit /b %FINAL_RC%
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Monday task: run 3-platform V2 + sync metrics to cmt_notes
|
||||
REM Register: schtasks /Create /SC WEEKLY /D MON /TN YingxiaoYunying_MondayBackfill /TR %PROJECT_DIR%\data\tools\daily_run_with_backfill.bat /ST 06:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
REM Task Scheduler (SYSTEM account) has no user PATH -> lock to venv python
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
where %PYTHON% >nul 2>&1 || set PYTHON=python
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
|
||||
set LOG_FILE=%LOG_DIR%\monday_run_%TS%.log
|
||||
|
||||
echo === Monday run started at %date% %time% === > "%LOG_FILE%"
|
||||
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
|
||||
echo --- [step 1] run_all.py (includes feishu_mapping refresh) --- >> "%LOG_FILE%"
|
||||
call %PYTHON% run_all.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_RUN=%ERRORLEVEL%
|
||||
echo run_all exit=%RC_RUN% >> "%LOG_FILE%"
|
||||
|
||||
echo --- [step 2] sync_metrics_to_cmt_notes.py (主采集后首次落库) --- >> "%LOG_FILE%"
|
||||
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_SYNC_PRE=%ERRORLEVEL%
|
||||
echo sync_pre exit=%RC_SYNC_PRE% >> "%LOG_FILE%"
|
||||
|
||||
echo --- [step 3] retry_failed.py --- >> "%LOG_FILE%"
|
||||
call %PYTHON% data\tools\retry_failed.py --max-attempts 1 --stale-hours 4 >> "%LOG_FILE%" 2>&1
|
||||
set RC_RETRY=%ERRORLEVEL%
|
||||
echo retry_failed exit=%RC_RETRY% >> "%LOG_FILE%"
|
||||
|
||||
echo --- [step 4] sync_metrics_to_cmt_notes.py (补跑后再次落库) --- >> "%LOG_FILE%"
|
||||
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_SYNC_POST=%ERRORLEVEL%
|
||||
echo sync_post exit=%RC_SYNC_POST% >> "%LOG_FILE%"
|
||||
|
||||
set FINAL_RC=0
|
||||
if not "%RC_RUN%"=="0" set FINAL_RC=1
|
||||
if not "%RC_SYNC_PRE%"=="0" set FINAL_RC=1
|
||||
if not "%RC_RETRY%"=="0" set FINAL_RC=1
|
||||
if not "%RC_SYNC_POST%"=="0" set FINAL_RC=1
|
||||
|
||||
echo === Monday run finished at %date% %time% (run=%RC_RUN%, sync_pre=%RC_SYNC_PRE%, retry=%RC_RETRY%, sync_post=%RC_SYNC_POST%, final=%FINAL_RC%) === >> "%LOG_FILE%"
|
||||
|
||||
endlocal & exit /b %FINAL_RC%
|
||||
@@ -1,39 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Friday relogin: 4 QR scans — B站, 蒲公英, 星图(+同步到抖音评论), 小红书评论
|
||||
REM 星图登录完成后自动把 cookie 同步到抖音评论 scraper,不需要单独再扫抖音码
|
||||
REM Register: schtasks /Create /SC WEEKLY /D FRI /TN YingxiaoYunying_FridayRelogin /TR %PROJECT_DIR%\data\tools\friday_relogin.bat /ST 10:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
where %PYTHON% >nul 2>&1 || set PYTHON=python
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
|
||||
set LOG_FILE=%LOG_DIR%\friday_relogin_%TS%.log
|
||||
|
||||
echo === Friday relogin started at %date% %time% === > "%LOG_FILE%"
|
||||
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
|
||||
echo --- Launch 5 QR relogins in parallel; send screenshots; retry up to 3 rounds --- >> "%LOG_FILE%"
|
||||
call %PYTHON% data\tools\friday_relogin_parallel.py --max-attempts 3 --round-timeout 300 --screenshot-delay 60 >> "%LOG_FILE%" 2>&1
|
||||
set FINAL_RC=%ERRORLEVEL%
|
||||
|
||||
echo === Friday relogin finished at %date% %time% (exit=%FINAL_RC%) === >> "%LOG_FILE%"
|
||||
|
||||
endlocal & exit /b %FINAL_RC%
|
||||
@@ -1,7 +0,0 @@
|
||||
$moduleRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$dataRoot = if ($env:GYXX_DATA_ROOT) { $env:GYXX_DATA_ROOT } else { Join-Path $moduleRoot 'var' }
|
||||
$profileRoot = Join-Path $dataRoot 'state\content_marketing\browser-profiles'
|
||||
Get-WmiObject Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like "*$profileRoot*" } | ForEach-Object { Write-Host "Killing PID $($_.ProcessId)"; Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep -Seconds 3
|
||||
$procs = Get-WmiObject Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like "*$profileRoot*" }
|
||||
if ($procs) { Write-Host 'Remaining project chrome processes:'; $procs | Select-Object ProcessId,CommandLine } else { Write-Host 'No project chrome processes remaining' }
|
||||
@@ -1,4 +0,0 @@
|
||||
$moduleRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$dataRoot = if ($env:GYXX_DATA_ROOT) { $env:GYXX_DATA_ROOT } else { Join-Path $moduleRoot 'var' }
|
||||
$profileRoot = Join-Path $dataRoot 'state\content_marketing\browser-profiles'
|
||||
Get-WmiObject Win32_Process | Where-Object { $_.Name -eq 'chrome.exe' -and $_.CommandLine -like "*$profileRoot*" } | Select-Object ProcessId, ParentProcessId, @{Name='CmdStart';Expression={$_.CommandLine.Substring(0, [Math]::Min(200, $_.CommandLine.Length))}}
|
||||
@@ -1,55 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Monday 13:00 self-operated pipeline: Bilibili + Chanmama -> sync to cmt_notes (with INSERT)
|
||||
REM Feishu write-back happens inside each scraper; sync step writes PostgreSQL.
|
||||
REM Register: schtasks /Create /SC WEEKLY /D MON /TN YingxiaoYunying_MondaySelf /TR %PROJECT_DIR%\data\tools\monday_self_run.bat /ST 13:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
if not exist "%PYTHON%" set PYTHON=python
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
|
||||
set LOG_FILE=%LOG_DIR%\monday_self_%TS%.log
|
||||
|
||||
echo === Monday self-operated run started at %date% %time% === > "%LOG_FILE%"
|
||||
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
|
||||
echo --- [step 1] self_bilibili_scraper.py --- >> "%LOG_FILE%"
|
||||
call %PYTHON% self_bilibili_scraper.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_BILI=%ERRORLEVEL%
|
||||
echo self_bilibili exit=%RC_BILI% >> "%LOG_FILE%"
|
||||
|
||||
echo --- [step 2] chanmama_scraper.py --- >> "%LOG_FILE%"
|
||||
call %PYTHON% chanmama_scraper.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_CM=%ERRORLEVEL%
|
||||
echo chanmama exit=%RC_CM% >> "%LOG_FILE%"
|
||||
|
||||
echo --- [step 3] sync_metrics_to_cmt_notes.py (with INSERT) --- >> "%LOG_FILE%"
|
||||
call %PYTHON% data\tools\sync_metrics_to_cmt_notes.py >> "%LOG_FILE%" 2>&1
|
||||
set RC_SYNC=%ERRORLEVEL%
|
||||
echo sync exit=%RC_SYNC% >> "%LOG_FILE%"
|
||||
|
||||
set FINAL_RC=0
|
||||
if not "%RC_BILI%"=="0" set FINAL_RC=1
|
||||
if not "%RC_CM%"=="0" set FINAL_RC=1
|
||||
if not "%RC_SYNC%"=="0" set FINAL_RC=1
|
||||
|
||||
echo === Monday self-operated run finished at %date% %time% (bili=%RC_BILI%, cm=%RC_CM%, sync=%RC_SYNC%, final=%FINAL_RC%) === >> "%LOG_FILE%"
|
||||
|
||||
endlocal & exit /b %FINAL_RC%
|
||||
@@ -1,41 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Monthly creator report: run on 1st of each month at 08:30
|
||||
REM 1. Read cmt_cooperations + cmt_creators + cmt_styles from PG
|
||||
REM 2. Generate 9-section analysis report (Markdown)
|
||||
REM 3. Create Feishu doc + save to cmt_creator_report
|
||||
REM Register: schtasks /Create /SC MONTHLY /D 1 /TN YingxiaoYunying_MonthlyCreatorReport /TR %PROJECT_DIR%\data\tools\monthly_creator_report.bat /ST 08:30 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
where %PYTHON% >nul 2>&1 || set PYTHON=python
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
|
||||
set LOG_FILE=%LOG_DIR%\monthly_creator_report_%TS%.log
|
||||
|
||||
echo === Monthly creator report started at %date% %time% === > "%LOG_FILE%"
|
||||
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
|
||||
call %PYTHON% -u -X utf8 data/tools/generate_creator_report.py >> "%LOG_FILE%" 2>&1
|
||||
set RC=%ERRORLEVEL%
|
||||
echo monthly_creator_report exit=%RC% >> "%LOG_FILE%"
|
||||
|
||||
echo === Monthly creator report finished at %date% %time% (rc=%RC%) === >> "%LOG_FILE%"
|
||||
|
||||
endlocal
|
||||
@@ -1,41 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Monthly summary: run on 1st of each month at 08:00
|
||||
REM 1. Compute last month date range
|
||||
REM 2. Query PG for notes per style
|
||||
REM 3. For each style: note analysis + LLM cross-compare + Feishu doc + write back + save to DB
|
||||
REM Register: schtasks /Create /SC MONTHLY /D 1 /TN YingxiaoYunying_MonthlySummary /TR %PROJECT_DIR%\data\tools\monthly_summary.bat /ST 08:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
where %PYTHON% >nul 2>&1 || set PYTHON=python
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
|
||||
set LOG_FILE=%LOG_DIR%\monthly_summary_%TS%.log
|
||||
|
||||
echo === Monthly summary started at %date% %time% === > "%LOG_FILE%"
|
||||
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
|
||||
call %PYTHON% -u -X utf8 monthly_summary_all.py --max-workers 4 >> "%LOG_FILE%" 2>&1
|
||||
set RC=%ERRORLEVEL%
|
||||
echo monthly_summary exit=%RC% >> "%LOG_FILE%"
|
||||
|
||||
echo === Monthly summary finished at %date% %time% (rc=%RC%) === >> "%LOG_FILE%"
|
||||
|
||||
endlocal
|
||||
@@ -1,37 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Sync creator attributes and cooperation records from Feishu to PostgreSQL
|
||||
REM Runs after daily scrape to pick up new creators/notes/cooperations
|
||||
REM Register: schtasks /Create /SC DAILY /TN YingxiaoYunying_SyncCooperations /TR %PROJECT_DIR%\data\tools\sync_cooperations.bat /ST 09:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
where %PYTHON% >nul 2>&1 || set PYTHON=python
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
|
||||
set LOG_FILE=%LOG_DIR%\sync_cooperations_%TS%.log
|
||||
|
||||
echo === sync_cooperations started at %date% %time% === > "%LOG_FILE%"
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
|
||||
call %PYTHON% data\tools\sync_cooperations.py --refresh-mapping >> "%LOG_FILE%" 2>&1
|
||||
set RC=%ERRORLEVEL%
|
||||
|
||||
echo === sync_cooperations finished at %date% %time% exit=%RC% === >> "%LOG_FILE%"
|
||||
|
||||
endlocal & exit /b %RC%
|
||||
@@ -1,80 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Weekly three-platform comment scraper wrapper.
|
||||
REM Runs bilibili, xiaohongshu, douyin in parallel via PowerShell.
|
||||
REM Register: schtasks /Create /SC WEEKLY /D SUN /TN YingxiaoYunying_WeeklyCommentScrape /TR %PROJECT_DIR%\data\tools\weekly_comment_scrape.bat /ST 12:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set "PYTHON_EXE=%GYXX_PYTHON%"
|
||||
set "LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing"
|
||||
set "TMP_DIR=%GYXX_DATA_ROOT%\tmp\content_marketing"
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
if not exist "%TMP_DIR%" mkdir "%TMP_DIR%"
|
||||
|
||||
for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value') do set "NOW=%%I"
|
||||
set "STAMP=%NOW:~0,4%-%NOW:~4,2%-%NOW:~6,2%_%NOW:~8,2%-%NOW:~10,2%-%NOW:~12,2%"
|
||||
|
||||
set "MASTER_LOG=%LOG_DIR%\weekly_%STAMP%.log"
|
||||
echo === weekly scrape started %STAMP% > "%MASTER_LOG%"
|
||||
|
||||
set "DOUYIN_LOG=%LOG_DIR%\weekly_%STAMP%_douyin.log"
|
||||
set "XHS_LOG=%LOG_DIR%\weekly_%STAMP%_xiaohongshu.log"
|
||||
set "BILI_LOG=%LOG_DIR%\weekly_%STAMP%_bilibili.log"
|
||||
|
||||
echo [%STAMP%] launching douyin, xiaohongshu, bilibili in parallel >> "%MASTER_LOG%"
|
||||
|
||||
set "PS_SCRIPT=%GYXX_DATA_ROOT%\tmp\content_marketing\weekly_scrape_%STAMP%.ps1"
|
||||
> "%PS_SCRIPT%" echo $ErrorActionPreference = 'Continue'
|
||||
>> "%PS_SCRIPT%" echo $env:PYTHONIOENCODING = 'utf-8'
|
||||
>> "%PS_SCRIPT%" echo $env:PYTHONUTF8 = '1'
|
||||
>> "%PS_SCRIPT%" echo Set-Location -LiteralPath '%PROJECT_DIR%'
|
||||
>> "%PS_SCRIPT%" echo $py = '%GYXX_PYTHON%'
|
||||
>> "%PS_SCRIPT%" echo $stamp = '%STAMP%'
|
||||
>> "%PS_SCRIPT%" echo $logDir = '%GYXX_DATA_ROOT%\logs\content_marketing'
|
||||
>> "%PS_SCRIPT%" echo $jobs = @(
|
||||
>> "%PS_SCRIPT%" echo [pscustomobject]@{ Name='bilibili'; Script='%PROJECT_DIR%\data\tools\batch_rescrape_bilibili.py'; Log=Join-Path $logDir ("weekly_${stamp}_bilibili.log") },
|
||||
>> "%PS_SCRIPT%" echo [pscustomobject]@{ Name='xiaohongshu'; Script='%PROJECT_DIR%\data\tools\batch_rescrape_xiaohongshu.py'; Log=Join-Path $logDir ("weekly_${stamp}_xiaohongshu.log") },
|
||||
>> "%PS_SCRIPT%" echo [pscustomobject]@{ Name='douyin'; Script='%PROJECT_DIR%\data\tools\batch_rescrape_douyin.py'; Log=Join-Path $logDir ("weekly_${stamp}_douyin.log") }
|
||||
>> "%PS_SCRIPT%" echo )
|
||||
>> "%PS_SCRIPT%" echo $started = @{}
|
||||
>> "%PS_SCRIPT%" echo foreach ($job in $jobs) {
|
||||
>> "%PS_SCRIPT%" echo $logPath = $job.Log
|
||||
>> "%PS_SCRIPT%" echo $header = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] === $($job.Name) launch ==="
|
||||
>> "%PS_SCRIPT%" echo $cmd = "`"$py`" -X utf8 `"$($job.Script)`" >> `"$logPath`" 2>&1"
|
||||
>> "%PS_SCRIPT%" echo Add-Content -LiteralPath $logPath -Value $header -Encoding utf8
|
||||
>> "%PS_SCRIPT%" echo $proc = Start-Process -FilePath cmd.exe -ArgumentList '/c', "`"$cmd`"" -PassThru -WindowStyle Hidden
|
||||
>> "%PS_SCRIPT%" echo if ($null -eq $proc) {
|
||||
>> "%PS_SCRIPT%" echo Add-Content -LiteralPath $logPath -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] !!! Start-Process returned null for $($job.Name) !!!" -Encoding utf8
|
||||
>> "%PS_SCRIPT%" echo continue
|
||||
>> "%PS_SCRIPT%" echo }
|
||||
>> "%PS_SCRIPT%" echo $started[$job.Name] = @{ Proc=$proc; Log=$logPath }
|
||||
>> "%PS_SCRIPT%" echo }
|
||||
>> "%PS_SCRIPT%" echo $exitCodes = @{}
|
||||
>> "%PS_SCRIPT%" echo foreach ($job in $jobs) {
|
||||
>> "%PS_SCRIPT%" echo $entry = $started[$job.Name]
|
||||
>> "%PS_SCRIPT%" echo $entry.Proc.WaitForExit()
|
||||
>> "%PS_SCRIPT%" echo $rc = $entry.Proc.ExitCode
|
||||
>> "%PS_SCRIPT%" echo $exitCodes[$job.Name] = $rc
|
||||
>> "%PS_SCRIPT%" echo Add-Content -LiteralPath $entry.Log -Value "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] === $($job.Name) done, exit=$rc ===" -Encoding utf8
|
||||
>> "%PS_SCRIPT%" echo }
|
||||
>> "%PS_SCRIPT%" echo foreach ($k in $exitCodes.Keys) { Write-Output ("{0}={1}" -f $k, $exitCodes[$k]) }
|
||||
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS_SCRIPT%" >> "%MASTER_LOG%" 2>&1
|
||||
set "RC=%ERRORLEVEL%"
|
||||
|
||||
del "%PS_SCRIPT%" 2>nul
|
||||
|
||||
echo [%STAMP%] all three scrapers finished, ps exit=%RC% >> "%MASTER_LOG%"
|
||||
echo === weekly scrape done %STAMP% >> "%MASTER_LOG%"
|
||||
|
||||
endlocal & exit /b %RC%
|
||||
@@ -1,42 +0,0 @@
|
||||
@echo off
|
||||
if not defined GYXX_PYTHON set "GYXX_PYTHON=python"
|
||||
REM Weekly summary: 每周一 10:00 跑上周各款周笔记汇总
|
||||
REM 1. 动态算上周一到上周日时间范围
|
||||
REM 2. PG 查上周有笔记的款
|
||||
REM 3. 对每个款:跑单品分析 + LLM 4 维度综合 + 创建飞书文档 + 写入生命进程表
|
||||
REM Register: schtasks /Create /SC WEEKLY /D TUE /TN YingxiaoYunying_WeeklySummary /TR %PROJECT_DIR%\data\tools\weekly_summary.bat /ST 10:00 /F
|
||||
|
||||
setlocal
|
||||
|
||||
for %%I in ("%~dp0..\..") do set "PROJECT_DIR=%%~fI"
|
||||
if not defined GYXX_DATA_ROOT (
|
||||
if not defined GYXX_PROJECT_ROOT (
|
||||
echo ERROR: GYXX_DATA_ROOT or GYXX_PROJECT_ROOT must be defined. 1>&2
|
||||
exit /b 3
|
||||
)
|
||||
set "GYXX_DATA_ROOT=%GYXX_PROJECT_ROOT%\var"
|
||||
)
|
||||
set LOG_DIR=%GYXX_DATA_ROOT%\logs\content_marketing\scheduled
|
||||
set PYTHON=%GYXX_PYTHON%
|
||||
where %PYTHON% >nul 2>&1 || set PYTHON=python
|
||||
|
||||
if not exist "%LOG_DIR%" mkdir "%LOG_DIR%"
|
||||
|
||||
set TS=%date:~0,4%%date:~5,2%%date:~8,2%_%time:~0,2%%time:~3,2%%time:~6,2%
|
||||
set TS=%TS: =0%
|
||||
|
||||
set LOG_FILE=%LOG_DIR%\weekly_summary_%TS%.log
|
||||
|
||||
echo === Weekly summary started at %date% %time% === > "%LOG_FILE%"
|
||||
echo PROJECT_DIR=%PROJECT_DIR% >> "%LOG_FILE%"
|
||||
|
||||
cd /d "%PROJECT_DIR%"
|
||||
|
||||
REM 跑周汇总(4 路并发,动态时间范围)
|
||||
call %PYTHON% -u -X utf8 weekly_summary_all.py --max-workers 4 >> "%LOG_FILE%" 2>&1
|
||||
set RC=%ERRORLEVEL%
|
||||
echo weekly_summary exit=%RC% >> "%LOG_FILE%"
|
||||
|
||||
echo === Weekly summary finished at %date% %time% (rc=%RC%) === >> "%LOG_FILE%"
|
||||
|
||||
endlocal
|
||||
-57
@@ -1,57 +0,0 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
TOOLS_DIR = PROJECT_ROOT / "data" / "tools"
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import analyze_note # noqa: E402
|
||||
|
||||
|
||||
class AnalyzeNoteWithoutCommentsTest(unittest.TestCase):
|
||||
def test_no_comments_still_writes_metrics_report(self):
|
||||
info = {
|
||||
"title": "无评论测试笔记",
|
||||
"platform": "xiaohongshu",
|
||||
"source_url": "https://example.com/note",
|
||||
"scraped_at": "",
|
||||
"comment_count_total": 0,
|
||||
"like_count": 12,
|
||||
"favorite_count": 3,
|
||||
"comment_count_metric": 0,
|
||||
"share_count": 1,
|
||||
"view_count": 1000,
|
||||
"collect_count_yxyy": 0,
|
||||
"style_name": "测试款",
|
||||
"creator_name": "测试达人",
|
||||
}
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
output = Path(tmpdir) / "note.txt"
|
||||
argv = [
|
||||
"analyze_note.py",
|
||||
"--note-id",
|
||||
"1",
|
||||
"--no-send",
|
||||
"--brand",
|
||||
"光影行星",
|
||||
"--output",
|
||||
str(output),
|
||||
]
|
||||
with patch.object(analyze_note, "load_note_from_db", return_value=(info, [])), patch.object(
|
||||
sys, "argv", argv
|
||||
):
|
||||
result = analyze_note.main()
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
self.assertTrue(output.exists())
|
||||
report = output.read_text(encoding="utf-8-sig")
|
||||
self.assertIn("无有效评论可分析", report)
|
||||
self.assertIn("1,000", report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,9 +0,0 @@
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import batch_rescrape_douyin as batch
|
||||
|
||||
|
||||
def test_result_logged_in_reads_nested_scraper_stats():
|
||||
result = {"stats": {"logged_in": True}}
|
||||
|
||||
assert batch.result_logged_in(result) is True
|
||||
assert batch.result_logged_in({"stats": {"logged_in": False}}) is False
|
||||
assert batch.result_logged_in({}) is False
|
||||
-840
@@ -1,840 +0,0 @@
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime import collection_completeness as cc
|
||||
import bilibili_scraper as bili
|
||||
import pgy_xhs_scraper_v2 as pgy
|
||||
import run_all
|
||||
import self_douyin_scraper as self_dy
|
||||
import xingtu_scraper_v2 as xingtu
|
||||
|
||||
|
||||
def test_normalize_title_handles_nfkc_case_and_zero_width():
|
||||
assert cc.normalize_title("MacBook\u200b 通勤包!") == "macbook通勤包"
|
||||
|
||||
|
||||
def test_coerce_url_unwraps_feishu_markdown_links():
|
||||
value = "[查看笔记](https://www.xiaohongshu.com/discovery/item/abc123?x=1)"
|
||||
assert cc.coerce_url(value) == "https://www.xiaohongshu.com/discovery/item/abc123?x=1"
|
||||
|
||||
|
||||
def test_match_tasks_prefers_note_id_over_changed_title():
|
||||
tasks = [{
|
||||
"record_id": "r1",
|
||||
"target_title": "飞书中的旧标题",
|
||||
"note_url": "https://www.xiaohongshu.com/explore/abc123",
|
||||
"note_id": "abc123",
|
||||
}]
|
||||
cards = [
|
||||
{"title": "完全不同的新标题", "href": "https://www.xiaohongshu.com/explore/abc123", "note_id": "abc123"},
|
||||
{"title": "飞书中的旧标题", "href": "https://www.xiaohongshu.com/explore/other", "note_id": "other"},
|
||||
]
|
||||
|
||||
matched = cc.match_tasks_to_cards(tasks, cards, platform="xhs")
|
||||
|
||||
assert matched["r1"]["note_id"] == "abc123"
|
||||
assert matched["r1"]["match_method"] == "content_id"
|
||||
|
||||
|
||||
def test_match_tasks_accepts_unique_truncated_title_but_rejects_ambiguous_title():
|
||||
task = {"record_id": "r1", "target_title": "男生长期主义通勤双肩包分享"}
|
||||
unique = [
|
||||
{"title": "男生长期主义通勤双肩包"},
|
||||
{"title": "夏日轻量斜挎包"},
|
||||
]
|
||||
ambiguous = [
|
||||
{"title": "男生长期主义通勤双肩包"},
|
||||
{"title": "男生长期主义通勤双肩包"},
|
||||
]
|
||||
|
||||
assert cc.match_tasks_to_cards([task], unique, platform="douyin")["r1"]
|
||||
assert "r1" not in cc.match_tasks_to_cards([task], ambiguous, platform="douyin")
|
||||
|
||||
|
||||
def test_title_similarity_ignores_episode_prefix_and_hashtag_suffix():
|
||||
target = "第20集:Pocket4拍旋焦,无后期也能出片?"
|
||||
platform_title = (
|
||||
"Pocket4拍旋焦,无后期也能出片? #旋焦 #pocket4 #摄影装备 "
|
||||
"无论Pocket3还是Pocket4都可以轻松拍出旋焦效果!"
|
||||
)
|
||||
|
||||
assert cc.title_similarity(platform_title, target) >= 0.98
|
||||
|
||||
|
||||
def test_title_similarity_rejects_different_episode_numbers():
|
||||
assert cc.title_similarity(
|
||||
"第19集:Pocket4拍旋焦,无后期也能出片?",
|
||||
"第20集:Pocket4拍旋焦,无后期也能出片?",
|
||||
) == 0.0
|
||||
|
||||
|
||||
def test_match_tasks_rejects_duplicate_exact_titles_with_different_ids():
|
||||
task = {"record_id": "r1", "target_title": "完全相同的目标标题"}
|
||||
cards = [
|
||||
{"title": "完全相同的目标标题", "note_id": "video-1"},
|
||||
{"title": "完全相同的目标标题", "note_id": "video-2"},
|
||||
]
|
||||
|
||||
assert "r1" not in cc.match_tasks_to_cards([task], cards, platform="douyin")
|
||||
|
||||
|
||||
def test_one_card_is_not_reused_for_two_different_creator_tasks():
|
||||
tasks = [
|
||||
{
|
||||
"record_id": "loose",
|
||||
"target_title": "commuter backpack review today",
|
||||
"note_url": "https://v.douyin.com/loose-source/",
|
||||
},
|
||||
{
|
||||
"record_id": "exact",
|
||||
"target_title": "commuter backpack review",
|
||||
"note_url": "https://v.douyin.com/exact-source/",
|
||||
},
|
||||
]
|
||||
cards = [{"title": "commuter backpack review", "note_id": "observed-1"}]
|
||||
|
||||
matched = cc.match_tasks_to_cards(tasks, cards, platform="douyin")
|
||||
|
||||
assert set(matched) == {"exact"}
|
||||
|
||||
|
||||
def test_same_source_url_can_share_one_card_across_styles():
|
||||
tasks = [
|
||||
{
|
||||
"record_id": "style-1",
|
||||
"target_title": "同一篇跨款式笔记",
|
||||
"note_url": "https://v.douyin.com/same-short-link/",
|
||||
},
|
||||
{
|
||||
"record_id": "style-2",
|
||||
"target_title": "同一篇跨款式笔记",
|
||||
"note_url": "https://v.douyin.com/same-short-link/",
|
||||
},
|
||||
]
|
||||
cards = [{"title": "同一篇跨款式笔记", "note_id": "observed-1"}]
|
||||
|
||||
assert set(cc.match_tasks_to_cards(tasks, cards, platform="douyin")) == {
|
||||
"style-1", "style-2",
|
||||
}
|
||||
|
||||
|
||||
def test_known_mismatched_content_ids_cannot_fall_back_to_same_title():
|
||||
task = {
|
||||
"record_id": "r1",
|
||||
"target_title": "完全相同标题",
|
||||
"note_id": "video-a",
|
||||
}
|
||||
card = {"title": "完全相同标题", "note_id": "video-b"}
|
||||
|
||||
assert cc.match_tasks_to_cards([task], [card], platform="douyin") == {}
|
||||
|
||||
|
||||
def test_xingtu_api_and_dom_duplicate_are_merged_before_matching():
|
||||
cards = [
|
||||
{
|
||||
"title": "同一条视频",
|
||||
"play_count": 19481,
|
||||
"note_id": "video-1",
|
||||
"source": "show_items_api",
|
||||
},
|
||||
{
|
||||
"title": "同一条视频",
|
||||
"play_count": "1.9w",
|
||||
"note_id": "",
|
||||
"page": 1,
|
||||
},
|
||||
]
|
||||
|
||||
deduped = cc.deduplicate_observed_cards(cards)
|
||||
matched = cc.match_tasks_to_cards(
|
||||
[{"record_id": "r1", "target_title": "同一条视频"}],
|
||||
deduped,
|
||||
platform="douyin",
|
||||
)
|
||||
|
||||
assert len(deduped) == 1
|
||||
assert matched["r1"]["note_id"] == "video-1"
|
||||
|
||||
|
||||
def test_merge_partial_summary_keeps_existing_success_rows():
|
||||
existing = {
|
||||
"style": "款式A", "index": 1, "total": 2, "matched": 1, "filled": 1,
|
||||
"results": [
|
||||
{"record_id": "ok", "status": "success", "matched": True, "write_ok": True},
|
||||
{"record_id": "retry", "status": "retryable_failure", "matched": False},
|
||||
],
|
||||
}
|
||||
partial = {
|
||||
"style": "款式A", "index": 1, "total": 1, "matched": 1, "filled": 1,
|
||||
"results": [
|
||||
{"record_id": "retry", "status": "success", "matched": True, "write_ok": True},
|
||||
],
|
||||
}
|
||||
|
||||
merged = cc.merge_style_summary(existing, partial)
|
||||
|
||||
assert merged["total"] == 2
|
||||
assert {row["record_id"] for row in merged["results"]} == {"ok", "retry"}
|
||||
assert merged["matched"] == 2
|
||||
assert merged["filled"] == 2
|
||||
assert merged["complete"] is True
|
||||
|
||||
|
||||
def test_summary_contract_detects_missing_or_write_failure():
|
||||
summary = {
|
||||
"total": 2,
|
||||
"results": [
|
||||
{"record_id": "ok", "status": "success", "matched": True, "write_ok": True},
|
||||
{"record_id": "bad", "status": "write_failure", "matched": True, "write_ok": False},
|
||||
],
|
||||
}
|
||||
finalized = cc.finalize_summary(summary, dry_run=False)
|
||||
assert finalized["complete"] is False
|
||||
assert finalized["unresolved"] == 1
|
||||
|
||||
missing = cc.finalize_summary({"total": 2, "results": summary["results"][:1]}, dry_run=False)
|
||||
assert missing["complete"] is False
|
||||
assert missing["missing_results"] == 1
|
||||
|
||||
|
||||
def _style_for_platform(platform: str):
|
||||
return {
|
||||
"index": 1,
|
||||
"name": "款式A",
|
||||
"base_token": "base",
|
||||
"table_id": "table",
|
||||
"field_map": {
|
||||
"creator_name": {"field_id": "name"},
|
||||
"creator_id": {"field_id": "creator_id"},
|
||||
"note_title": {"field_id": "title"},
|
||||
"publish_time": {"field_id": "pub"},
|
||||
"note_url": {"field_id": "url"},
|
||||
"platform": {"field_id": "platform"},
|
||||
"read_count_7d": {"field_id": "read7"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_extractors_preserve_note_url_and_content_id(monkeypatch):
|
||||
xhs_row = {
|
||||
"record_id": "xhs-r", "name": "达人", "creator_id": "xhs-id",
|
||||
"title": "标题", "pub": "2026-07-20", "platform": "小红书",
|
||||
"url": "https://www.xiaohongshu.com/explore/abc123", "read7": None,
|
||||
}
|
||||
dy_row = {
|
||||
"record_id": "dy-r", "name": "达人", "creator_id": "dy-id",
|
||||
"title": "标题", "pub": "2026-07-20", "platform": "抖音",
|
||||
"url": "https://www.douyin.com/video/7654321", "read7": None,
|
||||
}
|
||||
monkeypatch.setattr(pgy, "list_records_by_table", lambda *_: [xhs_row])
|
||||
monkeypatch.setattr(xingtu, "list_records_by_table", lambda *_: [dy_row])
|
||||
|
||||
xhs_task = pgy.extract_target_tasks(_style_for_platform("小红书"))[0]
|
||||
dy_task = xingtu.extract_target_tasks(_style_for_platform("抖音"))[0]
|
||||
|
||||
assert xhs_task["note_url"].endswith("/abc123")
|
||||
assert xhs_task["note_id"] == "abc123"
|
||||
assert dy_task["note_url"].endswith("/7654321")
|
||||
assert dy_task["note_id"] == "7654321"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("module", "platform", "filled_url"),
|
||||
[
|
||||
(pgy, "小红书", "https://www.douyin.com/video/7654321"),
|
||||
(xingtu, "抖音", "http://8.99 分享文案 https://v.douyin.com/abc123/"),
|
||||
],
|
||||
)
|
||||
def test_filled_publish_link_triggers_collection_without_platform_validation(
|
||||
monkeypatch, module, platform, filled_url,
|
||||
):
|
||||
row = {
|
||||
"record_id": "r1",
|
||||
"name": "达人",
|
||||
"creator_id": "creator-id",
|
||||
"title": "已发布标题",
|
||||
"pub": "2026-07-20",
|
||||
"platform": platform,
|
||||
"url": filled_url,
|
||||
"read7": None,
|
||||
}
|
||||
monkeypatch.setattr(module, "list_records_by_table", lambda *_: [row])
|
||||
|
||||
task = module.extract_target_tasks(_style_for_platform(platform))[0]
|
||||
|
||||
assert task["input_error"] is None
|
||||
|
||||
|
||||
def test_extractors_can_collect_missing_title_when_direct_url_has_content_id(monkeypatch):
|
||||
xhs_style = _style_for_platform("小红书")
|
||||
dy_style = _style_for_platform("抖音")
|
||||
xhs_row = {
|
||||
"record_id": "xhs-r", "name": "达人", "creator_id": "xhs-id",
|
||||
"title": None, "pub": "2026-07-20", "platform": "小红书",
|
||||
"url": "https://www.xiaohongshu.com/explore/abc123", "read7": None,
|
||||
}
|
||||
dy_row = {
|
||||
"record_id": "dy-r", "name": "达人", "creator_id": "dy-id",
|
||||
"title": None, "pub": "2026-07-20", "platform": "抖音",
|
||||
"url": "https://www.douyin.com/video/7654321", "read7": None,
|
||||
}
|
||||
monkeypatch.setattr(pgy, "list_records_by_table", lambda *_: [xhs_row])
|
||||
monkeypatch.setattr(xingtu, "list_records_by_table", lambda *_: [dy_row])
|
||||
|
||||
assert pgy.extract_target_tasks(xhs_style)[0]["note_id"] == "abc123"
|
||||
assert xingtu.extract_target_tasks(dy_style)[0]["note_id"] == "7654321"
|
||||
assert xhs_style["_extract_stats"]["skipped_incomplete"] == 0
|
||||
assert dy_style["_extract_stats"]["skipped_incomplete"] == 0
|
||||
|
||||
|
||||
def test_missing_critical_mapping_is_an_incomplete_summary(monkeypatch):
|
||||
style = _style_for_platform("抖音")
|
||||
del style["field_map"]["note_url"]
|
||||
monkeypatch.setattr(
|
||||
xingtu,
|
||||
"list_records_by_table",
|
||||
lambda *_: (_ for _ in ()).throw(AssertionError("mapping should fail first")),
|
||||
)
|
||||
|
||||
_, summaries = xingtu.collect_tasks_across_styles([style], None)
|
||||
finalized = cc.finalize_summary(summaries[1], dry_run=True)
|
||||
|
||||
assert "note_url" in finalized["error"]
|
||||
assert finalized["complete"] is False
|
||||
assert finalized["unresolved"] >= 1
|
||||
|
||||
|
||||
class _FakeInput:
|
||||
def __init__(self):
|
||||
self.filled = []
|
||||
|
||||
def click(self):
|
||||
return None
|
||||
|
||||
def fill(self, value):
|
||||
self.filled.append(value)
|
||||
|
||||
def press(self, _key):
|
||||
return None
|
||||
|
||||
|
||||
class _FakeLocator:
|
||||
def __init__(self, input_):
|
||||
self.first = input_
|
||||
|
||||
def filter(self, **_kwargs):
|
||||
return self
|
||||
|
||||
|
||||
class _FakeSearchPage:
|
||||
def __init__(self):
|
||||
self.input = _FakeInput()
|
||||
self.has_result_calls = 0
|
||||
|
||||
def wait_for_timeout(self, _ms):
|
||||
return None
|
||||
|
||||
def evaluate(self, script, arg=None):
|
||||
if "const rows" in script:
|
||||
self.has_result_calls += 1
|
||||
return self.has_result_calls >= 2
|
||||
if "const inputs" in script:
|
||||
return ""
|
||||
raise AssertionError(script)
|
||||
|
||||
def locator(self, _selector):
|
||||
return _FakeLocator(self.input)
|
||||
|
||||
def wait_for_url(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def test_pgy_search_retry_keeps_creator_id_query():
|
||||
page = _FakeSearchPage()
|
||||
|
||||
pgy._ensure_search_results(page, query="xhs-id-123", result_name="达人昵称", max_retries=2)
|
||||
|
||||
assert page.input.filled == ["xhs-id-123"]
|
||||
|
||||
|
||||
def test_apply_matched_task_reports_write_failure(monkeypatch):
|
||||
task = {
|
||||
"record_id": "r1", "publish_time": object(),
|
||||
"style_context": {
|
||||
"index": 1, "base_token": "base", "table_id": "table", "field_map": {},
|
||||
},
|
||||
}
|
||||
summary = {"matched": 0, "filled": 0, "skipped_no_pubtime": 0, "results": []}
|
||||
monkeypatch.setattr(pgy, "pick_read_field", lambda *_: ("read_count_7d", "fid", "7天曝光量"))
|
||||
monkeypatch.setattr(pgy, "write_back", lambda *_: False)
|
||||
|
||||
outcome = pgy.apply_matched_task(task, {"read_count": "123", "title": "标题"}, summary, dry_run=False)
|
||||
|
||||
assert outcome["status"] == "write_failure"
|
||||
assert outcome["write_ok"] is False
|
||||
|
||||
|
||||
def test_default_creator_batch_has_no_forced_hour_pause():
|
||||
assert pgy.DEFAULT_BATCH_SIZE == 0
|
||||
assert xingtu.DEFAULT_BATCH_SIZE == 0
|
||||
|
||||
|
||||
def test_repeatable_style_argument_and_selection_keep_every_requested_style():
|
||||
parser = argparse.ArgumentParser()
|
||||
cc.add_repeatable_style_argument(parser, "styles")
|
||||
args = parser.parse_args(["--style", "1", "--style", "3"])
|
||||
styles = [{"index": 1}, {"index": 2}, {"index": 3}]
|
||||
|
||||
assert args.style == [1, 3]
|
||||
assert cc.select_requested_styles(styles, args.style) == [styles[0], styles[2]]
|
||||
with pytest.raises(ValueError, match="99"):
|
||||
cc.select_requested_styles(styles, [1, 99])
|
||||
|
||||
|
||||
def test_pgy_stable_cards_retry_an_initial_empty_render(monkeypatch):
|
||||
sequence = [[], [{"title": "标题", "read_count": "1"}], [{"title": "标题", "read_count": "1"}]]
|
||||
monkeypatch.setattr(pgy, "parse_cards_on_page", lambda _page: sequence.pop(0))
|
||||
|
||||
class Page:
|
||||
waits = 0
|
||||
|
||||
def wait_for_timeout(self, _ms):
|
||||
self.waits += 1
|
||||
|
||||
page = Page()
|
||||
assert pgy.parse_cards_stable(page) == [{"title": "标题", "read_count": "1"}]
|
||||
assert page.waits == 2
|
||||
|
||||
|
||||
def test_xingtu_paginates_before_falling_back_to_search(monkeypatch):
|
||||
pages = [
|
||||
[{"title": "无关视频", "play_count": "10"}],
|
||||
[{"title": "目标视频标题", "play_count": "20"}],
|
||||
]
|
||||
calls = {"next": 0}
|
||||
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
|
||||
monkeypatch.setattr(xingtu, "parse_videos_stable", lambda _page: pages.pop(0))
|
||||
|
||||
def next_page(_page):
|
||||
calls["next"] += 1
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(xingtu, "go_next_page", next_page)
|
||||
monkeypatch.setattr(xingtu, "submit_video_search", lambda *_: False)
|
||||
|
||||
found, _, _ = xingtu.find_videos_for_tasks(
|
||||
object(), [{"record_id": "r1", "target_title": "目标视频标题"}], max_pages=2,
|
||||
)
|
||||
|
||||
assert found["r1"]["play_count"] == "20"
|
||||
assert calls["next"] == 1
|
||||
|
||||
|
||||
def test_xingtu_normalizes_show_items_api_video():
|
||||
card = xingtu.normalize_xingtu_api_item({
|
||||
"item_id": 7654321,
|
||||
"item_title": "目标视频标题",
|
||||
"play": 168000,
|
||||
"like": 1200,
|
||||
"comment": 34,
|
||||
"share": 56,
|
||||
"url": "https://www.douyin.com/video/7654321",
|
||||
})
|
||||
|
||||
assert card == {
|
||||
"title": "目标视频标题",
|
||||
"play_count": 168000,
|
||||
"like_count": 1200,
|
||||
"comment_count": 34,
|
||||
"share_count": 56,
|
||||
"href": "https://www.douyin.com/video/7654321",
|
||||
"note_id": "7654321",
|
||||
"source": "show_items_api",
|
||||
}
|
||||
|
||||
|
||||
def test_xingtu_show_items_capture_collects_and_deduplicates_cards():
|
||||
class Response:
|
||||
url = "https://www.xingtu.cn/gw/api/author/get_author_show_items_v2"
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"latest_item_info": [
|
||||
{"item_id": "1", "title": "视频一", "play": 10},
|
||||
{"item_id": "2", "item_title": "视频二", "play": 20},
|
||||
],
|
||||
"latest_star_item_info": [
|
||||
{"item_id": "2", "item_title": "视频二", "play": 20},
|
||||
],
|
||||
}
|
||||
|
||||
class Page:
|
||||
def on(self, event, callback):
|
||||
assert event == "response"
|
||||
self.callback = callback
|
||||
|
||||
page = Page()
|
||||
cards = xingtu.setup_show_items_capture(page)
|
||||
page.callback(Response())
|
||||
|
||||
assert [card["note_id"] for card in cards] == ["1", "2"]
|
||||
|
||||
|
||||
def test_xingtu_matches_api_cards_without_dom_pagination(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
xingtu,
|
||||
"parse_videos_stable",
|
||||
lambda _page: (_ for _ in ()).throw(AssertionError("DOM should not be needed")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
xingtu,
|
||||
"is_blocked",
|
||||
lambda _page: (_ for _ in ()).throw(AssertionError("page should not be needed")),
|
||||
)
|
||||
tasks = [{
|
||||
"record_id": "r1",
|
||||
"target_title": "飞书旧标题",
|
||||
"note_url": "https://www.douyin.com/video/7654321",
|
||||
"note_id": "7654321",
|
||||
}]
|
||||
api_cards = [{
|
||||
"title": "平台上的新标题",
|
||||
"play_count": 168000,
|
||||
"note_id": "7654321",
|
||||
"source": "show_items_api",
|
||||
}]
|
||||
|
||||
found, _, page_limit_hit = xingtu.find_videos_for_tasks(
|
||||
object(), tasks, max_pages=5, api_cards=api_cards,
|
||||
)
|
||||
|
||||
assert found["r1"]["play_count"] == 168000
|
||||
assert found["r1"]["match_method"] == "content_id"
|
||||
assert page_limit_hit is False
|
||||
|
||||
|
||||
def test_xingtu_api_fuzzy_candidate_does_not_preempt_later_exact_dom(monkeypatch):
|
||||
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
|
||||
monkeypatch.setattr(
|
||||
xingtu,
|
||||
"parse_videos_stable",
|
||||
lambda _page: [{"title": "commuter backpack review", "note_id": "exact-card"}],
|
||||
)
|
||||
monkeypatch.setattr(xingtu, "go_next_page", lambda _page: False)
|
||||
monkeypatch.setattr(xingtu, "submit_video_search", lambda *_: False)
|
||||
task = {"record_id": "r1", "target_title": "commuter backpack review"}
|
||||
api_cards = [{
|
||||
"title": "commuter backpack review today",
|
||||
"note_id": "fuzzy-card",
|
||||
"source": "show_items_api",
|
||||
}]
|
||||
|
||||
found, _, _ = xingtu.find_videos_for_tasks(
|
||||
object(), [task], max_pages=2, api_cards=api_cards,
|
||||
)
|
||||
|
||||
assert found["r1"]["note_id"] == "exact-card"
|
||||
|
||||
|
||||
def test_browser_session_loss_detection_is_specific():
|
||||
closed = RuntimeError("Target page, context or browser has been closed")
|
||||
assert pgy.is_browser_session_lost(closed)
|
||||
assert xingtu.is_browser_session_lost(closed)
|
||||
assert not pgy.is_browser_session_lost(RuntimeError("ordinary parse failure"))
|
||||
|
||||
|
||||
def test_xingtu_nested_session_loss_is_reraised():
|
||||
closed = RuntimeError("Target page, context or browser has been closed")
|
||||
with pytest.raises(RuntimeError, match="has been closed"):
|
||||
xingtu.reraise_if_browser_session_lost(closed)
|
||||
xingtu.reraise_if_browser_session_lost(RuntimeError("ordinary lookup miss"))
|
||||
|
||||
|
||||
def test_xingtu_detail_and_video_search_propagate_closed_session():
|
||||
closed = RuntimeError("Target page, context or browser has been closed")
|
||||
|
||||
class Detail:
|
||||
def goto(self, *_args, **_kwargs):
|
||||
raise closed
|
||||
|
||||
class Context:
|
||||
pages = []
|
||||
|
||||
def new_page(self):
|
||||
return Detail()
|
||||
|
||||
class SearchPage:
|
||||
context = Context()
|
||||
|
||||
def evaluate(self, _script, _arg=None):
|
||||
return {"found": True, "url": "https://www.xingtu.cn/ad/creator/author-homepage/douyin-video/1"}
|
||||
|
||||
with pytest.raises(RuntimeError, match="has been closed"):
|
||||
xingtu.open_creator_detail(SearchPage(), "达人", "creator-id")
|
||||
|
||||
class Locator:
|
||||
first = None
|
||||
|
||||
def __init__(self):
|
||||
self.first = self
|
||||
|
||||
def count(self):
|
||||
return 1
|
||||
|
||||
def wait_for(self, **_kwargs):
|
||||
raise closed
|
||||
|
||||
class VideoPage:
|
||||
def locator(self, _selector):
|
||||
return Locator()
|
||||
|
||||
with pytest.raises(RuntimeError, match="has been closed"):
|
||||
xingtu.submit_video_search(VideoPage(), "标题")
|
||||
|
||||
|
||||
def test_pgy_fallback_detail_click_propagates_closed_session():
|
||||
closed = RuntimeError("Target page, context or browser has been closed")
|
||||
|
||||
class Clicker:
|
||||
first = None
|
||||
|
||||
def __init__(self):
|
||||
self.first = self
|
||||
|
||||
def click(self, **_kwargs):
|
||||
raise closed
|
||||
|
||||
class Context:
|
||||
pages = []
|
||||
|
||||
class Page:
|
||||
context = Context()
|
||||
|
||||
def evaluate(self, *_args, **_kwargs):
|
||||
return {"ok": False, "reason": "no-row"}
|
||||
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return Clicker()
|
||||
|
||||
with pytest.raises(RuntimeError, match="has been closed"):
|
||||
pgy.open_blogger_detail(Page(), "达人", None)
|
||||
|
||||
|
||||
def test_bilibili_triggered_blocked_inputs_are_visible_terminal_rows():
|
||||
summary = bili.finalize_bili_summary({
|
||||
"style": "款式A", "index": 1, "total_b_records": 1,
|
||||
"details": [{
|
||||
"record_id": "r1", "status": "blocked_input",
|
||||
"matched": False, "reason": "publish_time_missing", "ok": False,
|
||||
}],
|
||||
})
|
||||
|
||||
assert summary["blocked_input"] == 1
|
||||
assert summary["unresolved"] == 0
|
||||
assert summary["complete"] is True
|
||||
|
||||
|
||||
def test_bilibili_missing_url_does_not_trigger_collection(monkeypatch):
|
||||
style = {
|
||||
"style": "款式A",
|
||||
"name": "款式A",
|
||||
"index": 1,
|
||||
"base_token": "base",
|
||||
"table_id": "table",
|
||||
"field_map": {
|
||||
"platform": {"field_id": "platform"},
|
||||
"note_url": {"field_id": "url"},
|
||||
"creator_name": {"field_id": "creator"},
|
||||
"publish_time": {"field_id": "pub"},
|
||||
"read_count_7d": {"field_id": "s7"},
|
||||
"read_count_14d": {"field_id": "s14"},
|
||||
"read_count_21d": {"field_id": "s21"},
|
||||
"read_count_28d": {"field_id": "s28"},
|
||||
"month_end": {"field_id": "sm"},
|
||||
},
|
||||
}
|
||||
rows = [
|
||||
{
|
||||
"record_id": "no-link",
|
||||
"platform": "B站",
|
||||
"creator": "未发布达人",
|
||||
"url": "",
|
||||
"pub": "2026-07-20",
|
||||
},
|
||||
{
|
||||
"record_id": "published",
|
||||
"platform": "B站",
|
||||
"creator": "已发布达人",
|
||||
"url": "https://www.bilibili.com/video/BV1234567890",
|
||||
"pub": "2026-07-20",
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(bili, "list_all_records", lambda *_args: rows)
|
||||
monkeypatch.setattr(bili, "fetch_play_count", lambda *_args: 321)
|
||||
monkeypatch.setattr(bili, "write_record", lambda *_args, **_kwargs: True)
|
||||
|
||||
summary = bili.process_style(
|
||||
style,
|
||||
only_record_ids=None,
|
||||
dry_run=True,
|
||||
delay=0,
|
||||
session=object(),
|
||||
state={},
|
||||
force_today=bili.date(2026, 7, 23),
|
||||
first_run=False,
|
||||
)
|
||||
|
||||
assert summary["source_b_records"] == 2
|
||||
assert summary["skipped_no_url"] == 1
|
||||
assert summary["total_b_records"] == 1
|
||||
assert summary["success"] == 1
|
||||
assert summary["blocked_input"] == 0
|
||||
assert [row["record_id"] for row in summary["details"]] == ["published"]
|
||||
|
||||
|
||||
def test_bilibili_partial_retry_preserves_old_success():
|
||||
existing = {
|
||||
"style": "款式A", "index": 1, "total_b_records": 2,
|
||||
"details": [
|
||||
{"record_id": "ok", "status": "success", "ok": True},
|
||||
{"record_id": "retry", "status": "retryable_failure", "ok": False},
|
||||
],
|
||||
}
|
||||
partial = {
|
||||
"style": "款式A", "index": 1, "total_b_records": 1,
|
||||
"details": [{"record_id": "retry", "status": "success", "ok": True}],
|
||||
}
|
||||
|
||||
merged = bili.merge_bili_summary(existing, partial)
|
||||
|
||||
assert merged["total_b_records"] == 2
|
||||
assert {row["record_id"] for row in merged["details"]} == {"ok", "retry"}
|
||||
assert merged["complete"] is True
|
||||
|
||||
|
||||
def test_bilibili_top_level_error_cannot_finalize_as_complete():
|
||||
finalized = bili.finalize_bili_summary({
|
||||
"style": "款式A",
|
||||
"index": 1,
|
||||
"total_b_records": 0,
|
||||
"details": [],
|
||||
"error": "missing platform/url field_id",
|
||||
"unresolved": 1,
|
||||
"retryable_failures": 1,
|
||||
"complete": False,
|
||||
})
|
||||
|
||||
assert finalized["complete"] is False
|
||||
assert finalized["unresolved"] >= 1
|
||||
|
||||
|
||||
def test_bilibili_missing_slot_mapping_is_system_error_not_blocked_input():
|
||||
summary = bili.process_style(
|
||||
{
|
||||
"style": "款式A",
|
||||
"name": "款式A",
|
||||
"index": 1,
|
||||
"base_token": "base",
|
||||
"table_id": "table",
|
||||
"field_map": {
|
||||
"platform": {"field_id": "platform"},
|
||||
"note_url": {"field_id": "url"},
|
||||
},
|
||||
},
|
||||
only_record_ids=None,
|
||||
dry_run=True,
|
||||
delay=0,
|
||||
session=object(),
|
||||
state={},
|
||||
force_today=None,
|
||||
first_run=False,
|
||||
)
|
||||
|
||||
finalized = bili.finalize_bili_summary(summary, dry_run=True)
|
||||
assert "publish_time" in finalized["error"]
|
||||
assert "read_count_7d" in finalized["error"]
|
||||
assert finalized["complete"] is False
|
||||
|
||||
|
||||
def test_self_douyin_missing_mapping_and_write_failure_are_not_complete(monkeypatch):
|
||||
missing = self_dy.scrape_one_style(
|
||||
{"name": "款式A", "index": 1, "base_token": "base", "table_id": "table", "field_map": {}},
|
||||
login_timeout=1,
|
||||
headless=True,
|
||||
only_record_ids=None,
|
||||
dry_run=False,
|
||||
)
|
||||
assert cc.finalize_summary(missing)["complete"] is False
|
||||
|
||||
style = _style_for_platform("抖音")
|
||||
row = {
|
||||
"record_id": "r1",
|
||||
"platform": "抖音",
|
||||
"name": "自营达人",
|
||||
"title": "目标作品",
|
||||
"url": "https://www.douyin.com/video/1",
|
||||
"pub": "2026-07-20",
|
||||
}
|
||||
|
||||
class Page:
|
||||
def goto(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def wait_for_timeout(self, _ms):
|
||||
return None
|
||||
|
||||
class Session:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
def fetch(self, _url, page_action, wait):
|
||||
page_action(Page())
|
||||
|
||||
monkeypatch.setattr(self_dy, "DynamicSession", Session)
|
||||
monkeypatch.setattr(self_dy, "list_records_by_table", lambda *_: [row])
|
||||
monkeypatch.setattr(self_dy, "dismiss_popups", lambda *_: None)
|
||||
monkeypatch.setattr(self_dy, "restore_cookies", lambda *_: None)
|
||||
monkeypatch.setattr(self_dy, "maybe_wait_for_login", lambda *_: None)
|
||||
monkeypatch.setattr(self_dy, "save_state", lambda *_: None)
|
||||
monkeypatch.setattr(
|
||||
self_dy,
|
||||
"scroll_and_collect_posts",
|
||||
lambda *_args, **_kwargs: [{"title": "目标作品", "play_count": 123}],
|
||||
)
|
||||
monkeypatch.setattr(self_dy, "pick_read_field", lambda *_: ("read_count_7d", "read7", "7天曝光量"))
|
||||
monkeypatch.setattr(self_dy, "write_back", lambda *_: False)
|
||||
|
||||
failed = cc.finalize_summary(
|
||||
self_dy.scrape_one_style(
|
||||
style,
|
||||
login_timeout=1,
|
||||
headless=True,
|
||||
only_record_ids=None,
|
||||
dry_run=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert failed["results"][0]["status"] == "write_failure"
|
||||
assert failed["complete"] is False
|
||||
|
||||
|
||||
def test_run_all_rejects_stale_or_incomplete_summary(tmp_path: Path):
|
||||
path = tmp_path / "summary.json"
|
||||
path.write_text("[]", encoding="utf-8")
|
||||
started_at = path.stat().st_mtime + 1
|
||||
|
||||
assert run_all.validate_summary_payload([], {1}, path, started_at)[0] is False
|
||||
|
||||
path.write_text('[{"index": 1, "total": 2, "results": [{"record_id": "a", "status": "success"}]}]', encoding="utf-8")
|
||||
assert run_all.validate_summary_payload(
|
||||
[{"index": 1, "total": 2, "results": [{"record_id": "a", "status": "success"}]}],
|
||||
{1}, path, path.stat().st_mtime - 1,
|
||||
)[0] is False
|
||||
-103
@@ -1,103 +0,0 @@
|
||||
import unittest
|
||||
from datetime import date
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_dashboard_analytics import build_dashboard_facts
|
||||
|
||||
|
||||
class DailyDashboardAnalyticsTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tracking = [
|
||||
{"style_name": "大流量", "visitors": 1000, "cart_users": 100, "sales": 20, "refund_orders": 2, "light": "🟡"},
|
||||
{"style_name": "高转化", "visitors": 100, "cart_users": 30, "sales": 20, "refund_orders": 8, "light": "🔴"},
|
||||
{"style_name": "极小样本", "visitors": 10, "cart_users": 5, "sales": 10, "refund_orders": 0, "light": "🔴"},
|
||||
{"style_name": "缺数据", "visitors": None, "cart_users": None, "sales": None, "refund_orders": None, "light": "无判断"},
|
||||
]
|
||||
self.platform = [
|
||||
{"style_name": "大流量", "platform": "天猫", "visitors": 900, "cart_users": 90, "sales": 18, "refund_orders": 2},
|
||||
{"style_name": "高转化", "platform": "天猫", "visitors": 100, "cart_users": 30, "sales": 20, "refund_orders": 8},
|
||||
{"style_name": "极小样本", "platform": "天猫", "visitors": 10, "cart_users": 5, "sales": 10, "refund_orders": 0},
|
||||
]
|
||||
self.personas = [
|
||||
{
|
||||
"style_name": "大流量", "platform": "天猫", "data_date": date(2026, 7, 15),
|
||||
"gender_top": "女性 60%", "age_top": "26-35岁 45%",
|
||||
"city_top": "一线城市 35%", "buying_power_top": "高级白领 40%",
|
||||
}
|
||||
]
|
||||
|
||||
def test_all_styles_receive_same_metrics_and_diagnosis(self):
|
||||
facts = build_dashboard_facts(
|
||||
"2026-07-15", self.tracking, self.platform, self.personas,
|
||||
{"大流量": {"creator_count": 3, "directions": ["通勤"]}},
|
||||
)
|
||||
self.assertEqual(len(facts["styles"]), 4)
|
||||
high = next(row for row in facts["styles"] if row["style_name"] == "高转化")
|
||||
self.assertEqual(high["conversion_rate"], 0.2)
|
||||
self.assertEqual(high["cart_rate"], 0.3)
|
||||
self.assertEqual(high["refund_rate"], 0.4)
|
||||
missing = next(row for row in facts["styles"] if row["style_name"] == "缺数据")
|
||||
self.assertEqual(missing["diagnosis"], "无数据")
|
||||
|
||||
def test_top_conversion_excludes_tiny_visitor_base(self):
|
||||
facts = build_dashboard_facts("2026-07-15", self.tracking, self.platform, self.personas, {})
|
||||
self.assertEqual(facts["rankings"]["conversion"][0]["style_name"], "高转化")
|
||||
self.assertEqual(facts["rankings"]["visitors"][0]["style_name"], "大流量")
|
||||
self.assertEqual(facts["rankings"]["refund_rate"][0]["style_name"], "高转化")
|
||||
|
||||
def test_platform_winners_keep_missing_persona_explicit(self):
|
||||
facts = build_dashboard_facts("2026-07-15", self.tracking, self.platform, self.personas, {})
|
||||
card = facts["platform_personas"][0]
|
||||
self.assertEqual(card["platform"], "天猫")
|
||||
self.assertEqual(card["top_conversion"]["style_name"], "高转化")
|
||||
self.assertEqual(card["top_conversion"]["persona_text"], "无数据")
|
||||
self.assertEqual(card["top_visitors"]["style_name"], "大流量")
|
||||
self.assertIn("推测", card["top_visitors"]["interest_hypothesis"])
|
||||
self.assertIn("提转化", card["top_visitors"]["optimization"])
|
||||
|
||||
def test_daily_new_notes_overview_replaces_view_delta_and_creator_ranking(self):
|
||||
notes = [{"note_id": 7, "style_name": "大流量", "creator": "达人A", "platform": "小红书", "view_count": 500}]
|
||||
facts = build_dashboard_facts(
|
||||
"2026-07-15", self.tracking, self.platform, self.personas, {}, daily_notes=notes
|
||||
)
|
||||
self.assertEqual(facts["daily_new_notes"], notes)
|
||||
self.assertNotIn("creator_connections", facts["rankings"])
|
||||
self.assertNotIn("daily_views", facts["rankings"])
|
||||
|
||||
def test_daily_view_ranking_and_platform_cards_receive_stored_images(self):
|
||||
images = {
|
||||
("高转化", "天猫"): {"local_image_path": "same.jpg", "image_platform": "天猫"},
|
||||
("大流量", "天猫"): {"local_image_path": "visitor.jpg", "image_platform": "天猫"},
|
||||
}
|
||||
facts = build_dashboard_facts(
|
||||
"2026-07-15", self.tracking, self.platform, self.personas, {},
|
||||
style_images=images,
|
||||
)
|
||||
card = facts["platform_personas"][0]
|
||||
self.assertEqual(card["top_conversion"]["local_image_path"], "same.jpg")
|
||||
self.assertEqual(card["top_visitors"]["local_image_path"], "visitor.jpg")
|
||||
|
||||
def test_matrix_groups_by_style_category_without_total_exposure(self):
|
||||
signals = {
|
||||
"高转化": {"persona": True, "note_count": 3, "recent_note_exposure": 43000, "comment_count": 80,
|
||||
"review_count": 10, "review_negative_rate": 0.3, "creative_ctr": 0.08},
|
||||
}
|
||||
facts = build_dashboard_facts(
|
||||
"2026-07-15", self.tracking, self.platform, self.personas, {},
|
||||
style_categories={
|
||||
"大流量": "都市机能", "高转化": "智性通勤",
|
||||
"极小样本": "智性通勤", "缺数据": "都市机能",
|
||||
},
|
||||
style_signals=signals,
|
||||
)
|
||||
self.assertEqual([group["style_category"] for group in facts["style_groups"]], ["都市机能", "智性通勤"])
|
||||
self.assertEqual([row["style_name"] for row in facts["style_groups"][0]["styles"]], ["大流量", "缺数据"])
|
||||
high = next(row for row in facts["styles"] if row["style_name"] == "高转化")
|
||||
self.assertNotIn("total_note_exposure", high)
|
||||
self.assertEqual(high["recent_note_exposure"], 43000)
|
||||
self.assertNotIn("total_note_exposure", facts["definitions"])
|
||||
self.assertIn("评价风险", high["diagnosis"])
|
||||
self.assertIn("画像", high["evidence_summary"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,429 +0,0 @@
|
||||
import unittest
|
||||
from datetime import date
|
||||
from unittest.mock import patch
|
||||
|
||||
import daily_marketing_report as daily
|
||||
|
||||
|
||||
class DailyMarketingReportTests(unittest.TestCase):
|
||||
def test_sales_forecast_uses_seven_day_total_and_recent_three_day_velocity(self):
|
||||
forecast = daily.estimate_sales_forecast([10, 12, 14, 16, 18, 20, 22])
|
||||
|
||||
self.assertEqual(forecast["sales_7d_total"], 112)
|
||||
self.assertEqual(forecast["sales_forecast_7d"], 140)
|
||||
self.assertAlmostEqual(forecast["sales_forecast_change"], 7 / 13, places=4)
|
||||
|
||||
def test_sales_forecast_requires_complete_seven_day_history(self):
|
||||
self.assertEqual(
|
||||
daily.estimate_sales_forecast([10, 12, 14]),
|
||||
{
|
||||
"sales_7d_total": None,
|
||||
"sales_forecast_7d": None,
|
||||
"sales_forecast_change": None,
|
||||
},
|
||||
)
|
||||
|
||||
def test_default_recipients_include_all_four_daily_report_owners(self):
|
||||
self.assertEqual(
|
||||
daily.DEFAULT_RECIPIENT_OPEN_IDS,
|
||||
(
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
"ou_fa8d81a16527ad06352dbecc575285b8",
|
||||
"ou_2eda5eec112109ae6d19a1f6813eadcb",
|
||||
"ou_89bcff110ccbb09a23548dc0fb3d880c",
|
||||
),
|
||||
)
|
||||
|
||||
def test_hermes_transport_error_is_not_accepted_as_report(self):
|
||||
with self.assertRaisesRegex(RuntimeError, "Hermes 分析失败"):
|
||||
daily.require_valid_hermes_report("API call failed after 3 retries: Connection error.")
|
||||
self.assertEqual(daily.require_valid_hermes_report("整体经营分析\n销量保持稳定"), "整体经营分析\n销量保持稳定")
|
||||
|
||||
def test_light_uses_highest_absolute_visitor_or_cart_change(self):
|
||||
self.assertEqual(daily.classify_light(0.05, -0.08), "🟢")
|
||||
self.assertEqual(daily.classify_light(0.12, 0.02), "🟡")
|
||||
self.assertEqual(daily.classify_light(-0.31, 0.01), "🔴")
|
||||
self.assertEqual(daily.classify_light(None, None), "无判断")
|
||||
|
||||
def test_comment_signal_requires_minimum_sample(self):
|
||||
comments = ["光影行星宙斯怎么买"] * 49
|
||||
self.assertIsNone(daily.calculate_comment_signal("宙斯", comments))
|
||||
|
||||
comments.append("好看")
|
||||
signal = daily.calculate_comment_signal("宙斯", comments)
|
||||
self.assertEqual(signal["sample_size"], 50)
|
||||
self.assertEqual(signal["brand_mention_rate"], 0.98)
|
||||
self.assertEqual(signal["purchase_intent_rate"], 0.98)
|
||||
|
||||
def test_prompt_uses_only_the_fresh_user_instruction_and_database_facts(self):
|
||||
prompt = daily.build_daily_prompt(
|
||||
report_date="2026-07-15",
|
||||
today_notes=[],
|
||||
tracking_rows=[],
|
||||
next_day_rows=[],
|
||||
comment_rows=[],
|
||||
)
|
||||
self.assertIn("以下为数据库查询结果", prompt)
|
||||
self.assertIn('"报告日期": "2026-07-15"', prompt)
|
||||
self.assertIn("增长型SKU", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("流量机会型SKU", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("潜力型SKU", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("风险型SKU", daily.SYSTEM_PROMPT)
|
||||
self.assertNotIn("A类:爆款增长SKU", daily.SYSTEM_PROMPT)
|
||||
|
||||
def test_prompt_uses_fashion_bag_data_analyst_role(self):
|
||||
self.assertIn("专业的时尚包袋品牌电商运营数据分析师", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("销售、电商运营及达人营销数据", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("发现增长机会和风险", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("SKU营销运营日报", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("展示关键数据", daily.SYSTEM_PROMPT)
|
||||
|
||||
def test_fresh_prompt_contains_the_complete_requested_analysis_structure(self):
|
||||
prompt = daily.SYSTEM_PROMPT
|
||||
for phrase in (
|
||||
"今日整体经营判断",
|
||||
"SKU经营表现分析",
|
||||
"达人营销效果分析",
|
||||
"产品节奏判断",
|
||||
"蓄水期",
|
||||
"放量期",
|
||||
"稳定期",
|
||||
"衰退期",
|
||||
"增长SKU TOP3",
|
||||
"风险SKU TOP3",
|
||||
"比较昨日、七日基准",
|
||||
"用图表展示",
|
||||
"曝光量",
|
||||
"建议动作",
|
||||
"对销量影响",
|
||||
"所有建议必须具体到SKU",
|
||||
):
|
||||
self.assertIn(phrase, prompt)
|
||||
for retired in ("A类:爆款增长SKU", "B类:潜力增长SKU", "D类:需要优化SKU"):
|
||||
self.assertNotIn(retired, prompt)
|
||||
|
||||
def test_database_payload_keeps_all_available_sources(self):
|
||||
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], {"reviews": [{"sample_size": 3}]})
|
||||
for phrase in ("所有SKU电商运营数据", "达人营销内容数据", "评论与补充经营数据", "reviews"):
|
||||
self.assertIn(phrase, prompt)
|
||||
|
||||
def test_note_label_ignores_placeholder_creator_name(self):
|
||||
note = {"id": 7, "creator_name": "/", "title": "瑞白通勤包实测"}
|
||||
self.assertEqual(daily.note_label(note), "瑞白通勤包实测")
|
||||
|
||||
def test_cooperation_metrics_hide_unverifiable_engagement(self):
|
||||
invalid = daily._sanitize_cooperation_row({
|
||||
"publish_time": None,
|
||||
"exposure_count": 38000,
|
||||
"engagement_count_num": 21430000,
|
||||
})
|
||||
self.assertIsNone(invalid["engagement_count_num"])
|
||||
self.assertEqual(invalid["publish_status"], "发布时间无数据")
|
||||
self.assertIn("口径异常", invalid["engagement_data_status"])
|
||||
|
||||
valid = daily._sanitize_cooperation_row({
|
||||
"publish_time": "2026-07-14",
|
||||
"exposure_count": 38000,
|
||||
"engagement_count_num": 2143,
|
||||
})
|
||||
self.assertEqual(valid["engagement_count_num"], 2143)
|
||||
self.assertEqual(valid["publish_status"], "已记录发布时间")
|
||||
|
||||
def test_load_report_facts_tracks_all_styles_for_report_date(self):
|
||||
metrics = {
|
||||
"宙斯": {"light": "🟢"},
|
||||
}
|
||||
with patch.object(daily, "_note_rows", return_value=[]), \
|
||||
patch.object(daily, "_all_style_names", return_value=["宙斯", "蓝鹊"]) as all_styles, \
|
||||
patch.object(daily, "_style_metrics", return_value=metrics) as style_metrics:
|
||||
with patch.object(daily, "_load_enrichment", return_value={"platform_metrics": []}), \
|
||||
patch.object(daily, "_platform_metrics", return_value=[]), \
|
||||
patch.object(daily, "_persona_context", return_value=[]), \
|
||||
patch.object(daily, "load_creator_connections", return_value={}), \
|
||||
patch.object(daily, "load_style_images", return_value={}), \
|
||||
patch.object(daily, "load_style_categories", return_value={}), \
|
||||
patch.object(daily, "_cooperation_context", return_value=[]), \
|
||||
patch.object(daily, "_style_comment_context", return_value=[]), \
|
||||
patch.object(daily, "_review_context", return_value=[]), \
|
||||
patch.object(daily, "_creative_context", return_value=[]), \
|
||||
patch.object(daily, "build_dashboard_facts", return_value={"styles": ["宙斯", "蓝鹊"]}):
|
||||
_, tracking, _, _, enrichment = daily.load_report_facts(date(2026, 7, 15))
|
||||
|
||||
all_styles.assert_called_once_with(date(2026, 7, 15))
|
||||
self.assertEqual([row["style_name"] for row in tracking], ["宙斯", "蓝鹊"])
|
||||
self.assertEqual(tracking[1]["light"], "无判断")
|
||||
self.assertEqual(style_metrics.call_args_list[0].args[1], ["宙斯", "蓝鹊"])
|
||||
self.assertEqual(enrichment["platform_metrics"], [])
|
||||
self.assertEqual(enrichment["candidate_styles"], [])
|
||||
self.assertEqual(enrichment["dashboard"]["styles"], ["宙斯", "蓝鹊"])
|
||||
|
||||
def test_report_date_uses_yesterday_notes_for_daily_new_notes(self):
|
||||
notes = [
|
||||
{"id": 1, "publish_date": "2026-07-15", "style_name": "宙斯", "creator_name": "达人甲", "platform": "小红书", "publish_time": "2026-07-15 10:00", "title": "今天", "view_count": 10, "engagement_rate": None},
|
||||
{"id": 2, "publish_date": "2026-07-14", "style_name": "宙斯", "creator_name": "达人乙", "platform": "抖音", "publish_time": "2026-07-14 11:00", "title": "昨天", "view_count": 20, "engagement_rate": None},
|
||||
]
|
||||
with patch.object(daily, "_note_rows", return_value=notes), \
|
||||
patch.object(daily, "_all_style_names", return_value=["宙斯"]), \
|
||||
patch.object(daily, "_style_metrics", return_value={}), \
|
||||
patch.object(daily, "_load_enrichment", return_value={}), \
|
||||
patch.object(daily, "_platform_metrics", return_value=[]), \
|
||||
patch.object(daily, "_persona_context", return_value=[]), \
|
||||
patch.object(daily, "load_creator_connections", return_value={}), \
|
||||
patch.object(daily, "load_style_images", return_value={}), \
|
||||
patch.object(daily, "load_style_categories", return_value={}), \
|
||||
patch.object(daily, "_cooperation_context", return_value=[]), \
|
||||
patch.object(daily, "_style_comment_context", return_value=[]), \
|
||||
patch.object(daily, "_review_context", return_value=[]), \
|
||||
patch.object(daily, "_creative_context", return_value=[]), \
|
||||
patch.object(daily, "_comment_rows", return_value=[]), \
|
||||
patch.object(daily, "build_dashboard_facts", return_value={}) as dashboard:
|
||||
daily_notes, _, _, _, _ = daily.load_report_facts(date(2026, 7, 15))
|
||||
|
||||
self.assertEqual([row["note_id"] for row in daily_notes], [2])
|
||||
self.assertEqual([row["note_id"] for row in dashboard.call_args.kwargs["daily_notes"]], [2])
|
||||
|
||||
def test_prompt_requires_all_styles_without_top_ten_truncation(self):
|
||||
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [])
|
||||
self.assertIn("所有SKU", prompt)
|
||||
self.assertNotIn("只保留异常程度最高的10个", prompt)
|
||||
self.assertIn("SKU经营表现分析", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("加购率", daily.SYSTEM_PROMPT)
|
||||
|
||||
def test_prompt_uses_the_new_daily_output_contract(self):
|
||||
self.assertIn("电商运营数据", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("达人营销数据", daily.SYSTEM_PROMPT)
|
||||
self.assertNotIn("TOP20", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("一页A4", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("↑↓展示趋势", daily.SYSTEM_PROMPT)
|
||||
self.assertNotIn("→稳定", daily.SYSTEM_PROMPT)
|
||||
self.assertIn("电商销量表现 + 访客数 + 转化率 + 达人笔记曝光 + 用户反馈", daily.SYSTEM_PROMPT)
|
||||
|
||||
def test_prompt_payload_keeps_yesterday_and_seven_day_comparisons(self):
|
||||
rows = [{
|
||||
"style_name": "蓝鹊",
|
||||
"sales": 10,
|
||||
"sales_yesterday": 8,
|
||||
"avg_sales_7d": 7.5,
|
||||
"sales_change_yesterday": 0.25,
|
||||
"sales_change": 0.3333,
|
||||
}]
|
||||
prompt = daily.build_daily_prompt("2026-07-16", [], rows, [], [])
|
||||
self.assertIn('"sales_yesterday": 8', prompt)
|
||||
self.assertIn('"avg_sales_7d": 7.5', prompt)
|
||||
self.assertIn('"sales_change_yesterday": 0.25', prompt)
|
||||
|
||||
def test_style_metrics_calculates_yesterday_and_seven_day_changes(self):
|
||||
class FakeCursor:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def execute(self, query, params):
|
||||
self.query = query
|
||||
self.params = params
|
||||
|
||||
def fetchall(self):
|
||||
return [(
|
||||
"蓝鹊",
|
||||
120, 24, 10, 2,
|
||||
100, 20, 8, 1,
|
||||
90, 18, 7.5, 1.2,
|
||||
[4, 5, 6, 7, 8, 9, 10],
|
||||
)]
|
||||
|
||||
class FakeConnection:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def cursor(self):
|
||||
return FakeCursor()
|
||||
|
||||
with patch.object(daily, "get_conn", return_value=FakeConnection()):
|
||||
row = daily._style_metrics(date(2026, 7, 16), ["蓝鹊"])["蓝鹊"]
|
||||
|
||||
self.assertEqual(row["sales_yesterday"], 8)
|
||||
self.assertEqual(row["sales_change_yesterday"], 0.25)
|
||||
self.assertEqual(row["sales_change"], 0.3333)
|
||||
self.assertEqual(row["visitor_change_yesterday"], 0.2)
|
||||
self.assertEqual(row["conversion_rate"], round(10 / 120, 4))
|
||||
self.assertEqual(row["conversion_rate_yesterday"], round(8 / 100, 4))
|
||||
self.assertEqual(row["sales_7d_total"], 49)
|
||||
self.assertEqual(row["sales_forecast_7d"], 63)
|
||||
|
||||
def test_prompt_does_not_reuse_retired_dashboard_sections(self):
|
||||
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], {"dashboard": {
|
||||
"rankings": {"sales": []},
|
||||
"platform_personas": [{"platform": "天猫"}],
|
||||
"daily_new_notes": [],
|
||||
"styles": [{"style_name": "蓝鹊", "total_note_exposure": 1000, "evidence_summary": "经营、画像、营销笔记"}],
|
||||
"definitions": {"conversion_rate": "销量/访客"},
|
||||
}})
|
||||
self.assertNotIn("按风格分组的综合经营矩阵", prompt)
|
||||
self.assertNotIn("平台高转化与高访客画像", prompt)
|
||||
self.assertNotIn("全款式经营看板摘要", prompt)
|
||||
|
||||
def test_report_normalization_and_all_style_validation(self):
|
||||
report = daily.normalize_report(
|
||||
"健康度:偏弱。36 个款式中\n蓝鹊同比增长79%\n宙斯环比下降16%",
|
||||
35,
|
||||
)
|
||||
self.assertNotIn("同比", report)
|
||||
self.assertIn("环比下降16%", report)
|
||||
self.assertIn("35个款式中", report)
|
||||
self.assertEqual(daily.normalize_report("销量 0 / 退款 0(无数据)"), "销量 0 / 退款 0(当日均为0)")
|
||||
cleaned = daily.normalize_report("投放尚未发布,带动效应快速衰减;待发布后核对")
|
||||
self.assertNotIn("未发布", cleaned)
|
||||
self.assertNotIn("带动效应", cleaned)
|
||||
self.assertIn("发布时间无数据", cleaned)
|
||||
self.assertNotIn("驱动访客", daily.normalize_report("该内容未驱动访客回升"))
|
||||
daily.validate_all_styles(report, [{"style_name": "蓝鹊"}, {"style_name": "宙斯"}])
|
||||
with self.assertRaisesRegex(RuntimeError, "缺少款式"):
|
||||
daily.validate_all_styles(report, [{"style_name": "白鹭"}])
|
||||
|
||||
def test_report_normalization_removes_database_and_code_style_language(self):
|
||||
raw = """数据已收到,我先用脚本做一遍聚合。
|
||||
# 品牌SKU电商运营日报
|
||||
|
||||
• 最佳渠道:无法判定。今日 `昨日新发布内容` 为空,cooperations 仅1条,publish_time=null、engagement_data_status=无数据。
|
||||
|
||||
5. 数据治理与内容补位:(a)对齐 publish_time;(b)修复互动数据;(c)补齐数据。
|
||||
|
||||
报告字数提示:如需压缩请告知。
|
||||
完整数据索引:如需导出 CSV 请告诉我。
|
||||
"""
|
||||
report = daily.normalize_report(raw)
|
||||
|
||||
self.assertTrue(report.startswith("# 品牌SKU电商运营日报"))
|
||||
for forbidden in ("我先用脚本", "cooperations", "publish_time", "engagement_data_status", "null", "`", "(a)", "报告字数提示", "请告诉我"):
|
||||
self.assertNotIn(forbidden, report)
|
||||
self.assertIn("达人合作记录", report)
|
||||
self.assertIn("发布时间无数据", report)
|
||||
self.assertIn("互动数据无数据", report)
|
||||
self.assertIn("(1)", report)
|
||||
|
||||
def test_system_prompt_forbids_database_fields_and_conversational_tail(self):
|
||||
for phrase in (
|
||||
"禁止输出数据库表名、字段名、JSON键名",
|
||||
"禁止出现反引号、null",
|
||||
"禁止描述生成过程",
|
||||
"禁止在结尾追问",
|
||||
):
|
||||
self.assertIn(phrase, daily.SYSTEM_PROMPT)
|
||||
|
||||
def test_status_groups_are_replaced_with_complete_deterministic_lists(self):
|
||||
report = "二、种草追踪池\n\nB. 全量状态(固定4行)\n🔴(1):蓝鹊\n\n三、昨日笔记次日追踪"
|
||||
rows = [
|
||||
{"style_name": "蓝鹊", "light": "🔴"},
|
||||
{"style_name": "宙斯", "light": "🟡"},
|
||||
{"style_name": "白鹭", "light": "🟢"},
|
||||
{"style_name": "云卷2", "light": "无判断"},
|
||||
]
|
||||
result = daily.replace_status_groups(report, rows)
|
||||
self.assertIn("B. 全量状态(4款)", result)
|
||||
self.assertIn("🔴(1):蓝鹊", result)
|
||||
self.assertIn("🟡(1):宙斯", result)
|
||||
self.assertIn("🟢(1):白鹭", result)
|
||||
self.assertIn("无判断(1):云卷2", result)
|
||||
self.assertIn("三、昨日笔记次日追踪", result)
|
||||
|
||||
def test_status_groups_are_inserted_when_model_omits_marker(self):
|
||||
report = "一、核心经营结论\n正常\n\n三、达人投放与电商同期关联\n无数据"
|
||||
rows = [{"style_name": "蓝鹊", "light": "🔴"}, {"style_name": "白鹭", "light": "🟢"}]
|
||||
result = daily.replace_status_groups(report, rows, "C. 补充经营信号\n- 店铺大盘:无数据")
|
||||
self.assertIn("B. 全量状态(2款)", result)
|
||||
self.assertIn("🔴(1):蓝鹊", result)
|
||||
self.assertIn("🟢(1):白鹭", result)
|
||||
self.assertLess(result.index("B. 全量状态"), result.index("三、达人投放"))
|
||||
|
||||
def test_prompt_passes_available_enrichment_without_restoring_old_sections(self):
|
||||
enrichment = {
|
||||
"platform_metrics": [{"style_name": "蓝鹊", "platform": "天猫", "sales": 14}],
|
||||
"cooperations": [{"style_name": "蓝鹊", "content_direction": "通勤"}],
|
||||
"personas": [{"style_name": "蓝鹊", "gender_top": "女性用户 60%"}],
|
||||
"creatives": [{"style_name": "蓝鹊", "ctr": 4.2}],
|
||||
"reviews": [{"style_name": "蓝鹊", "sample_size": 6}],
|
||||
"shop_overview": [{"platform": "天猫", "week_end": "2026-07-12"}],
|
||||
}
|
||||
prompt = daily.build_daily_prompt("2026-07-15", [], [], [], [], enrichment)
|
||||
for source_key in ("platform_metrics", "cooperations", "personas", "creatives", "reviews", "shop_overview"):
|
||||
self.assertIn(source_key, prompt)
|
||||
self.assertIn("评论与补充经营数据", prompt)
|
||||
self.assertNotIn("补充经营信号", prompt)
|
||||
self.assertNotIn("全量状态", prompt)
|
||||
|
||||
def test_enrichment_section_validation_requires_available_categories(self):
|
||||
enrichment = {"personas": [{}], "creatives": [{}], "reviews": [{}], "shop_overview": [{}]}
|
||||
complete = "条件信号:人物画像;主图;商品评价;店铺大盘"
|
||||
daily.validate_enrichment_sections(complete, enrichment)
|
||||
with self.assertRaisesRegex(RuntimeError, "主图"):
|
||||
daily.validate_enrichment_sections("人物画像 商品评价 店铺大盘", enrichment)
|
||||
|
||||
def test_build_condition_signals_covers_all_available_sources(self):
|
||||
enrichment = {
|
||||
"candidate_styles": ["蓝鹊"],
|
||||
"personas": [{"style_name": "蓝鹊", "platform": "天猫", "data_date": "2026-07-15", "gender_top": "女性 60%"}],
|
||||
"creatives": [{"style_name": "蓝鹊", "platform": "天猫", "data_date": "2026-07-12", "impressions": 1000, "clicks": 50, "ctr": 5.0}],
|
||||
"reviews": [{"style_name": "蓝鹊", "sample_size": 6, "negative_count": 1, "negative_rate": 0.1667, "window_start": "2026-07-09", "window_end": "2026-07-15"}],
|
||||
"shop_overview": [{"platform": "天猫", "week_end": "2026-07-12", "visitors": 10000, "deal_amount": 20000}],
|
||||
}
|
||||
text = daily.build_condition_signals(enrichment)
|
||||
for phrase in ("C. 补充经营信号", "人物画像", "主图", "商品评价", "店铺大盘", "2026-07-12"):
|
||||
self.assertIn(phrase, text)
|
||||
|
||||
def test_persona_top_value_ignores_invalid_percentages(self):
|
||||
payload = {"buying_power": [{"name": "异常", "value": 133}, {"name": "L4", "value": 42}]}
|
||||
self.assertEqual(daily._top_persona_value(payload, "buying_power"), "L4 42.0%")
|
||||
|
||||
def test_enriched_report_rejects_missing_platform_claim_and_invented_new_product(self):
|
||||
daily.validate_enriched_report("平台拆分完整,均为同期合作")
|
||||
with self.assertRaisesRegex(RuntimeError, "平台拆分"):
|
||||
daily.validate_enriched_report("无平台拆分数据")
|
||||
with self.assertRaisesRegex(RuntimeError, "新品"):
|
||||
daily.validate_enriched_report("同期出现多个新品合作")
|
||||
|
||||
def test_platform_persona_section_uses_database_winners_not_model_text(self):
|
||||
report = ("### 三、平台人群与营销机会\n天猫高转化款为错误款式\n\n"
|
||||
"### 四、明日业务动作\n复查数据\n\n"
|
||||
"三、平台人群与营销机会\n重复错误款式")
|
||||
enrichment = {"dashboard": {
|
||||
"daily_views_status": "无数据:未保存每日增量快照",
|
||||
"platform_personas": [{
|
||||
"platform": "天猫",
|
||||
"top_conversion": {
|
||||
"style_name": "布谷", "conversion_rate": 0.0259, "visitors": 617,
|
||||
"persona_text": "无数据", "interest_hypothesis": "兴趣推测:无数据",
|
||||
"optimization": "先补采人群画像",
|
||||
},
|
||||
"top_visitors": {
|
||||
"style_name": "星云2", "conversion_rate": 0.0114, "visitors": 3504,
|
||||
"persona_text": "男性用户 62.7%", "interest_hypothesis": "兴趣推测:数码3C",
|
||||
"optimization": "优化详情页",
|
||||
},
|
||||
}],
|
||||
}}
|
||||
result = daily.replace_platform_persona_section(report, enrichment)
|
||||
self.assertIn("转化最高为布谷(转化率2.6%", result)
|
||||
self.assertIn("访客最高为星云2(访客3,504", result)
|
||||
self.assertIn("兴趣推测:数码3C", result)
|
||||
self.assertNotIn("错误款式", result)
|
||||
self.assertNotIn("重复错误款式", result)
|
||||
self.assertIn("四、明日业务动作", result)
|
||||
self.assertEqual(result.count("三、平台人群与营销机会"), 1)
|
||||
|
||||
def test_platform_persona_section_supports_new_daily_report_boundary(self):
|
||||
report = ("### 三、平台人群与营销机会\n模型内容\n\n"
|
||||
"### 四、今日重点SKU分析\n重点款内容\n")
|
||||
result = daily.replace_platform_persona_section(report, {"dashboard": {}})
|
||||
self.assertIn("三、平台人群与营销机会", result)
|
||||
self.assertIn("四、今日重点SKU分析", result)
|
||||
self.assertNotIn("模型内容", result)
|
||||
self.assertEqual(result.count("三、平台人群与营销机会"), 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class DailyMarketingReportScheduleTests(unittest.TestCase):
|
||||
def test_batch_runs_latest_daily_report_and_propagates_exit_code(self):
|
||||
path = Path(__file__).resolve().parents[1] / "data" / "tools" / "daily_marketing_report.bat"
|
||||
content = path.read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn("daily_marketing_report.py --send", content)
|
||||
self.assertNotIn("--date", content)
|
||||
self.assertIn("daily_marketing_report_%TS%.log", content)
|
||||
self.assertIn("LARK_CLI_NO_PROXY=1", content)
|
||||
self.assertIn("exit /b %RC%", content)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,189 +0,0 @@
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_report_card import (
|
||||
build_analysis_card,
|
||||
build_dashboard_card,
|
||||
build_daily_report_card,
|
||||
prepare_feishu_image,
|
||||
send_card_to_recipients,
|
||||
send_daily_report_cards,
|
||||
upload_dashboard_image,
|
||||
)
|
||||
|
||||
|
||||
class DailyReportCardTests(unittest.TestCase):
|
||||
def test_dashboard_and_analysis_are_separate_cards_with_full_text(self):
|
||||
report = """
|
||||
|
||||
第一部分 整体经营分析
|
||||
总销量下降15%。
|
||||
整体趋势判断:下滑状态。
|
||||
|
||||
第二部分 SKU分析排序
|
||||
1. 蓝鹊:销量第一。
|
||||
2. 星云2:转化领先。
|
||||
3. 宙斯:访客增长。
|
||||
4. 南斯:退款偏高。
|
||||
5. 凌云3:需要优化。
|
||||
6. 波塞冬:继续观察。
|
||||
|
||||
第三部分 关键预警与建议
|
||||
退款订单比上升,48小时内排查。
|
||||
"""
|
||||
rows = [{"style_name": "蓝鹊", "light": "🔴"}]
|
||||
|
||||
dashboard = build_dashboard_card("2026-07-17", rows, "img_v3_test")
|
||||
analysis = build_analysis_card("2026-07-17", report)
|
||||
|
||||
dashboard_text = json.dumps(dashboard, ensure_ascii=False)
|
||||
analysis_text = json.dumps(analysis, ensure_ascii=False)
|
||||
self.assertIn("img_v3_test", dashboard_text)
|
||||
self.assertNotIn("总销量下降15%", dashboard_text)
|
||||
self.assertNotIn("img_v3_test", analysis_text)
|
||||
self.assertNotIn("![全域种草数据看板]", analysis_text)
|
||||
self.assertIn("波塞冬:继续观察", analysis_text)
|
||||
self.assertIn("退款订单比上升", analysis_text)
|
||||
|
||||
def test_build_card_uses_card_2_schema_and_dashboard(self):
|
||||
report = """一、核心经营结论
|
||||
1. 波塞冬edge访客明显下滑。
|
||||
2. 蓝鹊销量同期上行。
|
||||
|
||||
二、产品经营分析
|
||||
略
|
||||
|
||||
四、明日业务动作
|
||||
1. 复查波塞冬edge天猫流量。
|
||||
2. 跟进蓝鹊投放承接。
|
||||
"""
|
||||
rows = [
|
||||
{"style_name": "波塞冬edge", "light": "🔴"},
|
||||
{"style_name": "宙斯", "light": "🟡"},
|
||||
{"style_name": "白鹭", "light": "🟢"},
|
||||
]
|
||||
card = build_daily_report_card("2026-07-15", report, rows, "img_v3_test")
|
||||
|
||||
self.assertEqual(card["schema"], "2.0")
|
||||
self.assertEqual(card["config"]["width_mode"], "fill")
|
||||
self.assertIn("Hermes", card["header"]["subtitle"]["content"])
|
||||
elements = card["body"]["elements"]
|
||||
self.assertEqual([item["tag"] for item in elements], ["column_set", "img", "markdown", "markdown"])
|
||||
self.assertEqual(elements[1]["img_key"], "img_v3_test")
|
||||
metrics_text = json.dumps(elements[0], ensure_ascii=False)
|
||||
self.assertIn("红灯款式", metrics_text)
|
||||
self.assertIn("1", metrics_text)
|
||||
self.assertNotIn("二、产品经营分析", json.dumps(card, ensure_ascii=False))
|
||||
|
||||
def test_prepare_feishu_image_resizes_to_supported_width(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
source = Path(tmp) / "source.png"
|
||||
target = Path(tmp) / "target.png"
|
||||
Image.new("RGB", (1600, 1000), "white").save(source)
|
||||
prepare_feishu_image(source, target)
|
||||
with Image.open(target) as image:
|
||||
self.assertEqual(image.width, 1500)
|
||||
self.assertLessEqual(image.height / image.width, 16 / 9)
|
||||
|
||||
def test_build_card_reads_new_management_report_headings(self):
|
||||
report = """## 1. 今日经营概览
|
||||
销售:↑12%,蓝鹊贡献主要增量。
|
||||
整体判断:增长状态。
|
||||
|
||||
## 2. SKU健康排名 TOP20
|
||||
略
|
||||
|
||||
## 6. 今日运营建议
|
||||
1. 商品动作:复核蓝鹊库存。
|
||||
2. 电商动作:优化宙斯详情页。
|
||||
"""
|
||||
card = build_daily_report_card("2026-07-17", report, [], "img_v3_test")
|
||||
card_text = json.dumps(card, ensure_ascii=False)
|
||||
self.assertIn("蓝鹊贡献主要增量", card_text)
|
||||
self.assertIn("复核蓝鹊库存", card_text)
|
||||
|
||||
def test_build_card_reads_fresh_partial_prompt_report_headings(self):
|
||||
report = """第一部分 整体经营分析
|
||||
总销量下降15%。
|
||||
整体趋势判断:下滑状态。
|
||||
|
||||
第二部分 SKU分析排序
|
||||
略
|
||||
|
||||
第三部分 关键预警与建议
|
||||
退款订单比上升,48小时内排查。
|
||||
追加增长型SKU预算。
|
||||
"""
|
||||
card = build_daily_report_card("2026-07-17", report, [], "img_v3_test")
|
||||
card_text = json.dumps(card, ensure_ascii=False)
|
||||
self.assertIn("总销量下降15%", card_text)
|
||||
self.assertIn("48小时内排查", card_text)
|
||||
|
||||
@patch("data.tools.daily_report_card.subprocess.run")
|
||||
def test_upload_dashboard_uses_named_image_file_field(self, run):
|
||||
run.return_value = type("Result", (), {
|
||||
"returncode": 0, "stdout": json.dumps({"image_key": "img_v3_test"}), "stderr": ""
|
||||
})()
|
||||
key = upload_dashboard_image(Path("charts/dashboard.png"))
|
||||
self.assertEqual(key, "img_v3_test")
|
||||
command = run.call_args.args[0]
|
||||
self.assertEqual(command[command.index("--file") + 1], "image=./dashboard.png")
|
||||
|
||||
@patch("data.tools.daily_report_card.subprocess.run")
|
||||
def test_send_card_to_all_recipients(self, run):
|
||||
run.side_effect = [
|
||||
type("Result", (), {"returncode": 0, "stdout": json.dumps({"message_id": f"om_{i}"}), "stderr": ""})()
|
||||
for i in range(3)
|
||||
]
|
||||
recipients = ["ou_one", "ou_two", "ou_three"]
|
||||
results = send_card_to_recipients({"schema": "2.0"}, recipients, "2026-07-15")
|
||||
|
||||
self.assertEqual([item["message_id"] for item in results], ["om_0", "om_1", "om_2"])
|
||||
self.assertEqual(run.call_count, 3)
|
||||
commands = [call.args[0] for call in run.call_args_list]
|
||||
self.assertEqual([cmd[cmd.index("--user-id") + 1] for cmd in commands], recipients)
|
||||
self.assertTrue(all("--msg-type" in cmd and "interactive" in cmd for cmd in commands))
|
||||
keys = [cmd[cmd.index("--idempotency-key") + 1] for cmd in commands]
|
||||
self.assertTrue(all(key.startswith("daily-2026-07-15-") for key in keys))
|
||||
self.assertTrue(all(len(key) <= 50 for key in keys))
|
||||
|
||||
@patch("data.tools.daily_report_card.send_card_to_recipients")
|
||||
@patch("data.tools.daily_report_card.upload_dashboard_image", return_value="img_v3_test")
|
||||
@patch("data.tools.daily_report_card.prepare_feishu_image")
|
||||
def test_daily_report_sends_dashboard_then_analysis_as_two_messages_per_recipient(
|
||||
self, prepare, upload, send
|
||||
):
|
||||
send.side_effect = [
|
||||
[{"message_id": "om_board_1"}, {"message_id": "om_board_2"}],
|
||||
[{"message_id": "om_text_1"}, {"message_id": "om_text_2"}],
|
||||
]
|
||||
recipients = ["ou_one", "ou_two"]
|
||||
|
||||
results = send_daily_report_cards(
|
||||
"2026-07-17",
|
||||
"第一部分 整体经营分析\n完整分析内容",
|
||||
[{"light": "🟢"}],
|
||||
Path("charts/dashboard.png"),
|
||||
recipients,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[item["message_id"] for item in results],
|
||||
["om_board_1", "om_board_2", "om_text_1", "om_text_2"],
|
||||
)
|
||||
self.assertEqual(send.call_count, 2)
|
||||
first_card = send.call_args_list[0].args[0]
|
||||
second_card = send.call_args_list[1].args[0]
|
||||
self.assertIn("img_v3_test", json.dumps(first_card, ensure_ascii=False))
|
||||
self.assertIn("完整分析内容", json.dumps(second_card, ensure_ascii=False))
|
||||
self.assertEqual(send.call_args_list[0].kwargs["message_kind"], "dashboard")
|
||||
self.assertEqual(send.call_args_list[1].kwargs["message_kind"], "analysis")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,104 +0,0 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.daily_report_charts import (
|
||||
_split_style_groups,
|
||||
build_overview_cards,
|
||||
classify_style_action,
|
||||
generate_dashboard,
|
||||
select_chart_styles,
|
||||
)
|
||||
|
||||
|
||||
class DailyReportChartTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.rows = [
|
||||
{"style_name": "大盘下滑", "light": "🔴", "visitors": 1000, "avg_visitors_7d": 2000, "cart_users": 80, "avg_cart_users_7d": 100, "visitor_change": -0.5, "cart_change": -0.2, "sales_change": -0.4, "refund_change": 0.1},
|
||||
{"style_name": "正向放量", "light": "🔴", "visitors": 1500, "avg_visitors_7d": 1000, "cart_users": 180, "avg_cart_users_7d": 100, "visitor_change": 0.5, "cart_change": 0.8, "sales_change": 0.3, "refund_change": -0.1},
|
||||
{"style_name": "小基数", "light": "🔴", "visitors": 20, "avg_visitors_7d": 100, "cart_users": 1, "avg_cart_users_7d": 2, "visitor_change": -0.8, "cart_change": -0.5, "sales_change": None, "refund_change": None},
|
||||
{"style_name": "黄灯A", "light": "🟡", "visitors": 800, "avg_visitors_7d": 1000, "cart_users": 70, "avg_cart_users_7d": 80, "visitor_change": -0.2, "cart_change": -0.125, "sales_change": -0.1, "refund_change": 0.2},
|
||||
{"style_name": "绿灯", "light": "🟢", "visitors": 500, "avg_visitors_7d": 510, "cart_users": 40, "avg_cart_users_7d": 41, "visitor_change": -0.02, "cart_change": -0.02, "sales_change": 0.0, "refund_change": 0.0},
|
||||
]
|
||||
|
||||
def test_selection_covers_negative_positive_and_small_base(self):
|
||||
names = [row["style_name"] for row in select_chart_styles(self.rows, limit=4)]
|
||||
self.assertIn("大盘下滑", names)
|
||||
self.assertIn("正向放量", names)
|
||||
self.assertIn("小基数", names)
|
||||
|
||||
def test_generate_dashboard_writes_png(self):
|
||||
enrichment = {"platform_metrics": [
|
||||
{"style_name": "大盘下滑", "platform": "天猫", "visitors": 700},
|
||||
{"style_name": "大盘下滑", "platform": "京东", "visitors": 300},
|
||||
]}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "dashboard.png"
|
||||
generate_dashboard("2026-07-15", self.rows, enrichment, path)
|
||||
self.assertTrue(path.exists())
|
||||
self.assertGreater(path.stat().st_size, 10_000)
|
||||
with Image.open(path) as image:
|
||||
self.assertEqual(image.size, (1600, 2500))
|
||||
|
||||
def test_style_groups_are_split_into_balanced_category_columns(self):
|
||||
groups = [
|
||||
{"style_category": "都市机能", "styles": [{}] * 12},
|
||||
{"style_category": "户外机能", "styles": [{}] * 3},
|
||||
{"style_category": "都市新贵", "styles": [{}] * 3},
|
||||
{"style_category": "智性通勤", "styles": [{}] * 14},
|
||||
{"style_category": "都市新旅", "styles": [{}]},
|
||||
{"style_category": "都市运动", "styles": [{}] * 3},
|
||||
]
|
||||
left, right = _split_style_groups(groups)
|
||||
self.assertEqual([group["style_category"] for group in left], ["都市机能", "户外机能", "都市新贵"])
|
||||
self.assertEqual([group["style_category"] for group in right], ["智性通勤", "都市新旅", "都市运动"])
|
||||
|
||||
groups.append({"style_category": "未分类", "styles": [{}] * 2})
|
||||
left, right = _split_style_groups(groups)
|
||||
self.assertEqual(sum(len(group["styles"]) for group in left), 18)
|
||||
self.assertEqual(sum(len(group["styles"]) for group in right), 20)
|
||||
|
||||
def test_overview_compares_today_yesterday_and_seven_day_average(self):
|
||||
rows = [
|
||||
{
|
||||
"style_name": "蓝鹊", "sales": 12, "sales_yesterday": 10, "avg_sales_7d": 8,
|
||||
"visitors": 120, "visitors_yesterday": 100, "avg_visitors_7d": 90,
|
||||
},
|
||||
{
|
||||
"style_name": "宙斯", "sales": 18, "sales_yesterday": 20, "avg_sales_7d": 15,
|
||||
"visitors": 180, "visitors_yesterday": 200, "avg_visitors_7d": 150,
|
||||
},
|
||||
]
|
||||
cards = build_overview_cards(rows, {"daily_new_notes": [{"view_count": 515}]})
|
||||
sales = next(card for card in cards if card["key"] == "sales")
|
||||
conversion = next(card for card in cards if card["key"] == "conversion")
|
||||
content = next(card for card in cards if card["key"] == "content")
|
||||
|
||||
self.assertEqual((sales["today"], sales["yesterday"], sales["baseline_7d"]), (30, 30, 23))
|
||||
self.assertEqual(conversion["today"], 0.1)
|
||||
self.assertEqual(content["today"], 515)
|
||||
self.assertIsNone(content["baseline_7d"])
|
||||
|
||||
def test_style_action_matches_prompt_growth_opportunity_and_risk_rules(self):
|
||||
growth = classify_style_action({
|
||||
"sales_change_yesterday": 0.2, "visitor_change_yesterday": 0.1,
|
||||
"conversion_change_yesterday": 0.001, "refund_change_yesterday": -0.1,
|
||||
})
|
||||
opportunity = classify_style_action({
|
||||
"sales_change_yesterday": -0.1, "visitor_change_yesterday": 0.2,
|
||||
"conversion_change_yesterday": -0.01, "refund_change_yesterday": 0,
|
||||
})
|
||||
risk = classify_style_action({
|
||||
"sales_change_yesterday": -0.2, "visitor_change_yesterday": -0.1,
|
||||
"conversion_change_yesterday": -0.01, "refund_change_yesterday": 0.4,
|
||||
})
|
||||
|
||||
self.assertEqual(growth, ("增长型", "加达人/备货"))
|
||||
self.assertEqual(opportunity, ("流量机会", "优化详情页"))
|
||||
self.assertEqual(risk, ("风险型", "查退款/控投放"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
-130
@@ -1,130 +0,0 @@
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import friday_relogin_parallel as relogin
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import relogin_transaction
|
||||
|
||||
|
||||
def test_five_expected_platforms_are_configured():
|
||||
assert set(relogin.PLATFORMS) == {
|
||||
"bilibili", "douyin", "pgy", "xingtu", "xiaohongshu"
|
||||
}
|
||||
assert relogin.PLATFORMS["xingtu"].required_cookie == "sessionid"
|
||||
assert relogin.PLATFORMS["douyin"].required_cookie == "sessionid"
|
||||
|
||||
|
||||
def test_default_screenshot_recipients_are_routed_by_platform():
|
||||
assert relogin.DEFAULT_RECIPIENTS == {
|
||||
"pgy": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
|
||||
"xingtu": "ou_24cc944d6e43c69c59d6560ad4e2ae6e",
|
||||
"douyin": "ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
"bilibili": "ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
"xiaohongshu": "ou_7ad5fc8012e2f741afc5346e05ffd447",
|
||||
}
|
||||
assert relogin.recipient_for_platform("pgy") == "ou_24cc944d6e43c69c59d6560ad4e2ae6e"
|
||||
assert relogin.recipient_for_platform("douyin") == "ou_7ad5fc8012e2f741afc5346e05ffd447"
|
||||
assert relogin.recipient_for_platform("bilibili") == "ou_7ad5fc8012e2f741afc5346e05ffd447"
|
||||
assert relogin.recipient_for_platform("pgy", "ou_override") == "ou_override"
|
||||
|
||||
|
||||
def test_failed_platforms_are_grouped_by_their_responsible_recipient():
|
||||
grouped = relogin.group_platforms_by_recipient(
|
||||
["pgy", "douyin", "bilibili", "xingtu", "xiaohongshu"]
|
||||
)
|
||||
assert grouped == {
|
||||
"ou_24cc944d6e43c69c59d6560ad4e2ae6e": ["pgy", "xingtu"],
|
||||
"ou_7ad5fc8012e2f741afc5346e05ffd447": [
|
||||
"douyin", "bilibili", "xiaohongshu"
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_login_windows_are_classified_by_title():
|
||||
assert relogin._platform_from_window_title("账号登录 - Google Chrome") == "bilibili"
|
||||
assert relogin._platform_from_window_title("小红书蒲公英 - Google Chrome") == "pgy"
|
||||
assert relogin._platform_from_window_title("抖音授权 - Google Chrome") == "xingtu"
|
||||
assert relogin._platform_from_window_title("抖音 - 记录美好生活") == "douyin"
|
||||
assert relogin._platform_from_window_title("小红书 - 你的生活兴趣社区") == "xiaohongshu"
|
||||
assert relogin._platform_from_window_title("Codex") is None
|
||||
|
||||
|
||||
def test_retry_only_failed_platforms_for_at_most_three_rounds():
|
||||
calls = []
|
||||
|
||||
def fake_round(names, attempt):
|
||||
calls.append((tuple(names), attempt))
|
||||
if attempt == 1:
|
||||
return {name: name != "xingtu" for name in names}
|
||||
if attempt == 2:
|
||||
return {name: False for name in names}
|
||||
return {name: True for name in names}
|
||||
|
||||
result = relogin.run_with_retries(
|
||||
["bilibili", "pgy", "xingtu", "xiaohongshu"],
|
||||
max_attempts=3,
|
||||
run_round=fake_round,
|
||||
)
|
||||
|
||||
assert calls == [
|
||||
(("bilibili", "pgy", "xingtu", "xiaohongshu"), 1),
|
||||
(("xingtu",), 2),
|
||||
(("xingtu",), 3),
|
||||
]
|
||||
assert all(result.values())
|
||||
|
||||
|
||||
def test_stops_after_third_failed_round():
|
||||
calls = []
|
||||
|
||||
def always_fail(names, attempt):
|
||||
calls.append(attempt)
|
||||
return {name: False for name in names}
|
||||
|
||||
result = relogin.run_with_retries(
|
||||
["pgy"], max_attempts=3, run_round=always_fail
|
||||
)
|
||||
|
||||
assert calls == [1, 2, 3]
|
||||
assert result == {"pgy": False}
|
||||
|
||||
|
||||
def test_timeout_terminates_the_complete_process_tree(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class Process:
|
||||
pid = 1234
|
||||
|
||||
def terminate(self):
|
||||
calls.append("fallback-terminate")
|
||||
|
||||
def wait(self, timeout):
|
||||
calls.append(("wait", timeout))
|
||||
|
||||
monkeypatch.setattr(
|
||||
relogin.subprocess,
|
||||
"run",
|
||||
lambda command, **kwargs: calls.append((command, kwargs)),
|
||||
)
|
||||
|
||||
relogin._terminate_process_tree(Process())
|
||||
|
||||
assert calls[0][0] == ["taskkill", "/PID", "1234", "/T", "/F"]
|
||||
assert calls[1] == ("wait", 10)
|
||||
|
||||
|
||||
def test_transaction_rolls_back_files_and_profile(tmp_path):
|
||||
cookie = tmp_path / "cookies.json"
|
||||
storage = tmp_path / "storage.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text("old-cookie", encoding="utf-8")
|
||||
storage.write_text("old-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
|
||||
|
||||
state = relogin_transaction.begin((cookie, storage), profile)
|
||||
cookie.write_text("partial", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "partial.txt").write_text("partial", encoding="utf-8")
|
||||
relogin_transaction.rollback(state)
|
||||
|
||||
assert cookie.read_text(encoding="utf-8") == "old-cookie"
|
||||
assert storage.read_text(encoding="utf-8") == "old-storage"
|
||||
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
|
||||
assert not (profile / "partial.txt").exists()
|
||||
-305
@@ -1,305 +0,0 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.creator_task_grouping import chunk_creator_groups, group_tasks_by_creator
|
||||
|
||||
import pgy_xhs_scraper_v2 as pgy
|
||||
import xingtu_scraper_v2 as xingtu
|
||||
|
||||
|
||||
def test_group_tasks_uses_creator_id_and_merges_missing_id_by_unique_name():
|
||||
tasks = [
|
||||
{"creator_name": "同一达人", "creator_id": " 123 ", "record_id": "a"},
|
||||
{"creator_name": "同一达人 ", "creator_id": None, "record_id": "b"},
|
||||
{"creator_name": "另一个达人", "creator_id": None, "record_id": "c"},
|
||||
]
|
||||
|
||||
groups = group_tasks_by_creator(tasks)
|
||||
|
||||
assert len(groups) == 2
|
||||
assert groups[0]["creator_id"] == "123"
|
||||
assert [task["record_id"] for task in groups[0]["tasks"]] == ["a", "b"]
|
||||
|
||||
|
||||
def test_group_tasks_does_not_guess_when_same_name_has_conflicting_ids():
|
||||
tasks = [
|
||||
{"creator_name": "重名达人", "creator_id": "1", "record_id": "a"},
|
||||
{"creator_name": "重名达人", "creator_id": "2", "record_id": "b"},
|
||||
{"creator_name": "重名达人", "creator_id": None, "record_id": "c"},
|
||||
]
|
||||
|
||||
groups = group_tasks_by_creator(tasks)
|
||||
|
||||
assert len(groups) == 3
|
||||
|
||||
|
||||
def test_zero_batch_size_means_one_unlimited_batch():
|
||||
groups = [{"creator_name": str(i), "tasks": []} for i in range(3)]
|
||||
assert chunk_creator_groups(groups, 0) == [groups]
|
||||
|
||||
|
||||
def _assert_cross_style_collection(module, monkeypatch):
|
||||
styles = [
|
||||
{"index": 1, "name": "款式A", "base_token": "base-a", "table_id": "table-a", "field_map": {"a": 1}},
|
||||
{"index": 2, "name": "款式B", "base_token": "base-b", "table_id": "table-b", "field_map": {"b": 2}},
|
||||
]
|
||||
|
||||
def fake_extract(style, only_record_ids):
|
||||
return [{
|
||||
"record_id": f"record-{style['index']}",
|
||||
"creator_name": "同一达人",
|
||||
"creator_id": "creator-1",
|
||||
"target_title": f"标题{style['index']}",
|
||||
}]
|
||||
|
||||
monkeypatch.setattr(module, "extract_target_tasks", fake_extract)
|
||||
tasks, summaries = module.collect_tasks_across_styles(styles, None)
|
||||
|
||||
assert len(tasks) == 2
|
||||
assert len(group_tasks_by_creator(tasks)) == 1
|
||||
assert tasks[0]["style_context"]["table_id"] == "table-a"
|
||||
assert tasks[1]["style_context"]["table_id"] == "table-b"
|
||||
assert summaries[1]["total"] == 1
|
||||
assert summaries[2]["total"] == 1
|
||||
|
||||
|
||||
def test_pgy_collects_tasks_across_styles(monkeypatch):
|
||||
_assert_cross_style_collection(pgy, monkeypatch)
|
||||
|
||||
|
||||
def test_xingtu_collects_tasks_across_styles(monkeypatch):
|
||||
_assert_cross_style_collection(xingtu, monkeypatch)
|
||||
|
||||
|
||||
def test_xingtu_record_scope_is_applied_per_style(monkeypatch):
|
||||
styles = [
|
||||
{"index": 1, "name": "款式A", "base_token": "a", "table_id": "a", "field_map": {}},
|
||||
{"index": 2, "name": "款式B", "base_token": "b", "table_id": "b", "field_map": {}},
|
||||
]
|
||||
observed = {}
|
||||
|
||||
def fake_extract(style, only_record_ids):
|
||||
observed[style["index"]] = only_record_ids
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(xingtu, "extract_target_tasks", fake_extract)
|
||||
|
||||
xingtu.collect_tasks_across_styles(styles, {1: {"same-id"}, 2: {"other-id"}})
|
||||
|
||||
assert observed == {1: {"same-id"}, 2: {"other-id"}}
|
||||
|
||||
|
||||
def test_xingtu_load_retry_scope_keeps_style_and_record_pair(tmp_path):
|
||||
summary_path = tmp_path / "summary.json"
|
||||
summary_path.write_text(json.dumps([
|
||||
{"index": 1, "results": [
|
||||
{"record_id": "same-id", "reason": "creator_search_circuit_open"},
|
||||
]},
|
||||
{"index": 2, "results": [
|
||||
{"record_id": "same-id", "reason": "title_unmatched"},
|
||||
{"record_id": "other-id", "reason": "creator_search_circuit_open"},
|
||||
]},
|
||||
]), encoding="utf-8")
|
||||
|
||||
scope = xingtu.load_retry_scope(summary_path, "creator_search_circuit_open")
|
||||
|
||||
assert scope == {1: {"same-id"}, 2: {"other-id"}}
|
||||
|
||||
|
||||
def _assert_matched_task_uses_own_writeback_context(module, metric_key, monkeypatch):
|
||||
calls = []
|
||||
task = {
|
||||
"record_id": "record-b",
|
||||
"publish_time": object(),
|
||||
"style_context": {
|
||||
"index": 2,
|
||||
"base_token": "base-b",
|
||||
"table_id": "table-b",
|
||||
"field_map": {"slot": "style-b-slot"},
|
||||
},
|
||||
}
|
||||
summary = {"matched": 0, "filled": 0, "skipped_no_pubtime": 0, "results": []}
|
||||
monkeypatch.setattr(module, "pick_read_field", lambda fmap, publish_time: ("read_count_7d", "field-b", "7天曝光量"))
|
||||
monkeypatch.setattr(module, "write_back", lambda *args: calls.append(args) or True)
|
||||
|
||||
module.apply_matched_task(task, {metric_key: "1.2万", "title": "命中标题"}, summary, dry_run=False)
|
||||
|
||||
assert calls == [("base-b", "table-b", "record-b", "field-b", 12000)]
|
||||
assert summary["matched"] == 1
|
||||
assert summary["filled"] == 1
|
||||
|
||||
|
||||
def test_pgy_match_writes_to_task_style_context(monkeypatch):
|
||||
_assert_matched_task_uses_own_writeback_context(pgy, "read_count", monkeypatch)
|
||||
|
||||
|
||||
def test_xingtu_match_writes_to_task_style_context(monkeypatch):
|
||||
_assert_matched_task_uses_own_writeback_context(xingtu, "play_count", monkeypatch)
|
||||
|
||||
|
||||
def test_xingtu_search_and_open_creator_submits_only_one_search(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fail_search(_page, creator_name, creator_id):
|
||||
calls.append((creator_name, creator_id))
|
||||
raise TimeoutError("no result")
|
||||
|
||||
monkeypatch.setattr(xingtu, "search_creator", fail_search)
|
||||
monkeypatch.setattr(
|
||||
xingtu,
|
||||
"open_creator_detail",
|
||||
lambda *_args, **_kwargs: pytest.fail("detail must not open after search failure"),
|
||||
)
|
||||
|
||||
with pytest.raises(TimeoutError, match="no result"):
|
||||
xingtu.search_and_open_creator_once(object(), "同一达人", "creator-1")
|
||||
|
||||
assert calls == [("同一达人", "creator-1")]
|
||||
|
||||
|
||||
def test_xingtu_search_budget_blocks_duplicate_without_failure_circuit():
|
||||
budget = xingtu.CreatorSearchBudget(max_consecutive_failures=3)
|
||||
|
||||
assert budget.begin(("id", "1")) is True
|
||||
assert budget.begin(("id", "1")) is False
|
||||
budget.failed("first")
|
||||
assert budget.begin(("id", "2")) is True
|
||||
budget.failed("second")
|
||||
assert budget.begin(("id", "3")) is True
|
||||
assert budget.failed("third") is False
|
||||
assert budget.begin(("id", "4")) is True
|
||||
assert budget.calls == 4
|
||||
assert budget.circuit_reason is None
|
||||
|
||||
budget.exhaust("search_quota_exhausted")
|
||||
assert budget.begin(("id", "5")) is False
|
||||
|
||||
|
||||
def test_xingtu_wait_for_results_detects_search_quota_exhausted():
|
||||
class Page:
|
||||
def evaluate(self, *_args):
|
||||
return "quota"
|
||||
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
pytest.fail("quota detection must return immediately")
|
||||
|
||||
with pytest.raises(xingtu.SearchQuotaExhausted):
|
||||
xingtu.wait_for_visible_creator_result(Page(), "达人", timeout_ms=1000)
|
||||
|
||||
|
||||
def test_xingtu_missing_creation_tab_is_not_treated_as_global_block():
|
||||
class Page:
|
||||
def evaluate(self, _script):
|
||||
return "no_creation_tab"
|
||||
|
||||
assert xingtu.is_blocked(Page()) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reason", ["verify_text", "verify_iframe", "verify_div"])
|
||||
def test_xingtu_explicit_verification_still_opens_global_block(reason):
|
||||
class Page:
|
||||
def evaluate(self, _script):
|
||||
return reason
|
||||
|
||||
assert xingtu.is_blocked(Page()) is True
|
||||
|
||||
|
||||
def test_xingtu_missing_creation_tab_falls_back_to_current_creator_page():
|
||||
class Locator:
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def click(self, **_kwargs):
|
||||
raise TimeoutError("missing tab")
|
||||
|
||||
class Page:
|
||||
def get_by_text(self, *_args, **_kwargs):
|
||||
return Locator()
|
||||
|
||||
def evaluate(self, _script):
|
||||
return False
|
||||
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
pass
|
||||
|
||||
assert xingtu.open_creation_ability(Page()) is False
|
||||
|
||||
|
||||
def test_xingtu_current_creator_page_cards_are_used_as_fallback(monkeypatch):
|
||||
class Page:
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
pass
|
||||
|
||||
monkeypatch.setattr(xingtu, "is_blocked", lambda _page: False)
|
||||
monkeypatch.setattr(
|
||||
xingtu,
|
||||
"parse_videos_stable",
|
||||
lambda _page: [{
|
||||
"title": "MacBook党必看!提升幸福感的开工好物",
|
||||
"play_count": "1.2万",
|
||||
"note_id": "",
|
||||
}],
|
||||
)
|
||||
monkeypatch.setattr(xingtu, "go_next_page", lambda _page: False)
|
||||
monkeypatch.setattr(xingtu, "submit_video_search", lambda _page, _title: False)
|
||||
|
||||
found, _candidates, page_limit_hit = xingtu.find_videos_for_tasks(
|
||||
Page(),
|
||||
[{
|
||||
"record_id": "record-1",
|
||||
"target_title": "MacBook党必看!提升幸福感的开工好物",
|
||||
}],
|
||||
)
|
||||
|
||||
assert found["record-1"]["play_count"] == "1.2万"
|
||||
assert found["record-1"]["match_method"] == "title"
|
||||
assert page_limit_hit is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
"BrowserContext.new_page: Protocol error (Target.createTarget): Failed to open a new tab",
|
||||
"Protocol error (Target.createTarget)",
|
||||
],
|
||||
)
|
||||
def test_xingtu_new_tab_creation_failure_rebuilds_browser_session(message):
|
||||
assert xingtu.is_browser_session_lost(RuntimeError(message)) is True
|
||||
|
||||
|
||||
def test_daily_retry_does_not_search_xingtu_again_same_day():
|
||||
batch = Path("data/tools/daily_run.bat").read_text(encoding="utf-8")
|
||||
|
||||
retry_lines = [line for line in batch.splitlines() if "retry_failed.py" in line and "call" in line]
|
||||
|
||||
assert len(retry_lines) == 1
|
||||
assert "--platform bili,pgy" in retry_lines[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"batch_path",
|
||||
[
|
||||
Path("data/tools/daily_run.bat"),
|
||||
Path("data/tools/daily_run_with_backfill.bat"),
|
||||
],
|
||||
)
|
||||
def test_scheduled_collection_syncs_pg_before_and_after_retry(batch_path):
|
||||
batch = batch_path.read_text(encoding="utf-8")
|
||||
calls = [
|
||||
line.strip().lower()
|
||||
for line in batch.splitlines()
|
||||
if line.strip().lower().startswith("call %python%")
|
||||
]
|
||||
|
||||
run_index = next(i for i, line in enumerate(calls) if "run_all.py" in line)
|
||||
retry_index = next(i for i, line in enumerate(calls) if "retry_failed.py" in line)
|
||||
sync_indices = [
|
||||
i for i, line in enumerate(calls)
|
||||
if "sync_metrics_to_cmt_notes.py" in line
|
||||
]
|
||||
|
||||
assert len(sync_indices) == 2
|
||||
assert run_index < sync_indices[0] < retry_index < sync_indices[1]
|
||||
@@ -1,240 +0,0 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import relogin_douyin, relogin_pgy, relogin_xingtu
|
||||
import xingtu_scraper_v2
|
||||
import pgy_xhs_scraper_v2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu])
|
||||
def test_failed_relogin_restores_cookie_and_profile(tmp_path, monkeypatch, module):
|
||||
cookie = tmp_path / "cookies.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text("old-cookie", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "state.txt").write_text("old-profile", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(module, "PROFILE_DIR", profile)
|
||||
|
||||
cookie_backup, profile_backup = module.reset_state()
|
||||
assert not cookie.exists()
|
||||
assert not profile.exists()
|
||||
|
||||
cookie.write_text("partial-new-cookie", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "partial.txt").write_text("partial", encoding="utf-8")
|
||||
|
||||
module.restore_previous_state(cookie_backup, profile_backup)
|
||||
|
||||
assert cookie.read_text(encoding="utf-8") == "old-cookie"
|
||||
assert (profile / "state.txt").read_text(encoding="utf-8") == "old-profile"
|
||||
assert not (profile / "partial.txt").exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("module", [relogin_pgy, relogin_xingtu])
|
||||
def test_successful_relogin_keeps_new_cookie(tmp_path, monkeypatch, module):
|
||||
cookie = tmp_path / "cookies.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text("old-cookie", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
|
||||
monkeypatch.setattr(module, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(module, "PROFILE_DIR", profile)
|
||||
|
||||
cookie_backup, profile_backup = module.reset_state()
|
||||
cookie.write_text("new-cookie", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
|
||||
module.finish_successful_relogin(profile_backup)
|
||||
|
||||
assert cookie.read_text(encoding="utf-8") == "new-cookie"
|
||||
assert cookie_backup.exists()
|
||||
assert not profile_backup.exists()
|
||||
|
||||
|
||||
def test_xingtu_scan_login_opens_customer_sso_and_clicks_douyin():
|
||||
class FakeLocator:
|
||||
def __init__(self):
|
||||
self.first = self
|
||||
self.clicked = False
|
||||
self.evaluated = False
|
||||
|
||||
def count(self):
|
||||
return 1
|
||||
|
||||
def is_visible(self):
|
||||
return True
|
||||
|
||||
def click(self, timeout):
|
||||
self.clicked = True
|
||||
|
||||
def evaluate(self, script):
|
||||
assert script == "e => e.click()"
|
||||
self.evaluated = True
|
||||
|
||||
class FakePage:
|
||||
url = "https://www.xingtu.cn/?redirect_uri=/ad/creator/market"
|
||||
|
||||
def __init__(self):
|
||||
self.goto_url = None
|
||||
self.selectors = []
|
||||
self.douyin = FakeLocator()
|
||||
|
||||
def goto(self, url, **kwargs):
|
||||
self.goto_url = url
|
||||
self.url = url
|
||||
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
pass
|
||||
|
||||
def locator(self, selector):
|
||||
self.selectors.append(selector)
|
||||
return self.douyin
|
||||
|
||||
def get_by_text(self, _pattern):
|
||||
return FakeLocator()
|
||||
|
||||
page = FakePage()
|
||||
xingtu_scraper_v2.open_scan_login(page)
|
||||
|
||||
assert page.goto_url == xingtu_scraper_v2.CUSTOMER_LOGIN_URL
|
||||
assert 'img[src*="aweme.png"]' in page.selectors
|
||||
assert page.douyin.clicked
|
||||
|
||||
|
||||
def test_xingtu_index_page_is_recognized_as_logged_in():
|
||||
class Locator:
|
||||
@property
|
||||
def first(self):
|
||||
return self
|
||||
|
||||
def wait_for(self, timeout):
|
||||
assert timeout == 1200
|
||||
|
||||
class Page:
|
||||
url = "https://www.xingtu.cn/ad/creator/index"
|
||||
|
||||
def get_by_text(self, _text, exact=False):
|
||||
return Locator()
|
||||
|
||||
assert xingtu_scraper_v2.is_logged_in(Page()) is True
|
||||
|
||||
|
||||
def test_xingtu_cookie_validation_requires_real_session_cookie(tmp_path):
|
||||
cookie = tmp_path / "xingtu.json"
|
||||
cookie.write_text('[{"name":"other","value":"1"}]', encoding="utf-8")
|
||||
assert xingtu_scraper_v2.has_valid_login_cookie_file(cookie) is False
|
||||
|
||||
cookie.write_text('[{"name":"sessionid","value":"fresh"}]', encoding="utf-8")
|
||||
assert xingtu_scraper_v2.has_valid_login_cookie_file(cookie) is True
|
||||
|
||||
|
||||
def test_xingtu_login_only_does_not_report_success_when_page_action_was_swallowed(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
cookie = tmp_path / "xingtu.json"
|
||||
cookie.write_text('[{"name":"sessionid","value":"old"}]', encoding="utf-8")
|
||||
|
||||
class Session:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def fetch(self, *_args, **_kwargs):
|
||||
# Scrapling can log a page_action error and still return a response.
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "DynamicSession", lambda **_kwargs: Session())
|
||||
monkeypatch.setattr(xingtu_scraper_v2, "_force_cleanup_session", lambda _session: None)
|
||||
|
||||
assert xingtu_scraper_v2.login_only(1) == 1
|
||||
|
||||
|
||||
def test_xingtu_interrupted_transaction_is_recovered(tmp_path, monkeypatch):
|
||||
cookie = tmp_path / "xingtu_cookies.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text("old-cookie", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(relogin_xingtu, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(relogin_xingtu, "PROFILE_DIR", profile)
|
||||
|
||||
cookie_backup, profile_backup = relogin_xingtu.reset_state()
|
||||
profile.mkdir()
|
||||
(profile / "partial.txt").write_text("partial", encoding="utf-8")
|
||||
|
||||
assert relogin_xingtu.recover_interrupted_state() is True
|
||||
assert cookie.read_text(encoding="utf-8") == "old-cookie"
|
||||
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
|
||||
assert not (profile / "partial.txt").exists()
|
||||
assert cookie_backup is not None
|
||||
assert profile_backup is not None
|
||||
|
||||
|
||||
def test_pgy_scan_login_clicks_qr_switch_image():
|
||||
class FakeLocator:
|
||||
def __init__(self):
|
||||
self.first = self
|
||||
self.clicked = False
|
||||
self.evaluated = False
|
||||
|
||||
def count(self):
|
||||
return 1
|
||||
|
||||
def is_visible(self):
|
||||
return True
|
||||
|
||||
def click(self, timeout):
|
||||
self.clicked = True
|
||||
|
||||
def evaluate(self, script):
|
||||
assert script == "e => e.click()"
|
||||
self.evaluated = True
|
||||
|
||||
class FakePage:
|
||||
def __init__(self):
|
||||
self.selectors = []
|
||||
self.qr_switch = FakeLocator()
|
||||
|
||||
def wait_for_timeout(self, _milliseconds):
|
||||
pass
|
||||
|
||||
def get_by_text(self, _pattern):
|
||||
return FakeLocator()
|
||||
|
||||
def locator(self, selector):
|
||||
self.selectors.append(selector)
|
||||
return self.qr_switch
|
||||
|
||||
page = FakePage()
|
||||
pgy_xhs_scraper_v2.open_scan_login(page)
|
||||
|
||||
assert 'img[src*="qr_code"]' in page.selectors
|
||||
assert page.qr_switch.evaluated
|
||||
|
||||
|
||||
def test_failed_douyin_relogin_restores_cookie_storage_and_profile(tmp_path, monkeypatch):
|
||||
cookie = tmp_path / "douyin_cookies.json"
|
||||
storage = tmp_path / "douyin_storage_state.json"
|
||||
profile = tmp_path / "profile"
|
||||
cookie.write_text('[{"name":"sessionid","value":"old"}]', encoding="utf-8")
|
||||
storage.write_text("old-storage", encoding="utf-8")
|
||||
profile.mkdir()
|
||||
(profile / "old.txt").write_text("old-profile", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(relogin_douyin, "COOKIE_FILE", cookie)
|
||||
monkeypatch.setattr(relogin_douyin, "STATE_FILE", storage)
|
||||
monkeypatch.setattr(relogin_douyin, "PROFILE_DIR", profile)
|
||||
monkeypatch.setattr(relogin_douyin.subprocess, "call", lambda *_args, **_kwargs: 1)
|
||||
monkeypatch.setattr(relogin_douyin.sys, "argv", ["relogin_douyin.py"])
|
||||
|
||||
assert relogin_douyin.main() == 1
|
||||
assert cookie.read_text(encoding="utf-8") == '[{"name":"sessionid","value":"old"}]'
|
||||
assert storage.read_text(encoding="utf-8") == "old-storage"
|
||||
assert (profile / "old.txt").read_text(encoding="utf-8") == "old-profile"
|
||||
@@ -1,72 +0,0 @@
|
||||
import io
|
||||
import os
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import db
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools import sync_metrics_to_cmt_notes as sync_metrics
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class RemoteDatabaseConfigTests(unittest.TestCase):
|
||||
def test_config_requires_every_connection_value_without_local_fallback(self):
|
||||
complete = {
|
||||
"PG_HOST": "db.example.com",
|
||||
"PG_PORT": "5432",
|
||||
"PG_DB": "analytics",
|
||||
"PG_USER": "reporter",
|
||||
"PG_PASSWORD": "secret",
|
||||
}
|
||||
self.assertEqual(
|
||||
db.database_config_from_env(complete),
|
||||
{
|
||||
"host": "db.example.com",
|
||||
"port": 5432,
|
||||
"dbname": "analytics",
|
||||
"user": "reporter",
|
||||
"password": "secret",
|
||||
},
|
||||
)
|
||||
for missing in complete:
|
||||
values = {key: value for key, value in complete.items() if key != missing}
|
||||
with self.subTest(missing=missing), self.assertRaisesRegex(RuntimeError, missing):
|
||||
db.database_config_from_env(values)
|
||||
|
||||
def test_runtime_config_points_to_remote_data_hub(self):
|
||||
config = db.get_db_config()
|
||||
self.assertEqual(config["host"], "8.148.185.119")
|
||||
self.assertEqual(config["dbname"], "data_hub")
|
||||
self.assertEqual(config["user"], "data_hub")
|
||||
self.assertNotIn(config["host"], {"localhost", "127.0.0.1"})
|
||||
|
||||
def test_database_scripts_do_not_define_local_connection_defaults(self):
|
||||
paths = (
|
||||
ROOT / "data" / "tools" / "db.py",
|
||||
ROOT / "data" / "tools" / "sync_metrics_to_cmt_notes.py",
|
||||
ROOT / "data" / "tools" / "sync_cooperations.py",
|
||||
ROOT / "data" / "tools" / "generate_creator_report.py",
|
||||
)
|
||||
for path in paths:
|
||||
source = path.read_text(encoding="utf-8-sig")
|
||||
with self.subTest(path=path.name):
|
||||
self.assertNotIn('"localhost"', source)
|
||||
self.assertNotIn("'localhost'", source)
|
||||
self.assertNotIn('"gyxx_super_data"', source)
|
||||
self.assertNotIn("'gyxx_super_data'", source)
|
||||
|
||||
def test_sync_console_replaces_characters_unsupported_by_gbk(self):
|
||||
raw = io.BytesIO()
|
||||
stream = io.TextIOWrapper(raw, encoding="gbk", errors="strict")
|
||||
|
||||
sync_metrics.configure_console_output(stream, None)
|
||||
stream.write("𝑻")
|
||||
stream.flush()
|
||||
|
||||
self.assertTrue(raw.getvalue())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
-385
@@ -1,385 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import gyxx_flow.modules.content_marketing.runtime.data.tools.retry_failed as retry
|
||||
import run_all
|
||||
|
||||
|
||||
def test_requested_records_must_be_successful_even_when_process_exits_zero():
|
||||
data = {
|
||||
"results": [
|
||||
{"record_id": "a", "status": "success", "matched": True},
|
||||
{"record_id": "b", "status": "retryable_failure", "matched": False},
|
||||
]
|
||||
}
|
||||
|
||||
ok, failed = retry.requested_records_succeeded(data, ["a", "b"])
|
||||
|
||||
assert ok is False
|
||||
assert failed == ["b"]
|
||||
|
||||
|
||||
def test_success_status_cannot_hide_write_failure_and_blocked_input_is_terminal():
|
||||
contradictory = {
|
||||
"results": [{
|
||||
"record_id": "a", "status": "success", "matched": True,
|
||||
"write_ok": False,
|
||||
}]
|
||||
}
|
||||
assert retry.requested_records_succeeded(contradictory, ["a"]) == (False, ["a"])
|
||||
|
||||
blocked = {
|
||||
"total": 1,
|
||||
"details": [{
|
||||
"record_id": "b", "status": "blocked_input", "ok": False,
|
||||
"reason": "url_missing",
|
||||
}],
|
||||
}
|
||||
assert retry.extract_failed_records(blocked, "bili") == []
|
||||
|
||||
|
||||
def test_canonical_filename_requires_current_style_name():
|
||||
style = {"index": 2, "name": "极星pro"}
|
||||
|
||||
assert retry.canonical_result_filename(style, "pgy", False) == "02-极星pro_v2.json"
|
||||
assert retry.canonical_result_filename(style, "xt", False) == "02-极星pro_xingtu_v2.json"
|
||||
assert retry.canonical_result_filename(style, "bili", True) == "02-极星pro_self_bilibili_v2.json"
|
||||
|
||||
|
||||
def _write_json(path: Path, payload: dict | list) -> None:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
def test_find_failed_uses_exact_current_style_file_and_ignores_legacy_name(tmp_path):
|
||||
style = {"index": 2, "name": "极星pro"}
|
||||
canonical = tmp_path / "02-极星pro_v2.json"
|
||||
legacy = tmp_path / "02-盖亚微单_v2.json"
|
||||
payload = {
|
||||
"index": 2,
|
||||
"total": 1,
|
||||
"matched": 1,
|
||||
"filled": 1,
|
||||
"results": [{"record_id": "ok", "status": "success", "matched": True}],
|
||||
}
|
||||
_write_json(canonical, payload)
|
||||
_write_json(legacy, {"index": 2, "total": 1, "matched": 0, "results": []})
|
||||
old = time.time() - 48 * 3600
|
||||
os.utime(legacy, (old, old))
|
||||
|
||||
failed = retry.find_failed(
|
||||
"pgy", stale_hours=4, include_half=True,
|
||||
styles=[style], v2_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert failed == {}
|
||||
|
||||
|
||||
def test_find_failed_treats_missing_result_rows_as_incomplete(tmp_path):
|
||||
style = {"index": 1, "name": "款式A"}
|
||||
path = tmp_path / retry.canonical_result_filename(style, "xt", False)
|
||||
_write_json(path, {"index": 1, "total": 2, "matched": 0, "filled": 0, "results": []})
|
||||
|
||||
failed = retry.find_failed(
|
||||
"xt", stale_hours=4, include_half=True,
|
||||
styles=[style], v2_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert failed[1]["reason"] == "ALL_FAILED"
|
||||
assert failed[1]["failed_rids"] == []
|
||||
|
||||
|
||||
def test_run_one_style_rechecks_payload_and_retries_exit_zero(monkeypatch, tmp_path):
|
||||
style = {"index": 1, "name": "款式A"}
|
||||
result_path = tmp_path / retry.canonical_result_filename(style, "pgy", False)
|
||||
calls = []
|
||||
|
||||
def fake_call(_cmd, cwd):
|
||||
calls.append(cwd)
|
||||
_write_json(result_path, {
|
||||
"index": 1,
|
||||
"total": 1,
|
||||
"matched": 0,
|
||||
"filled": 0,
|
||||
"results": [{
|
||||
"record_id": "bad",
|
||||
"status": "retryable_failure",
|
||||
"matched": False,
|
||||
}],
|
||||
})
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(retry.subprocess, "call", fake_call)
|
||||
monkeypatch.setattr(retry.time, "sleep", lambda _seconds: None)
|
||||
|
||||
ok = retry.run_one_style(
|
||||
"pgy", 1, max_attempts=2, record_ids=["bad"],
|
||||
style=style, v2_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert len(calls) == 2
|
||||
|
||||
|
||||
def test_run_one_style_accepts_exit_zero_only_after_requested_record_succeeds(monkeypatch, tmp_path):
|
||||
style = {"index": 1, "name": "款式A"}
|
||||
result_path = tmp_path / retry.canonical_result_filename(style, "pgy", False)
|
||||
|
||||
def fake_call(_cmd, cwd):
|
||||
_write_json(result_path, {
|
||||
"index": 1,
|
||||
"total": 1,
|
||||
"matched": 1,
|
||||
"filled": 1,
|
||||
"results": [{
|
||||
"record_id": "ok",
|
||||
"status": "success",
|
||||
"matched": True,
|
||||
"write_ok": True,
|
||||
}],
|
||||
})
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(retry.subprocess, "call", fake_call)
|
||||
|
||||
assert retry.run_one_style(
|
||||
"pgy", 1, max_attempts=1, record_ids=["ok"],
|
||||
style=style, v2_dir=tmp_path,
|
||||
) is True
|
||||
|
||||
|
||||
def test_self_operated_xingtu_retry_uses_self_douyin_scraper(monkeypatch, tmp_path):
|
||||
style = {"index": 1, "name": "款式A"}
|
||||
result_path = tmp_path / retry.canonical_result_filename(style, "xt", True)
|
||||
observed = {}
|
||||
|
||||
def fake_call(cmd, cwd):
|
||||
observed["cmd"] = cmd
|
||||
_write_json(result_path, {
|
||||
"index": 1,
|
||||
"total": 1,
|
||||
"results": [{"record_id": "ok", "status": "success", "matched": True}],
|
||||
})
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(retry.subprocess, "call", fake_call)
|
||||
|
||||
assert retry.run_one_style(
|
||||
"xt", 1, max_attempts=1, self_operated=True,
|
||||
record_ids=["ok"], style=style, v2_dir=tmp_path,
|
||||
) is True
|
||||
assert observed["cmd"][1].endswith("self_douyin_scraper.py")
|
||||
assert "--self-operated" not in observed["cmd"]
|
||||
|
||||
|
||||
def test_full_summary_declared_failure_cannot_be_treated_as_complete():
|
||||
complete, reason = retry._summary_completed({
|
||||
"total": 1,
|
||||
"results": [{
|
||||
"record_id": "ok",
|
||||
"status": "success",
|
||||
"matched": True,
|
||||
"write_ok": True,
|
||||
}],
|
||||
"write_failures": 1,
|
||||
})
|
||||
|
||||
assert complete is False
|
||||
assert reason == "summary declares unresolved failures"
|
||||
|
||||
|
||||
def test_retry_main_returns_nonzero_when_verified_retry_still_fails(monkeypatch):
|
||||
style = {"index": 1, "name": "款式A"}
|
||||
monkeypatch.setattr(retry, "load_current_styles", lambda self_operated=False: [style])
|
||||
monkeypatch.setattr(
|
||||
retry, "find_failed",
|
||||
lambda *_args, **_kwargs: {1: {"reason": "HALF", "failed_rids": ["bad"]}},
|
||||
)
|
||||
monkeypatch.setattr(retry, "run_one_style", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(retry.sys, "argv", ["retry_failed.py", "--platform", "pgy"])
|
||||
|
||||
assert retry.main() == 1
|
||||
|
||||
|
||||
def test_validate_summary_payload_rejects_stale_missing_duplicate_and_unresolved(tmp_path):
|
||||
path = tmp_path / "summary.json"
|
||||
valid = [{
|
||||
"index": 1,
|
||||
"run_started_at": time.time() + 10,
|
||||
"total": 2,
|
||||
"matched": 2,
|
||||
"filled": 2,
|
||||
"results": [
|
||||
{"record_id": "a", "status": "success", "matched": True},
|
||||
{"record_id": "b", "status": "success", "matched": True},
|
||||
],
|
||||
}]
|
||||
_write_json(path, valid)
|
||||
|
||||
assert run_all.validate_summary_payload(
|
||||
valid, {1}, path, path.stat().st_mtime - 1,
|
||||
)[0] is True
|
||||
assert run_all.validate_summary_payload(
|
||||
valid, {1}, path, path.stat().st_mtime + 1,
|
||||
)[0] is False
|
||||
assert run_all.validate_summary_payload(valid, {1, 2}, path, 0)[0] is False
|
||||
|
||||
duplicate = [{
|
||||
"index": 1, "total": 2,
|
||||
"results": [
|
||||
{"record_id": "a", "status": "success"},
|
||||
{"record_id": "a", "status": "success"},
|
||||
],
|
||||
}]
|
||||
assert run_all.validate_summary_payload(duplicate, {1}, path, 0)[0] is False
|
||||
|
||||
unresolved = [{
|
||||
"index": 1, "total": 1,
|
||||
"results": [{
|
||||
"record_id": "a", "status": "retryable_failure", "matched": False,
|
||||
}],
|
||||
}]
|
||||
assert run_all.validate_summary_payload(unresolved, {1}, path, 0)[0] is False
|
||||
|
||||
|
||||
def test_validate_selected_style_allows_unrelated_rows_in_merged_global_summary(tmp_path):
|
||||
path = tmp_path / "summary.json"
|
||||
payload = [
|
||||
{
|
||||
"index": 1,
|
||||
"run_started_at": time.time() + 10,
|
||||
"total": 1,
|
||||
"results": [{"record_id": "ok", "status": "success"}],
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"total": 1,
|
||||
"results": [{
|
||||
"record_id": "old-failure",
|
||||
"status": "retryable_failure",
|
||||
}],
|
||||
},
|
||||
]
|
||||
_write_json(path, payload)
|
||||
|
||||
valid, reason = run_all.validate_summary_payload(
|
||||
payload, {1}, path, path.stat().st_mtime - 1,
|
||||
)
|
||||
|
||||
assert valid is True, reason
|
||||
|
||||
|
||||
def test_validate_summary_rejects_expected_style_left_over_from_old_run(tmp_path):
|
||||
path = tmp_path / "summary.json"
|
||||
started_at = 100.0
|
||||
payload = [
|
||||
{
|
||||
"index": 1,
|
||||
"run_started_at": 101.0,
|
||||
"total": 1,
|
||||
"results": [{"record_id": "new", "status": "success"}],
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"run_started_at": 90.0,
|
||||
"total": 1,
|
||||
"results": [{"record_id": "old", "status": "success"}],
|
||||
},
|
||||
]
|
||||
_write_json(path, payload)
|
||||
|
||||
valid, reason = run_all.validate_summary_payload(
|
||||
payload, {1, 2}, path, started_at,
|
||||
)
|
||||
|
||||
assert valid is False
|
||||
assert "style 2" in reason
|
||||
assert "current run" in reason
|
||||
|
||||
|
||||
def test_aggregate_counts_all_unresolved_rows_as_errors():
|
||||
aggregate = run_all.aggregate({
|
||||
"pgy": [{
|
||||
"index": 1,
|
||||
"total": 3,
|
||||
"matched": 1,
|
||||
"filled": 1,
|
||||
"results": [
|
||||
{"record_id": "ok", "status": "success"},
|
||||
{"record_id": "miss", "matched": False},
|
||||
{"record_id": "detail", "reason": "no_detail_page"},
|
||||
],
|
||||
}],
|
||||
})
|
||||
|
||||
assert aggregate["platforms"]["pgy"]["unresolved"] == 2
|
||||
assert aggregate["platforms"]["pgy"]["errors"] == 2
|
||||
|
||||
|
||||
def test_aggregate_sums_declared_failure_categories_when_unresolved_is_absent():
|
||||
aggregate = run_all.aggregate({
|
||||
"xt": [{
|
||||
"index": 1,
|
||||
"total": 2,
|
||||
"matched": 2,
|
||||
"results": [
|
||||
{"record_id": "a", "status": "success"},
|
||||
{"record_id": "b", "status": "success"},
|
||||
],
|
||||
"retryable_failures": 1,
|
||||
"write_failures": 1,
|
||||
}],
|
||||
})
|
||||
|
||||
assert aggregate["platforms"]["xt"]["unresolved"] == 2
|
||||
|
||||
|
||||
def test_aggregate_keeps_partial_counts_when_validation_marks_payload_invalid():
|
||||
aggregate = run_all.aggregate({
|
||||
"pgy": {
|
||||
"error": "invalid summary: unresolved",
|
||||
"payload": [{
|
||||
"index": 1,
|
||||
"total": 1,
|
||||
"matched": 0,
|
||||
"results": [{
|
||||
"record_id": "bad", "status": "retryable_failure",
|
||||
}],
|
||||
}],
|
||||
},
|
||||
})
|
||||
|
||||
platform = aggregate["platforms"]["pgy"]
|
||||
assert platform["loaded"] is True
|
||||
assert platform["unresolved"] == 1
|
||||
assert platform["validation_error"] == "invalid summary: unresolved"
|
||||
|
||||
|
||||
def test_run_round_marks_exit_zero_platform_failed_when_summary_is_invalid(monkeypatch, tmp_path):
|
||||
args = SimpleNamespace(dry_run=True, style=[1])
|
||||
stdout = tmp_path / "stdout.log"
|
||||
stderr = tmp_path / "stderr.log"
|
||||
observed = {}
|
||||
|
||||
monkeypatch.setattr(
|
||||
run_all, "start_process",
|
||||
lambda *_args, **_kwargs: (object(), stdout, stderr),
|
||||
)
|
||||
monkeypatch.setattr(run_all, "wait_all", lambda _procs: {"pgy": 0})
|
||||
|
||||
def fake_load(key, self_operated=False, **kwargs):
|
||||
observed.update(kwargs)
|
||||
return {"error": "invalid summary: stale"}
|
||||
|
||||
monkeypatch.setattr(run_all, "load_summary", fake_load)
|
||||
monkeypatch.setattr(run_all, "print_report", lambda *_args: None)
|
||||
monkeypatch.setattr(run_all, "_atomic_write_json", lambda *_args: None)
|
||||
|
||||
result = run_all.run_round(args, ["pgy"])
|
||||
|
||||
assert result["exit_codes"] == {"pgy": 0}
|
||||
assert result["_non_zero_platforms"] == ["pgy"]
|
||||
assert observed["expected_indices"] == {1}
|
||||
assert isinstance(observed["started_at"], float)
|
||||
@@ -1,55 +0,0 @@
|
||||
import unittest
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime.data.tools.sync_style_categories import extract_style_mapping, normalize_style_name
|
||||
|
||||
|
||||
class SyncStyleCategoriesTests(unittest.TestCase):
|
||||
def test_normalizes_feishu_lifecycle_names_to_database_names(self):
|
||||
cases = {
|
||||
"盖亚微单Pro生命进程(新)": "盖亚微单",
|
||||
"星迹&星迹2生命进程(新)": "星迹2",
|
||||
"宙斯双肩包生命进程(新)": "宙斯",
|
||||
"极星Pro生命进程(新)": "极星pro",
|
||||
"瑞白双肩包生命进程(新)": "瑞白",
|
||||
"逐星gt生命进程(新)": "逐星GT",
|
||||
"拾影相机包生命进程 (新)": "拾影斜挎相机包",
|
||||
"极星托特生命进程(新)": "极星托特",
|
||||
}
|
||||
for source, expected in cases.items():
|
||||
with self.subTest(source=source):
|
||||
self.assertEqual(normalize_style_name(source), expected)
|
||||
|
||||
def test_extracts_mapping_and_ignores_blank_rows(self):
|
||||
payload = {
|
||||
"data": {
|
||||
"data": [
|
||||
[
|
||||
"[盖亚斜挎生命进程(新)](https://example.feishu.cn/base/TokenA)",
|
||||
["都市机能"],
|
||||
],
|
||||
[None, ["都市运动"]],
|
||||
["[极星托特生命进程(新)](https://example.feishu.cn/base/TokenB)", ["智性通勤"]],
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
self.assertEqual(
|
||||
extract_style_mapping(payload),
|
||||
{"盖亚斜挎": "都市机能", "极星托特": "智性通勤"},
|
||||
)
|
||||
|
||||
def test_rejects_conflicting_categories_for_same_style(self):
|
||||
payload = {
|
||||
"data": {
|
||||
"data": [
|
||||
["[布谷生命进程(新)](https://example/a)", ["都市运动"]],
|
||||
["[布谷生命进程(新)](https://example/b)", ["都市机能"]],
|
||||
]
|
||||
}
|
||||
}
|
||||
with self.assertRaisesRegex(ValueError, "布谷"):
|
||||
extract_style_mapping(payload)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,70 +0,0 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from gyxx_flow.modules.content_marketing.runtime import weekly_summary_all as weekly
|
||||
|
||||
|
||||
class WeeklyTmallPersonaTests(unittest.TestCase):
|
||||
def test_missing_persona_forbids_invented_percentages(self):
|
||||
text = weekly.format_tmall_persona(None)
|
||||
self.assertIn("不得虚构具体比例", text)
|
||||
|
||||
def test_format_tmall_persona_includes_date_and_dimensions(self):
|
||||
persona = {
|
||||
"collect_date": "2026-07-15",
|
||||
"item_id": "900176117674",
|
||||
"payload": {
|
||||
"gender": [
|
||||
{"name": "男性用户", "value": 66.97},
|
||||
{"name": "女性用户", "value": 28.26},
|
||||
],
|
||||
"age": [{"name": "25-29岁", "value": 26.97}],
|
||||
"city_tier": [{"name": "准一线城市", "value": 31.14}],
|
||||
"buying_power": [{"name": "购买力L5", "value": 30.65}],
|
||||
},
|
||||
}
|
||||
|
||||
text = weekly.format_tmall_persona(persona)
|
||||
|
||||
self.assertIn("2026-07-15", text)
|
||||
self.assertIn("男性用户 66.97%", text)
|
||||
self.assertIn("25-29岁 26.97%", text)
|
||||
self.assertIn("准一线城市 31.14%", text)
|
||||
self.assertIn("购买力L5 30.65%", text)
|
||||
|
||||
def test_generate_summary_injects_latest_tmall_persona_into_single_note_prompt(self):
|
||||
persona = {
|
||||
"collect_date": "2026-07-15",
|
||||
"item_id": "900176117674",
|
||||
"payload": {
|
||||
"gender": [{"name": "男性用户", "value": 66.97}],
|
||||
"age": [{"name": "25-29岁", "value": 26.97}],
|
||||
"city_tier": [],
|
||||
"buying_power": [],
|
||||
},
|
||||
}
|
||||
notes = [{
|
||||
"title": "测试笔记",
|
||||
"platform": "douyin",
|
||||
"creator_name": "达人甲",
|
||||
"view_count": 100,
|
||||
"like_count": 10,
|
||||
"comment_count": 2,
|
||||
"favorite_count": 3,
|
||||
"share_count": 1,
|
||||
"report_path": None,
|
||||
}]
|
||||
|
||||
with patch.object(weekly, "get_latest_tmall_persona", return_value=persona) as get_persona, \
|
||||
patch.object(weekly, "call_hermes_analyzer", side_effect=["单篇分析", "## 三、周度总结"]) as call:
|
||||
weekly.generate_summary("星云2", notes)
|
||||
|
||||
get_persona.assert_called_once_with("星云2")
|
||||
single_prompt = call.call_args_list[0].args[1]
|
||||
self.assertIn("天猫该款最新人物画像", single_prompt)
|
||||
self.assertIn("男性用户 66.97%", single_prompt)
|
||||
self.assertIn("内容触达人群", single_prompt)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user