feat: complete production workflow migration

This commit is contained in:
2026-08-06 14:29:57 +08:00
parent 7f215e79c4
commit 8df5266abb
448 changed files with 56937 additions and 14619 deletions
+111 -19
View File
@@ -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:
+20
View File
@@ -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",
]
+690
View File
@@ -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",
]
+569 -52
View File
@@ -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:
+57 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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)
+88 -36
View File
@@ -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)
+25
View File
@@ -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:]
)
+1 -2
View File
@@ -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",
@@ -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
@@ -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:
@@ -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
@@ -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,
@@ -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=
@@ -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 汇总文件)"
@@ -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.jsargs 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,
@@ -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
@@ -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:
@@ -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
@@ -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"},
@@ -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]:
@@ -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
@@ -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 = []
@@ -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
@@ -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:
@@ -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
@@ -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
@@ -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]
@@ -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
@@ -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:
@@ -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", "盖亚斜挎"),
@@ -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())
@@ -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"
@@ -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"
@@ -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"
@@ -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"
@@ -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
@@ -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")
@@ -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 (
@@ -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,
)
@@ -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
@@ -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())
# ============================================================
@@ -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"
@@ -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):
@@ -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.
@@ -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
@@ -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("无法获取款式对照表(动态读取失败且无缓存)")
@@ -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:
@@ -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("""
@@ -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
@@ -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', '?')
@@ -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%
@@ -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
@@ -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
@@ -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
@@ -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()
@@ -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 = """![全域种草数据看板](charts/dashboard.png)
第一部分 整体经营分析
总销量下降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()
@@ -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()
@@ -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()
@@ -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