Compare commits
22 Commits
dev
...
d863b5144a
| Author | SHA1 | Date | |
|---|---|---|---|
| d863b5144a | |||
| b92f8d8914 | |||
| 4c12e8bb0c | |||
| c17c408f52 | |||
| ef62b8e94a | |||
| 2a75537fb9 | |||
| bc2e93353a | |||
| e93e68db7e | |||
| a2468a2a2c | |||
| 59824940c6 | |||
| 70ac1ce73e | |||
| 81e69865ce | |||
| 418c4d6192 | |||
| 5956ea36a8 | |||
| 93c8d00af6 | |||
| 08f8196bf9 | |||
| b963cf07df | |||
| 849bf97c97 | |||
| 145e7de7f4 | |||
| 27296caf69 | |||
| 9b72c3128e | |||
| f5ef74bda6 |
@@ -122,9 +122,29 @@ jobs:
|
|||||||
-summary \
|
-summary \
|
||||||
"${manifests[@]}"
|
"${manifests[@]}"
|
||||||
|
|
||||||
|
lint-audit:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Audit Python dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
python:3.12-slim \
|
||||||
|
sh -lc '
|
||||||
|
pip install --quiet pip-audit pip-tools &&
|
||||||
|
pip-compile --quiet --strip-extras --output-file /tmp/reqs.txt pyproject.toml &&
|
||||||
|
pip-audit -r /tmp/reqs.txt
|
||||||
|
'
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
|
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
|
||||||
runs-on: [self-hosted, linux, arch, homelab]
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
@@ -122,9 +122,29 @@ jobs:
|
|||||||
-summary \
|
-summary \
|
||||||
"${manifests[@]}"
|
"${manifests[@]}"
|
||||||
|
|
||||||
|
lint-audit:
|
||||||
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
|
continue-on-error: true
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Audit Python dependencies
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
docker run --rm \
|
||||||
|
-v "$PWD:/work" \
|
||||||
|
-w /work \
|
||||||
|
python:3.12-slim \
|
||||||
|
sh -lc '
|
||||||
|
pip install --quiet pip-audit pip-tools &&
|
||||||
|
pip-compile --quiet --strip-extras --output-file /tmp/reqs.txt pyproject.toml &&
|
||||||
|
pip-audit -r /tmp/reqs.txt
|
||||||
|
'
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
|
needs: [lint-prettier, lint-ruff, lint-yaml, lint-dockerfiles, validate]
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request' && (github.ref_name == 'main' || github.ref_name == 'dev')
|
||||||
runs-on: [self-hosted, linux, arch, homelab]
|
runs-on: [self-hosted, linux, arch, homelab]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
|
|||||||
+151
-42
@@ -1,22 +1,25 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# ── defaults ──────────────────────────────────────────────────────────
|
# ── defaults ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
GLOBAL_DEFAULTS: Dict[str, Any] = {
|
GLOBAL_DEFAULTS: dict[str, Any] = {
|
||||||
"accounts": [],
|
"accounts": [],
|
||||||
"version": 2,
|
"version": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
ACCOUNT_DEFAULTS: Dict[str, Any] = {
|
ACCOUNT_DEFAULTS: dict[str, Any] = {
|
||||||
"label": "",
|
"label": "",
|
||||||
"api_id": None,
|
"api_id": None,
|
||||||
"api_hash": None,
|
"api_hash": None,
|
||||||
@@ -38,66 +41,125 @@ ACCOUNT_DEFAULTS: Dict[str, Any] = {
|
|||||||
class StateStore:
|
class StateStore:
|
||||||
"""Thread-safe JSON state store with TTL cache."""
|
"""Thread-safe JSON state store with TTL cache."""
|
||||||
|
|
||||||
def __init__(self, path: Path, defaults: Optional[Dict[str, Any]] = None):
|
def __init__(self, path: Path, defaults: dict[str, Any] | None = None):
|
||||||
self.path = path
|
self.path = path
|
||||||
self.defaults = defaults or {}
|
self.defaults = defaults or {}
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self._cache: Optional[Dict[str, Any]] = None
|
self._cache: dict[str, Any] | None = None
|
||||||
self._cache_time: float = 0
|
self._cache_time: float = 0
|
||||||
self._cache_ttl: float = 1.0
|
self._cache_ttl: float = 1.0
|
||||||
|
|
||||||
def load(self) -> Dict[str, Any]:
|
def load(self) -> dict[str, Any]:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if self._cache is not None and (now - self._cache_time) < self._cache_ttl:
|
if self._cache is not None and (now - self._cache_time) < self._cache_ttl:
|
||||||
return dict(self._cache)
|
return deepcopy(self._cache)
|
||||||
if not self.path.exists():
|
if not self.path.exists():
|
||||||
result = deepcopy(self.defaults)
|
result = deepcopy(self.defaults)
|
||||||
self._cache = result
|
self._cache = result
|
||||||
self._cache_time = now
|
self._cache_time = now
|
||||||
return result
|
return deepcopy(result)
|
||||||
try:
|
try:
|
||||||
with self.path.open("r", encoding="utf-8") as handle:
|
with self.path.open("r", encoding="utf-8") as handle:
|
||||||
state: Dict[str, Any] = json.load(handle)
|
state: dict[str, Any] = json.load(handle)
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
result = deepcopy(self.defaults)
|
result = deepcopy(self.defaults)
|
||||||
self._cache = result
|
self._cache = result
|
||||||
self._cache_time = now
|
self._cache_time = now
|
||||||
return result
|
return deepcopy(result)
|
||||||
result = self._merge_defaults(state)
|
result = self._merge_defaults(state)
|
||||||
self._cache = result
|
self._cache = result
|
||||||
self._cache_time = now
|
self._cache_time = now
|
||||||
return result
|
return deepcopy(result)
|
||||||
|
|
||||||
def save(self, state: Dict[str, Any]) -> None:
|
def save(self, state: dict[str, Any]) -> None:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
tmp_path = self.path.with_suffix(self.path.suffix + ".tmp")
|
|
||||||
merged = self._merge_defaults(state)
|
merged = self._merge_defaults(state)
|
||||||
with tmp_path.open("w", encoding="utf-8") as handle:
|
|
||||||
|
# Unique temp file so two writers to the same path cannot clobber
|
||||||
|
# each other's in-progress file, plus fsync before atomic rename so
|
||||||
|
# a power loss cannot leave an empty/corrupt state file behind.
|
||||||
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
|
dir=str(self.path.parent),
|
||||||
|
prefix=self.path.name + ".tmp-",
|
||||||
|
suffix=".tmp",
|
||||||
|
)
|
||||||
|
tmp_path = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
json.dump(merged, handle, ensure_ascii=False, indent=2)
|
json.dump(merged, handle, ensure_ascii=False, indent=2)
|
||||||
handle.write("\n")
|
handle.write("\n")
|
||||||
tmp_path.replace(self.path)
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
# Restrict permissions on the state file to the owning user.
|
||||||
|
try:
|
||||||
|
os.chmod(tmp_path, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
os.replace(tmp_path, self.path)
|
||||||
|
finally:
|
||||||
|
# Ensure no leftover stale temp file if something went wrong.
|
||||||
|
if tmp_path.exists():
|
||||||
|
try:
|
||||||
|
tmp_path.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Best-effort directory fsync for full durability (POSIX only).
|
||||||
|
try:
|
||||||
|
dir_fd = os.open(str(self.path.parent), os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(dir_fd)
|
||||||
|
finally:
|
||||||
|
os.close(dir_fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# After a successful save, best-effort sweep leftover stale tmp
|
||||||
|
# files (e.g. from a crashed writer) older than an hour. Throttled
|
||||||
|
# to avoid scanning the dir on every save.
|
||||||
|
self._sweep_stale_tmp(now=time.time())
|
||||||
|
|
||||||
self._cache = None
|
self._cache = None
|
||||||
|
|
||||||
def update(self, mutator: Callable[[Dict[str, Any]], None]) -> Dict[str, Any]:
|
_STALE_TMP_MAX_AGE = 3600.0 # 1 hour
|
||||||
|
_STALE_TMP_SWEEP_INTERVAL = 60.0
|
||||||
|
_stale_sweep_last: float = 0.0
|
||||||
|
|
||||||
|
def _sweep_stale_tmp(self, now: float) -> None:
|
||||||
|
if (now - self._stale_sweep_last) < self._STALE_TMP_SWEEP_INTERVAL:
|
||||||
|
return
|
||||||
|
self._stale_sweep_last = now
|
||||||
|
try:
|
||||||
|
cutoff = now - self._STALE_TMP_MAX_AGE
|
||||||
|
for stale in self.path.parent.glob(self.path.name + ".tmp-*.tmp"):
|
||||||
|
try:
|
||||||
|
if stale.stat().st_mtime < cutoff:
|
||||||
|
stale.unlink()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def update(self, mutator: Callable[[dict[str, Any]], None]) -> dict[str, Any]:
|
||||||
with self.lock:
|
with self.lock:
|
||||||
state = self.load()
|
state = self.load()
|
||||||
mutator(state)
|
mutator(state)
|
||||||
self.save(state)
|
self.save(state)
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def continuous_config(self) -> Dict[str, Any]:
|
def continuous_config(self) -> dict[str, Any]:
|
||||||
state = self.load()
|
state = self.load()
|
||||||
return deepcopy(state.get("continuous_scraping") or ACCOUNT_DEFAULTS["continuous_scraping"])
|
return deepcopy(state.get("continuous_scraping") or ACCOUNT_DEFAULTS["continuous_scraping"])
|
||||||
|
|
||||||
def save_continuous_config(self, config: Dict[str, Any]) -> Dict[str, Any]:
|
def save_continuous_config(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
def mutate(state: Dict[str, Any]) -> None:
|
def mutate(state: dict[str, Any]) -> None:
|
||||||
state["continuous_scraping"] = {
|
state["continuous_scraping"] = {
|
||||||
"enabled": bool(config.get("enabled", True)),
|
"enabled": bool(config.get("enabled", True)),
|
||||||
"interval_minutes": max(1, int(config.get("interval_minutes", 1) or 1)),
|
"interval_minutes": max(1, int(config.get("interval_minutes", 1) or 1)),
|
||||||
"channels": [
|
"channels": [
|
||||||
str(item).strip()
|
str(item).strip().lstrip("@")
|
||||||
for item in config.get("channels", [])
|
for item in config.get("channels", [])
|
||||||
if str(item).strip()
|
if str(item).strip()
|
||||||
],
|
],
|
||||||
@@ -106,7 +168,7 @@ class StateStore:
|
|||||||
|
|
||||||
return self.update(mutate)["continuous_scraping"]
|
return self.update(mutate)["continuous_scraping"]
|
||||||
|
|
||||||
def _merge_defaults(self, state: Dict[str, Any]) -> Dict[str, Any]:
|
def _merge_defaults(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||||
merged = deepcopy(self.defaults)
|
merged = deepcopy(self.defaults)
|
||||||
for key, value in state.items():
|
for key, value in state.items():
|
||||||
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
||||||
@@ -120,25 +182,37 @@ class StateStore:
|
|||||||
|
|
||||||
# ── global state helpers ──────────────────────────────────────────────
|
# ── global state helpers ──────────────────────────────────────────────
|
||||||
|
|
||||||
_GLOBAL_STORE: Optional[StateStore] = None
|
_GLOBAL_STORE: StateStore | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_global_store(data_dir: Path) -> StateStore:
|
def get_global_store(data_dir: Path) -> StateStore:
|
||||||
|
"""Return the process-wide global state store singleton.
|
||||||
|
|
||||||
|
The store is cached in a module-global and the SAME instance is returned
|
||||||
|
for the same process, regardless of how many times this is called with the
|
||||||
|
same (or any) data_dir. webui_server's ``STATE_STORE`` should delegate to
|
||||||
|
this function so there is exactly one authoritative global store per
|
||||||
|
process rather than a second, potentially divergent instance.
|
||||||
|
|
||||||
|
Backward-compat note: callers that cache their own ``_GLOBAL_STORE=None``
|
||||||
|
sentinel (e.g. tests resetting state between cases) still work because we
|
||||||
|
re-create the singleton lazily on first call.
|
||||||
|
"""
|
||||||
global _GLOBAL_STORE
|
global _GLOBAL_STORE
|
||||||
if _GLOBAL_STORE is None:
|
if _GLOBAL_STORE is None:
|
||||||
_GLOBAL_STORE = StateStore(data_dir / "state.json", defaults=GLOBAL_DEFAULTS)
|
_GLOBAL_STORE = StateStore(data_dir / "state.json", defaults=GLOBAL_DEFAULTS)
|
||||||
return _GLOBAL_STORE
|
return _GLOBAL_STORE
|
||||||
|
|
||||||
|
|
||||||
def load_global(data_dir: Path) -> Dict[str, Any]:
|
def load_global(data_dir: Path) -> dict[str, Any]:
|
||||||
return get_global_store(data_dir).load()
|
return get_global_store(data_dir).load()
|
||||||
|
|
||||||
|
|
||||||
def save_global(data_dir: Path, state: Dict[str, Any]) -> None:
|
def save_global(data_dir: Path, state: dict[str, Any]) -> None:
|
||||||
get_global_store(data_dir).save(state)
|
get_global_store(data_dir).save(state)
|
||||||
|
|
||||||
|
|
||||||
def list_accounts(data_dir: Path) -> List[str]:
|
def list_accounts(data_dir: Path) -> list[str]:
|
||||||
return list(load_global(data_dir).get("accounts", []))
|
return list(load_global(data_dir).get("accounts", []))
|
||||||
|
|
||||||
|
|
||||||
@@ -148,7 +222,7 @@ def account_exists(data_dir: Path, account_id: str) -> bool:
|
|||||||
|
|
||||||
# ── per-account state helpers ─────────────────────────────────────────
|
# ── per-account state helpers ─────────────────────────────────────────
|
||||||
|
|
||||||
_ACCOUNT_STORES: Dict[str, StateStore] = {}
|
_ACCOUNT_STORES: dict[str, StateStore] = {}
|
||||||
_account_stores_lock = threading.Lock()
|
_account_stores_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
@@ -165,11 +239,11 @@ def get_account_store(data_dir: Path, account_id: str) -> StateStore:
|
|||||||
return _ACCOUNT_STORES[key]
|
return _ACCOUNT_STORES[key]
|
||||||
|
|
||||||
|
|
||||||
def load_account(data_dir: Path, account_id: str) -> Dict[str, Any]:
|
def load_account(data_dir: Path, account_id: str) -> dict[str, Any]:
|
||||||
return get_account_store(data_dir, account_id).load()
|
return get_account_store(data_dir, account_id).load()
|
||||||
|
|
||||||
|
|
||||||
def save_account(data_dir: Path, account_id: str, state: Dict[str, Any]) -> None:
|
def save_account(data_dir: Path, account_id: str, state: dict[str, Any]) -> None:
|
||||||
get_account_store(data_dir, account_id).save(state)
|
get_account_store(data_dir, account_id).save(state)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,13 +260,39 @@ def account_session_path(session_dir: Path, account_id: str) -> str:
|
|||||||
# ── MIGRATION (with data copy) ─────────────────────────────────────────
|
# ── MIGRATION (with data copy) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _is_valid_channel_id(channel_id: str) -> bool:
|
||||||
|
"""Path-safe channel id check.
|
||||||
|
|
||||||
|
Mirrors webui_server.normalize_channel_id's validation: reject entries
|
||||||
|
containing ``/`` or ``\\``, control chars, ``.``/``..``, empty; keep
|
||||||
|
numbers and plain names. Defined locally (not imported from webui_server,
|
||||||
|
which would be circular) so it can be shared by migration.
|
||||||
|
"""
|
||||||
|
channel_id = str(channel_id or "").strip()
|
||||||
|
return not (not channel_id or "/" in channel_id or "\\" in channel_id or channel_id in {".", ".."} or any(ord(ch) < 32 for ch in channel_id))
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_continuous_channels(channels: Any) -> list[str]:
|
||||||
|
"""Normalize/drop invalid continuous-scraping channel entries during
|
||||||
|
migration. Mirrors webui_server.clean_continuous_channels: strips a
|
||||||
|
leading ``@``, keeps numbers/names, and drops unsafe entries so they can
|
||||||
|
never become a path-traversal vector.
|
||||||
|
"""
|
||||||
|
cleaned: list[str] = []
|
||||||
|
if not isinstance(channels, list):
|
||||||
|
return cleaned
|
||||||
|
for item in channels:
|
||||||
|
cleaned.append(str(item).strip().lstrip("@"))
|
||||||
|
return [c for c in cleaned if _is_valid_channel_id(c)]
|
||||||
|
|
||||||
|
|
||||||
def _copy_channel_data(src_root: Path, dst_root: Path, channel_id: str) -> None:
|
def _copy_channel_data(src_root: Path, dst_root: Path, channel_id: str) -> None:
|
||||||
"""Copy a single channel's DB + media from src_root to dst_root."""
|
"""Copy a single channel's DB + media from src_root to dst_root."""
|
||||||
src_ch = src_root / channel_id
|
src_ch = src_root / channel_id
|
||||||
dst_ch = dst_root / channel_id
|
dst_ch = dst_root / channel_id
|
||||||
if not src_ch.exists():
|
if not src_ch.exists():
|
||||||
return
|
return
|
||||||
dst_ch.mkdir(parents=True, exist_ok=True)
|
dst_ch.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
|
||||||
# SQLite DB
|
# SQLite DB
|
||||||
db_name = f"{channel_id}.db"
|
db_name = f"{channel_id}.db"
|
||||||
@@ -206,7 +306,7 @@ def _copy_channel_data(src_root: Path, dst_root: Path, channel_id: str) -> None:
|
|||||||
src_media = src_ch / "media"
|
src_media = src_ch / "media"
|
||||||
dst_media = dst_ch / "media"
|
dst_media = dst_ch / "media"
|
||||||
if src_media.exists() and src_media.is_dir():
|
if src_media.exists() and src_media.is_dir():
|
||||||
dst_media.mkdir(parents=True, exist_ok=True)
|
dst_media.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
for item in src_media.iterdir():
|
for item in src_media.iterdir():
|
||||||
if item.is_file():
|
if item.is_file():
|
||||||
dst_file = dst_media / item.name
|
dst_file = dst_media / item.name
|
||||||
@@ -235,7 +335,7 @@ def migrate_legacy_state(data_dir: Path, session_dir: Path) -> bool:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with state_path.open("r", encoding="utf-8") as f:
|
with state_path.open("r", encoding="utf-8") as f:
|
||||||
raw: Dict[str, Any] = json.load(f)
|
raw: dict[str, Any] = json.load(f)
|
||||||
except (json.JSONDecodeError, OSError):
|
except (json.JSONDecodeError, OSError):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -250,7 +350,24 @@ def migrate_legacy_state(data_dir: Path, session_dir: Path) -> bool:
|
|||||||
|
|
||||||
# ── 1. Create per-account state for "default" ──────────────────────
|
# ── 1. Create per-account state for "default" ──────────────────────
|
||||||
acc_dir = data_dir / "accounts" / "default"
|
acc_dir = data_dir / "accounts" / "default"
|
||||||
acc_dir.mkdir(parents=True, exist_ok=True)
|
acc_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
|
||||||
|
# Normalize/drop invalid continuous-scraping channel entries during
|
||||||
|
# migration so unsafe values can never become a path-traversal vector.
|
||||||
|
continuous_cfg = raw.get(
|
||||||
|
"continuous_scraping",
|
||||||
|
{
|
||||||
|
"enabled": True,
|
||||||
|
"interval_minutes": 1,
|
||||||
|
"channels": [],
|
||||||
|
"run_all_tracked": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if not isinstance(continuous_cfg, dict):
|
||||||
|
continuous_cfg = {}
|
||||||
|
continuous_cfg["channels"] = _clean_continuous_channels(
|
||||||
|
continuous_cfg.get("channels")
|
||||||
|
)
|
||||||
|
|
||||||
acc_state = {
|
acc_state = {
|
||||||
"label": "Default",
|
"label": "Default",
|
||||||
@@ -260,15 +377,7 @@ def migrate_legacy_state(data_dir: Path, session_dir: Path) -> bool:
|
|||||||
"channel_names": raw.get("channel_names", {}),
|
"channel_names": raw.get("channel_names", {}),
|
||||||
"scrape_media": raw.get("scrape_media", True),
|
"scrape_media": raw.get("scrape_media", True),
|
||||||
"forwarding_rules": raw.get("forwarding_rules", []),
|
"forwarding_rules": raw.get("forwarding_rules", []),
|
||||||
"continuous_scraping": raw.get(
|
"continuous_scraping": continuous_cfg,
|
||||||
"continuous_scraping",
|
|
||||||
{
|
|
||||||
"enabled": True,
|
|
||||||
"interval_minutes": 1,
|
|
||||||
"channels": [],
|
|
||||||
"run_all_tracked": True,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
acc_state_path = acc_dir / "state.json"
|
acc_state_path = acc_dir / "state.json"
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any
|
||||||
|
|
||||||
from app_state import StateStore
|
from app_state import StateStore, load_account
|
||||||
|
|
||||||
|
|
||||||
def health_payload(
|
def health_payload(
|
||||||
data_dir: Path,
|
data_dir: Path,
|
||||||
session_dir: Path,
|
session_dir: Path,
|
||||||
state_store: StateStore,
|
state_store: StateStore,
|
||||||
continuous_snapshot: Dict[str, Any],
|
continuous_snapshot: dict[str, Any],
|
||||||
job_queue_size: int,
|
job_queue_size: int,
|
||||||
account_ids: Optional[List[str]] = None,
|
account_ids: list[str] | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Return a health-check payload for the application.
|
Return a health-check payload for the application.
|
||||||
|
|
||||||
@@ -32,7 +32,7 @@ def health_payload(
|
|||||||
|
|
||||||
# Per-account health
|
# Per-account health
|
||||||
if account_ids:
|
if account_ids:
|
||||||
account_checks: Dict[str, Any] = {}
|
account_checks: dict[str, Any] = {}
|
||||||
for acc_id in account_ids:
|
for acc_id in account_ids:
|
||||||
acc_dir = data_dir / "accounts" / acc_id
|
acc_dir = data_dir / "accounts" / acc_id
|
||||||
session_file = session_dir / f"{acc_id}.session"
|
session_file = session_dir / f"{acc_id}.session"
|
||||||
@@ -55,10 +55,10 @@ def health_payload(
|
|||||||
return {"ok": ok, "status": "ok" if ok else "degraded", "checks": checks}
|
return {"ok": ok, "status": "ok" if ok else "degraded", "checks": checks}
|
||||||
|
|
||||||
|
|
||||||
def _dir_check(path: Path, writable: bool = False) -> Dict[str, Any]:
|
def _dir_check(path: Path, writable: bool = False) -> dict[str, Any]:
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
ok = path.exists() and path.is_dir()
|
ok = path.exists() and path.is_dir()
|
||||||
payload: Dict[str, Any] = {"ok": ok, "path": str(path)}
|
payload: dict[str, Any] = {"ok": ok, "path": str(path)}
|
||||||
if writable:
|
if writable:
|
||||||
probe = path / ".healthcheck"
|
probe = path / ".healthcheck"
|
||||||
try:
|
try:
|
||||||
@@ -70,9 +70,31 @@ def _dir_check(path: Path, writable: bool = False) -> Dict[str, Any]:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
def _state_check(state_store: StateStore) -> Dict[str, Any]:
|
def _state_check(state_store: StateStore) -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
state = state_store.load()
|
state = state_store.load()
|
||||||
|
data_dir = state_store.path.parent
|
||||||
|
accounts = state.get("accounts") or []
|
||||||
|
|
||||||
|
if accounts:
|
||||||
|
# Multi-account mode: the global store no longer holds api
|
||||||
|
# credentials / channels. Aggregate those from each account's own
|
||||||
|
# state file so the reported values are meaningful.
|
||||||
|
has_api_credentials = False
|
||||||
|
tracked_channels = 0
|
||||||
|
for acc_id in accounts:
|
||||||
|
acc = load_account(data_dir, acc_id)
|
||||||
|
if acc.get("api_id") and acc.get("api_hash"):
|
||||||
|
has_api_credentials = True
|
||||||
|
tracked_channels += len(acc.get("channels", {}) or {})
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"path": str(state_store.path),
|
||||||
|
"has_api_credentials": has_api_credentials,
|
||||||
|
"tracked_channels": tracked_channels,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Legacy single-account semantics (no accounts list).
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"path": str(state_store.path),
|
"path": str(state_store.path),
|
||||||
@@ -83,7 +105,7 @@ def _state_check(state_store: StateStore) -> Dict[str, Any]:
|
|||||||
return {"ok": False, "path": str(state_store.path), "error": str(exc)}
|
return {"ok": False, "path": str(state_store.path), "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
def _sqlite_check() -> Dict[str, Any]:
|
def _sqlite_check() -> dict[str, Any]:
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(":memory:")
|
conn = sqlite3.connect(":memory:")
|
||||||
conn.execute("SELECT 1")
|
conn.execute("SELECT 1")
|
||||||
@@ -93,7 +115,7 @@ def _sqlite_check() -> Dict[str, Any]:
|
|||||||
return {"ok": False, "error": str(exc)}
|
return {"ok": False, "error": str(exc)}
|
||||||
|
|
||||||
|
|
||||||
def _continuous_check(snapshot: Dict[str, Any]) -> Dict[str, Any]:
|
def _continuous_check(snapshot: dict[str, Any]) -> dict[str, Any]:
|
||||||
running_accounts = snapshot.get("running_accounts")
|
running_accounts = snapshot.get("running_accounts")
|
||||||
if running_accounts is None:
|
if running_accounts is None:
|
||||||
running_accounts = snapshot
|
running_accounts = snapshot
|
||||||
|
|||||||
@@ -31,13 +31,28 @@ spec:
|
|||||||
labels:
|
labels:
|
||||||
app: telegram-scraper
|
app: telegram-scraper
|
||||||
spec:
|
spec:
|
||||||
|
securityContext:
|
||||||
|
runAsNonRoot: true
|
||||||
|
runAsUser: 1000
|
||||||
|
runAsGroup: 1000
|
||||||
|
fsGroup: 1000
|
||||||
containers:
|
containers:
|
||||||
- name: telegram-scraper
|
- name: telegram-scraper
|
||||||
image: gcr.forust.xyz/forust/telegram-scraper:latest
|
image: gcr.forust.xyz/forust/telegram-scraper:latest
|
||||||
stdin: true
|
stdin: true
|
||||||
tty: true
|
tty: true
|
||||||
|
env:
|
||||||
|
- name: TRUSTED_HOSTS
|
||||||
|
value: 'tg.workstation.internal'
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8080
|
- containerPort: 8080
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
memory: 128Mi
|
||||||
|
cpu: 100m
|
||||||
|
limits:
|
||||||
|
memory: 512Mi
|
||||||
|
cpu: 1
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
|
|||||||
@@ -18,8 +18,9 @@ def main():
|
|||||||
# Run legacy migration before starting the server
|
# Run legacy migration before starting the server
|
||||||
from app_state import migrate_legacy_state
|
from app_state import migrate_legacy_state
|
||||||
|
|
||||||
data_dir = Path("data")
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
session_dir = Path("session")
|
data_dir = BASE_DIR / "data"
|
||||||
|
session_dir = BASE_DIR / "session"
|
||||||
try:
|
try:
|
||||||
if migrate_legacy_state(data_dir, session_dir):
|
if migrate_legacy_state(data_dir, session_dir):
|
||||||
logger.info("Legacy migration completed successfully.")
|
logger.info("Legacy migration completed successfully.")
|
||||||
|
|||||||
@@ -20,3 +20,6 @@ dependencies = [
|
|||||||
"Telethon==1.40.0",
|
"Telethon==1.40.0",
|
||||||
"yarl==1.20.1",
|
"yarl==1.20.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
pythonpath = ["."]
|
||||||
|
|||||||
+76
-14
@@ -1,34 +1,71 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app_state import StateStore
|
from app_state import StateStore
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def _run_in_new_loop(coro_factory):
|
||||||
|
"""Run an awaitable on a dedicated thread with its own event loop.
|
||||||
|
|
||||||
|
Returns the coroutine's result. This avoids ``asyncio.run()`` raising
|
||||||
|
``RuntimeError`` when the caller runs on a thread that already has a
|
||||||
|
running event loop (e.g. job threads, auth-loop threads).
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
error = {}
|
||||||
|
|
||||||
|
def runner():
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
asyncio.set_event_loop(loop)
|
||||||
|
result["value"] = loop.run_until_complete(coro_factory())
|
||||||
|
except BaseException as exc: # noqa: BLE001 - relay any failure
|
||||||
|
error["value"] = exc
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
loop.close()
|
||||||
|
finally:
|
||||||
|
asyncio.set_event_loop(None)
|
||||||
|
|
||||||
|
thread = threading.Thread(target=runner, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
thread.join()
|
||||||
|
if "value" in error:
|
||||||
|
raise error["value"]
|
||||||
|
return result.get("value")
|
||||||
|
|
||||||
|
|
||||||
class ScraperJobService:
|
class ScraperJobService:
|
||||||
def __init__(self, state_store: StateStore):
|
def __init__(self, state_store: StateStore):
|
||||||
self.state_store = state_store
|
self.state_store = state_store
|
||||||
|
|
||||||
def run(self, job_type: str, payload: Dict[str, Any]) -> None:
|
def run(self, job_type: str, payload: dict[str, Any]) -> None:
|
||||||
if job_type == "set_scrape_media":
|
if job_type == "set_scrape_media":
|
||||||
|
# The webui handler already persists the scrape_media setting to
|
||||||
|
# the per-account (or legacy) store before enqueueing this job.
|
||||||
|
# Multi-account mode has no single global change to make, so this
|
||||||
|
# is a passthrough that just records success to keep the
|
||||||
|
# job-status / SSE flow intact.
|
||||||
value = bool(payload["value"])
|
value = bool(payload["value"])
|
||||||
|
logger.info("Media scraping set to %s (already persisted by handler)", value)
|
||||||
def mutate(state: Dict[str, Any]) -> None:
|
|
||||||
state["scrape_media"] = value
|
|
||||||
|
|
||||||
self.state_store.update(mutate)
|
|
||||||
logger.info("Media scraping set to %s", value)
|
|
||||||
return
|
return
|
||||||
|
|
||||||
asyncio.run(self._run_async(job_type, payload))
|
# Run the async job on a fresh thread/event loop so we never hit
|
||||||
|
# "asyncio.run() cannot be called from a running event loop".
|
||||||
|
_run_in_new_loop(lambda: self._run_async(job_type, payload))
|
||||||
|
|
||||||
async def _run_async(self, job_type: str, payload: Dict[str, Any]) -> None:
|
async def _run_async(self, job_type: str, payload: dict[str, Any]) -> None:
|
||||||
# Extract account_id from payload, default to None (legacy)
|
# Extract account_id from payload, default to None (legacy)
|
||||||
account_id: Optional[str] = payload.get("account_id")
|
account_id: str | None = payload.get("account_id")
|
||||||
ScraperClass = self._import_scraper_class()
|
ScraperClass = self._import_scraper_class()
|
||||||
scraper = ScraperClass(account_id=account_id)
|
scraper = ScraperClass(account_id=account_id, base_dir=BASE_DIR)
|
||||||
|
|
||||||
if account_id:
|
if account_id:
|
||||||
from app_state import load_account
|
from app_state import load_account
|
||||||
@@ -72,10 +109,35 @@ class ScraperJobService:
|
|||||||
if scraper.client:
|
if scraper.client:
|
||||||
await scraper.client.disconnect()
|
await scraper.client.disconnect()
|
||||||
|
|
||||||
async def _scrape_channels(self, scraper, channels: List[str]) -> None:
|
async def _scrape_channels(self, scraper, channels: list[str]) -> None:
|
||||||
|
"""Scrape all channels resiliently: a single channel failure does not
|
||||||
|
abort the rest. Each channel's offset is persisted even on failure
|
||||||
|
(see scrape_channel's finally block), so partial progress is retained.
|
||||||
|
If *every* channel fails, raise so the job is marked failed.
|
||||||
|
"""
|
||||||
|
failed: list[str] = []
|
||||||
for channel_id in channels:
|
for channel_id in channels:
|
||||||
offset = int(scraper.state.get("channels", {}).get(channel_id, 0) or 0)
|
offset = int(scraper.state.get("channels", {}).get(channel_id, 0) or 0)
|
||||||
await scraper.scrape_channel(channel_id, offset)
|
try:
|
||||||
|
ok = await scraper.scrape_channel(channel_id, offset)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Scrape of channel %s raised", channel_id)
|
||||||
|
failed.append(channel_id)
|
||||||
|
continue
|
||||||
|
if not ok:
|
||||||
|
logger.warning("Scrape of channel %s reported failure", channel_id)
|
||||||
|
failed.append(channel_id)
|
||||||
|
if failed and len(failed) == len(channels):
|
||||||
|
raise RuntimeError(
|
||||||
|
"All scrape target(s) failed: " + ", ".join(failed)
|
||||||
|
)
|
||||||
|
if failed:
|
||||||
|
logger.warning(
|
||||||
|
"Partial scrape failure — %d/%d channel(s) failed: %s",
|
||||||
|
len(failed),
|
||||||
|
len(channels),
|
||||||
|
", ".join(failed),
|
||||||
|
)
|
||||||
|
|
||||||
def _import_scraper_class(self):
|
def _import_scraper_class(self):
|
||||||
from telegram_scraper_with_forwarding import OptimizedTelegramScraper
|
from telegram_scraper_with_forwarding import OptimizedTelegramScraper
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import sys
|
|||||||
import time
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
|
|
||||||
PORT = os.environ.get("TELEGRAM_SCRAPER_SMOKE_PORT", "18080")
|
PORT = os.environ.get("TELEGRAM_SCRAPER_SMOKE_PORT", "18080")
|
||||||
BASE_URL = f"http://127.0.0.1:{PORT}"
|
BASE_URL = f"http://127.0.0.1:{PORT}"
|
||||||
ENDPOINTS = [
|
ENDPOINTS = [
|
||||||
@@ -61,9 +60,7 @@ def main() -> int:
|
|||||||
if status != 200:
|
if status != 200:
|
||||||
raise RuntimeError(f"{endpoint} returned HTTP {status}")
|
raise RuntimeError(f"{endpoint} returned HTTP {status}")
|
||||||
if (
|
if (
|
||||||
endpoint.endswith(".json")
|
endpoint.endswith(".json") or endpoint.startswith(("/api", "/health"))
|
||||||
or endpoint.startswith("/api")
|
|
||||||
or endpoint.startswith("/health")
|
|
||||||
):
|
):
|
||||||
json.loads(body.decode("utf-8"))
|
json.loads(body.decode("utf-8"))
|
||||||
print(f"ok {endpoint}")
|
print(f"ok {endpoint}")
|
||||||
|
|||||||
+28
-27
@@ -1,27 +1,28 @@
|
|||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import json
|
|
||||||
import csv
|
|
||||||
import asyncio
|
|
||||||
import time
|
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import warnings
|
import warnings
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional, Any
|
|
||||||
from pathlib import Path
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import qrcode
|
||||||
from telethon import TelegramClient
|
from telethon import TelegramClient
|
||||||
|
from telethon.errors import FloodWaitError, SessionPasswordNeededError
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
MessageMediaPhoto,
|
|
||||||
MessageMediaDocument,
|
|
||||||
MessageMediaWebPage,
|
|
||||||
User,
|
|
||||||
PeerChannel,
|
|
||||||
Channel,
|
Channel,
|
||||||
Chat,
|
Chat,
|
||||||
|
MessageMediaDocument,
|
||||||
|
MessageMediaPhoto,
|
||||||
|
MessageMediaWebPage,
|
||||||
|
PeerChannel,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from telethon.errors import FloodWaitError, SessionPasswordNeededError
|
|
||||||
import qrcode
|
|
||||||
|
|
||||||
warnings.filterwarnings(
|
warnings.filterwarnings(
|
||||||
"ignore", message="Using async sessions support is an experimental feature"
|
"ignore", message="Using async sessions support is an experimental feature"
|
||||||
@@ -47,17 +48,17 @@ class MessageData:
|
|||||||
message_id: int
|
message_id: int
|
||||||
date: str
|
date: str
|
||||||
sender_id: int
|
sender_id: int
|
||||||
first_name: Optional[str]
|
first_name: str | None
|
||||||
last_name: Optional[str]
|
last_name: str | None
|
||||||
username: Optional[str]
|
username: str | None
|
||||||
message: str
|
message: str
|
||||||
media_type: Optional[str]
|
media_type: str | None
|
||||||
media_path: Optional[str]
|
media_path: str | None
|
||||||
reply_to: Optional[int]
|
reply_to: int | None
|
||||||
post_author: Optional[str]
|
post_author: str | None
|
||||||
views: Optional[int]
|
views: int | None
|
||||||
forwards: Optional[int]
|
forwards: int | None
|
||||||
reactions: Optional[str]
|
reactions: str | None
|
||||||
|
|
||||||
|
|
||||||
class OptimizedTelegramScraper:
|
class OptimizedTelegramScraper:
|
||||||
@@ -71,7 +72,7 @@ class OptimizedTelegramScraper:
|
|||||||
self.state_save_interval = 50
|
self.state_save_interval = 50
|
||||||
self.db_connections = {}
|
self.db_connections = {}
|
||||||
|
|
||||||
def load_state(self) -> Dict[str, Any]:
|
def load_state(self) -> dict[str, Any]:
|
||||||
if os.path.exists(self.STATE_FILE):
|
if os.path.exists(self.STATE_FILE):
|
||||||
try:
|
try:
|
||||||
with open(self.STATE_FILE, "r") as f:
|
with open(self.STATE_FILE, "r") as f:
|
||||||
@@ -148,7 +149,7 @@ class OptimizedTelegramScraper:
|
|||||||
conn.close()
|
conn.close()
|
||||||
self.db_connections.clear()
|
self.db_connections.clear()
|
||||||
|
|
||||||
def batch_insert_messages(self, channel: str, messages: List[MessageData]):
|
def batch_insert_messages(self, channel: str, messages: list[MessageData]):
|
||||||
if not messages:
|
if not messages:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -183,7 +184,7 @@ class OptimizedTelegramScraper:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def download_media(self, channel: str, message) -> Optional[str]:
|
async def download_media(self, channel: str, message) -> str | None:
|
||||||
if not message.media or not self.state["scrape_media"]:
|
if not message.media or not self.state["scrape_media"]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -701,7 +702,7 @@ class OptimizedTelegramScraper:
|
|||||||
async for dialog in self.client.iter_dialogs():
|
async for dialog in self.client.iter_dialogs():
|
||||||
entity = dialog.entity
|
entity = dialog.entity
|
||||||
if dialog.id != 777000 and (
|
if dialog.id != 777000 and (
|
||||||
isinstance(entity, Channel) or isinstance(entity, Chat)
|
isinstance(entity, (Channel, Chat))
|
||||||
):
|
):
|
||||||
channel_type = (
|
channel_type = (
|
||||||
"Channel"
|
"Channel"
|
||||||
|
|||||||
@@ -1,32 +1,38 @@
|
|||||||
import sqlite3
|
|
||||||
import json
|
|
||||||
import csv
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import csv
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import warnings
|
import warnings
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Dict, List, Optional, Any
|
|
||||||
from pathlib import Path
|
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import qrcode
|
||||||
from telethon import TelegramClient, events
|
from telethon import TelegramClient, events
|
||||||
|
from telethon.errors import FloodWaitError, SessionPasswordNeededError
|
||||||
from telethon.tl.types import (
|
from telethon.tl.types import (
|
||||||
MessageMediaPhoto,
|
|
||||||
MessageMediaDocument,
|
|
||||||
MessageMediaWebPage,
|
|
||||||
User,
|
|
||||||
PeerChannel,
|
|
||||||
Channel,
|
Channel,
|
||||||
Chat,
|
Chat,
|
||||||
|
MessageMediaDocument,
|
||||||
|
MessageMediaPhoto,
|
||||||
|
MessageMediaWebPage,
|
||||||
|
PeerChannel,
|
||||||
|
User,
|
||||||
)
|
)
|
||||||
from telethon.errors import FloodWaitError, SessionPasswordNeededError
|
|
||||||
import qrcode
|
|
||||||
from app_state import (
|
from app_state import (
|
||||||
StateStore,
|
StateStore,
|
||||||
account_session_path,
|
account_session_path,
|
||||||
get_account_store,
|
get_account_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
warnings.filterwarnings(
|
warnings.filterwarnings(
|
||||||
"ignore", message="Using async sessions support is an experimental feature"
|
"ignore", message="Using async sessions support is an experimental feature"
|
||||||
)
|
)
|
||||||
@@ -51,17 +57,17 @@ class MessageData:
|
|||||||
message_id: int
|
message_id: int
|
||||||
date: str
|
date: str
|
||||||
sender_id: int
|
sender_id: int
|
||||||
first_name: Optional[str]
|
first_name: str | None
|
||||||
last_name: Optional[str]
|
last_name: str | None
|
||||||
username: Optional[str]
|
username: str | None
|
||||||
message: str
|
message: str
|
||||||
media_type: Optional[str]
|
media_type: str | None
|
||||||
media_path: Optional[str]
|
media_path: str | None
|
||||||
reply_to: Optional[int]
|
reply_to: int | None
|
||||||
post_author: Optional[str]
|
post_author: str | None
|
||||||
views: Optional[int]
|
views: int | None
|
||||||
forwards: Optional[int]
|
forwards: int | None
|
||||||
reactions: Optional[str]
|
reactions: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -90,19 +96,21 @@ def _ensure_session_wal(session_path: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
class OptimizedTelegramScraper:
|
class OptimizedTelegramScraper:
|
||||||
def __init__(self, account_id: Optional[str] = None):
|
def __init__(self, account_id: str | None = None, base_dir: Path | None = None):
|
||||||
self.account_id = account_id
|
self.account_id = account_id
|
||||||
self.SESSION_DIR = Path("session")
|
base_dir = base_dir or BASE_DIR
|
||||||
self.SESSION_DIR.mkdir(exist_ok=True)
|
self.BASE_DIR = base_dir
|
||||||
|
self.SESSION_DIR = base_dir / "session"
|
||||||
|
self.SESSION_DIR.mkdir(exist_ok=True, mode=0o700)
|
||||||
|
|
||||||
if account_id:
|
if account_id:
|
||||||
self.DATA_DIR = Path("data") / "accounts" / account_id
|
self.DATA_DIR = base_dir / "data" / "accounts" / account_id
|
||||||
self.state_store = get_account_store(Path("data"), account_id)
|
self.state_store = get_account_store(base_dir / "data", account_id)
|
||||||
else:
|
else:
|
||||||
self.DATA_DIR = Path("data")
|
self.DATA_DIR = base_dir / "data"
|
||||||
self.state_store = StateStore(self.DATA_DIR / "state.json")
|
self.state_store = StateStore(self.DATA_DIR / "state.json")
|
||||||
|
|
||||||
self.DATA_DIR.mkdir(parents=True, exist_ok=True)
|
self.DATA_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
self.STATE_FILE = str(self.DATA_DIR / "state.json")
|
self.STATE_FILE = str(self.DATA_DIR / "state.json")
|
||||||
|
|
||||||
self.state = self.load_state()
|
self.state = self.load_state()
|
||||||
@@ -112,19 +120,37 @@ class OptimizedTelegramScraper:
|
|||||||
self.max_concurrent_downloads = 5
|
self.max_concurrent_downloads = 5
|
||||||
self.batch_size = 100
|
self.batch_size = 100
|
||||||
self.state_save_interval = 50
|
self.state_save_interval = 50
|
||||||
|
self.state_save_throttle_seconds = 5.0
|
||||||
|
self.last_state_save = None
|
||||||
self.db_connections = {}
|
self.db_connections = {}
|
||||||
self.forwarding_handler = None
|
self.forwarding_handler = None
|
||||||
|
|
||||||
def load_state(self) -> Dict[str, Any]:
|
def load_state(self) -> dict[str, Any]:
|
||||||
return self.state_store.load()
|
return self.state_store.load()
|
||||||
|
|
||||||
def save_state(self):
|
def save_state(self):
|
||||||
try:
|
try:
|
||||||
self.state_store.save(self.state)
|
self.state_store.save(self.state)
|
||||||
|
self.last_state_save = time.time()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Failed to save state: {e}")
|
print(f"Failed to save state: {e}")
|
||||||
|
|
||||||
def get_forwarding_rules(self) -> List[ForwardingRule]:
|
def _save_state_throttled(self):
|
||||||
|
"""Throttled intermediate state persistence.
|
||||||
|
|
||||||
|
Avoids rewriting the whole per-account JSON on every
|
||||||
|
``state_save_interval`` messages (which is far too chatty for long
|
||||||
|
channels). Skips saves that fall within the throttle window; the
|
||||||
|
final save at end-of-scrape always runs regardless.
|
||||||
|
"""
|
||||||
|
now = time.time()
|
||||||
|
if (
|
||||||
|
self.last_state_save is None
|
||||||
|
or (now - self.last_state_save) >= self.state_save_throttle_seconds
|
||||||
|
):
|
||||||
|
self.save_state()
|
||||||
|
|
||||||
|
def get_forwarding_rules(self) -> list[ForwardingRule]:
|
||||||
rules = []
|
rules = []
|
||||||
for rule_dict in self.state.get("forwarding_rules", []):
|
for rule_dict in self.state.get("forwarding_rules", []):
|
||||||
rules.append(
|
rules.append(
|
||||||
@@ -181,7 +207,7 @@ class OptimizedTelegramScraper:
|
|||||||
def get_db_connection(self, channel: str) -> sqlite3.Connection:
|
def get_db_connection(self, channel: str) -> sqlite3.Connection:
|
||||||
if channel not in self.db_connections:
|
if channel not in self.db_connections:
|
||||||
channel_dir = self.DATA_DIR / channel
|
channel_dir = self.DATA_DIR / channel
|
||||||
channel_dir.mkdir(parents=True, exist_ok=True)
|
channel_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||||
|
|
||||||
db_file = channel_dir / f"{channel}.db"
|
db_file = channel_dir / f"{channel}.db"
|
||||||
conn = sqlite3.connect(str(db_file), check_same_thread=False, timeout=30)
|
conn = sqlite3.connect(str(db_file), check_same_thread=False, timeout=30)
|
||||||
@@ -210,6 +236,22 @@ class OptimizedTelegramScraper:
|
|||||||
columns = {row[1] for row in cursor.fetchall()}
|
columns = {row[1] for row in cursor.fetchall()}
|
||||||
|
|
||||||
migrations = []
|
migrations = []
|
||||||
|
if "sender_id" not in columns:
|
||||||
|
migrations.append("ALTER TABLE messages ADD COLUMN sender_id INTEGER")
|
||||||
|
if "first_name" not in columns:
|
||||||
|
migrations.append("ALTER TABLE messages ADD COLUMN first_name TEXT")
|
||||||
|
if "last_name" not in columns:
|
||||||
|
migrations.append("ALTER TABLE messages ADD COLUMN last_name TEXT")
|
||||||
|
if "username" not in columns:
|
||||||
|
migrations.append("ALTER TABLE messages ADD COLUMN username TEXT")
|
||||||
|
if "message" not in columns:
|
||||||
|
migrations.append("ALTER TABLE messages ADD COLUMN message TEXT")
|
||||||
|
if "media_type" not in columns:
|
||||||
|
migrations.append("ALTER TABLE messages ADD COLUMN media_type TEXT")
|
||||||
|
if "media_path" not in columns:
|
||||||
|
migrations.append("ALTER TABLE messages ADD COLUMN media_path TEXT")
|
||||||
|
if "reply_to" not in columns:
|
||||||
|
migrations.append("ALTER TABLE messages ADD COLUMN reply_to INTEGER")
|
||||||
if "post_author" not in columns:
|
if "post_author" not in columns:
|
||||||
migrations.append("ALTER TABLE messages ADD COLUMN post_author TEXT")
|
migrations.append("ALTER TABLE messages ADD COLUMN post_author TEXT")
|
||||||
if "views" not in columns:
|
if "views" not in columns:
|
||||||
@@ -222,8 +264,8 @@ class OptimizedTelegramScraper:
|
|||||||
for migration in migrations:
|
for migration in migrations:
|
||||||
try:
|
try:
|
||||||
conn.execute(migration)
|
conn.execute(migration)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning("Migration failed for %s: %s", migration, e)
|
||||||
|
|
||||||
if migrations:
|
if migrations:
|
||||||
conn.commit()
|
conn.commit()
|
||||||
@@ -233,7 +275,7 @@ class OptimizedTelegramScraper:
|
|||||||
conn.close()
|
conn.close()
|
||||||
self.db_connections.clear()
|
self.db_connections.clear()
|
||||||
|
|
||||||
def batch_insert_messages(self, channel: str, messages: List[MessageData]):
|
def batch_insert_messages(self, channel: str, messages: list[MessageData]):
|
||||||
if not messages:
|
if not messages:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -268,7 +310,7 @@ class OptimizedTelegramScraper:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
async def download_media(self, channel: str, message) -> Optional[str]:
|
async def download_media(self, channel: str, message) -> str | None:
|
||||||
if not message.media or not self.state["scrape_media"]:
|
if not message.media or not self.state["scrape_media"]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -278,7 +320,7 @@ class OptimizedTelegramScraper:
|
|||||||
try:
|
try:
|
||||||
channel_dir = self.DATA_DIR / channel
|
channel_dir = self.DATA_DIR / channel
|
||||||
media_folder = channel_dir / "media"
|
media_folder = channel_dir / "media"
|
||||||
media_folder.mkdir(exist_ok=True)
|
media_folder.mkdir(exist_ok=True, mode=0o700)
|
||||||
|
|
||||||
if isinstance(message.media, MessageMediaPhoto):
|
if isinstance(message.media, MessageMediaPhoto):
|
||||||
original_name = getattr(message.file, "name", None) or "photo.jpg"
|
original_name = getattr(message.file, "name", None) or "photo.jpg"
|
||||||
@@ -294,9 +336,18 @@ class OptimizedTelegramScraper:
|
|||||||
unique_filename = f"{message.id}-{base_name}{extension}"
|
unique_filename = f"{message.id}-{base_name}{extension}"
|
||||||
media_path = media_folder / unique_filename
|
media_path = media_folder / unique_filename
|
||||||
|
|
||||||
existing_files = list(media_folder.glob(f"{message.id}-*"))
|
# Prefer the exact expected filename. Fall back to a matching
|
||||||
if existing_files:
|
# "{id}-*" file only if it is non-empty (and pick the newest one,
|
||||||
return str(existing_files[0])
|
# in case a stale/partial/other-extension file is present).
|
||||||
|
if media_path.exists() and media_path.stat().st_size > 0:
|
||||||
|
return str(media_path)
|
||||||
|
|
||||||
|
candidates = [
|
||||||
|
p for p in media_folder.glob(f"{message.id}-*")
|
||||||
|
if p != media_path and p.is_file() and p.stat().st_size > 0
|
||||||
|
]
|
||||||
|
if candidates:
|
||||||
|
return str(max(candidates, key=lambda p: p.stat().st_mtime))
|
||||||
|
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
try:
|
try:
|
||||||
@@ -353,7 +404,7 @@ class OptimizedTelegramScraper:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
async def forward_message(
|
async def forward_message(
|
||||||
self, message, rule: ForwardingRule, source_channel_id: int = None
|
self, message, rule: ForwardingRule, source_channel_id: int | None = None, _retry: int = 0
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
dest_entity = await self._resolve_entity(rule.destination_channel)
|
dest_entity = await self._resolve_entity(rule.destination_channel)
|
||||||
@@ -393,9 +444,12 @@ class OptimizedTelegramScraper:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
except FloodWaitError as e:
|
except FloodWaitError as e:
|
||||||
|
if _retry >= 3:
|
||||||
|
print(f" Failed to forward message {message.id}: FloodWait retry limit exceeded")
|
||||||
|
return False
|
||||||
print(f" Rate limited, waiting {e.seconds}s...")
|
print(f" Rate limited, waiting {e.seconds}s...")
|
||||||
await asyncio.sleep(e.seconds)
|
await asyncio.sleep(e.seconds)
|
||||||
return await self.forward_message(message, rule, source_channel_id)
|
return await self.forward_message(message, rule, source_channel_id, _retry=_retry + 1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" Failed to forward message {message.id}: {e}")
|
print(f" Failed to forward message {message.id}: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -429,6 +483,11 @@ class OptimizedTelegramScraper:
|
|||||||
print("No valid source channels")
|
print("No valid source channels")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Unregister a previously installed handler so it is never registered twice.
|
||||||
|
if self.forwarding_handler is not None:
|
||||||
|
self.client.remove_event_handler(self.forwarding_handler)
|
||||||
|
self.forwarding_handler = None
|
||||||
|
|
||||||
@self.client.on(
|
@self.client.on(
|
||||||
events.NewMessage(chats=source_channels, incoming=True, outgoing=True)
|
events.NewMessage(chats=source_channels, incoming=True, outgoing=True)
|
||||||
)
|
)
|
||||||
@@ -829,7 +888,14 @@ class OptimizedTelegramScraper:
|
|||||||
else:
|
else:
|
||||||
return await self.client.get_entity(channel)
|
return await self.client.get_entity(channel)
|
||||||
|
|
||||||
async def scrape_channel(self, channel: str, offset_id: int):
|
async def scrape_channel(self, channel: str, offset_id: int) -> bool:
|
||||||
|
"""Scrape a single channel. Returns True on success, False on failure.
|
||||||
|
|
||||||
|
Offset progress is persisted in a ``finally`` block so partial
|
||||||
|
progress is never lost even when an error occurs mid-scrape.
|
||||||
|
"""
|
||||||
|
last_message_id = offset_id
|
||||||
|
success = False
|
||||||
try:
|
try:
|
||||||
if not self.client.is_connected():
|
if not self.client.is_connected():
|
||||||
await self.client.connect()
|
await self.client.connect()
|
||||||
@@ -842,15 +908,70 @@ class OptimizedTelegramScraper:
|
|||||||
|
|
||||||
if total_messages == 0:
|
if total_messages == 0:
|
||||||
print(f"No messages found in channel {channel}")
|
print(f"No messages found in channel {channel}")
|
||||||
return
|
return True
|
||||||
|
|
||||||
print(f"Found {total_messages} messages in channel {channel}")
|
print(f"Found {total_messages} messages in channel {channel}")
|
||||||
|
|
||||||
message_batch = []
|
message_batch = []
|
||||||
media_tasks = []
|
media_tasks = []
|
||||||
processed_messages = 0
|
processed_messages = 0
|
||||||
last_message_id = offset_id
|
|
||||||
semaphore = asyncio.Semaphore(self.max_concurrent_downloads)
|
semaphore = asyncio.Semaphore(self.max_concurrent_downloads)
|
||||||
|
media_flush_chunk = 50
|
||||||
|
|
||||||
|
# Media progress counters tracked across chunked flushes so the
|
||||||
|
# progress bar stays coherent even though downloads happen in
|
||||||
|
# bounded chunks during the pass instead of all at the end.
|
||||||
|
total_media = 0
|
||||||
|
completed_media = 0
|
||||||
|
successful_downloads = 0
|
||||||
|
|
||||||
|
async def flush_media_batch():
|
||||||
|
"""Download the accumulated media messages in small batches.
|
||||||
|
|
||||||
|
Keeps memory bounded (we never hold references to every
|
||||||
|
media-capable message for the whole channel), and updates the
|
||||||
|
shared media progress counters via ``nonlocal``.
|
||||||
|
"""
|
||||||
|
nonlocal total_media, completed_media, successful_downloads
|
||||||
|
if not media_tasks:
|
||||||
|
return
|
||||||
|
batch = list(media_tasks)
|
||||||
|
media_tasks.clear()
|
||||||
|
total_media += len(batch)
|
||||||
|
|
||||||
|
async def download_single_media(message):
|
||||||
|
async with semaphore:
|
||||||
|
return await self.download_media(channel, message)
|
||||||
|
|
||||||
|
sub_batch = 10
|
||||||
|
for i in range(0, len(batch), sub_batch):
|
||||||
|
sub = batch[i : i + sub_batch]
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(download_single_media(msg)) for msg in sub
|
||||||
|
]
|
||||||
|
for j, task in enumerate(tasks):
|
||||||
|
try:
|
||||||
|
media_path = await task
|
||||||
|
if media_path:
|
||||||
|
await self.update_media_path(
|
||||||
|
channel, sub[j].id, media_path
|
||||||
|
)
|
||||||
|
successful_downloads += 1
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
completed_media += 1
|
||||||
|
if total_media:
|
||||||
|
mprogress = (completed_media / total_media) * 100
|
||||||
|
bar_length = 30
|
||||||
|
mfilled = int(
|
||||||
|
bar_length * completed_media // total_media
|
||||||
|
)
|
||||||
|
mbar = "█" * mfilled + "░" * (bar_length - mfilled)
|
||||||
|
sys.stdout.write(
|
||||||
|
f"\r📥 Media: [{mbar}] {mprogress:.1f}% "
|
||||||
|
f"({completed_media}/{total_media})"
|
||||||
|
)
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
async for message in self.client.iter_messages(
|
async for message in self.client.iter_messages(
|
||||||
entity, offset_id=offset_id, reverse=True
|
entity, offset_id=offset_id, reverse=True
|
||||||
@@ -902,6 +1023,11 @@ class OptimizedTelegramScraper:
|
|||||||
and not isinstance(message.media, MessageMediaWebPage)
|
and not isinstance(message.media, MessageMediaWebPage)
|
||||||
):
|
):
|
||||||
media_tasks.append(message)
|
media_tasks.append(message)
|
||||||
|
# Flush the pending media list as soon as it reaches the
|
||||||
|
# bounded chunk so we never hold thousands of message
|
||||||
|
# references in memory for the whole channel.
|
||||||
|
if len(media_tasks) >= media_flush_chunk:
|
||||||
|
await flush_media_batch()
|
||||||
|
|
||||||
last_message_id = message.id
|
last_message_id = message.id
|
||||||
processed_messages += 1
|
processed_messages += 1
|
||||||
@@ -909,10 +1035,15 @@ class OptimizedTelegramScraper:
|
|||||||
if len(message_batch) >= self.batch_size:
|
if len(message_batch) >= self.batch_size:
|
||||||
self.batch_insert_messages(channel, message_batch)
|
self.batch_insert_messages(channel, message_batch)
|
||||||
message_batch.clear()
|
message_batch.clear()
|
||||||
|
# After each insert batch, also flush any accumulated
|
||||||
|
# media (bounded) rather than deferring everything to
|
||||||
|
# the end of the full pass.
|
||||||
|
if media_tasks:
|
||||||
|
await flush_media_batch()
|
||||||
|
|
||||||
if processed_messages % self.state_save_interval == 0:
|
if processed_messages % self.state_save_interval == 0:
|
||||||
self.state["channels"][channel] = last_message_id
|
self.state["channels"][channel] = last_message_id
|
||||||
self.save_state()
|
self._save_state_throttled()
|
||||||
|
|
||||||
progress = (processed_messages / total_messages) * 100
|
progress = (processed_messages / total_messages) * 100
|
||||||
bar_length = 30
|
bar_length = 30
|
||||||
@@ -933,56 +1064,35 @@ class OptimizedTelegramScraper:
|
|||||||
self.batch_insert_messages(channel, message_batch)
|
self.batch_insert_messages(channel, message_batch)
|
||||||
|
|
||||||
if media_tasks:
|
if media_tasks:
|
||||||
total_media = len(media_tasks)
|
await flush_media_batch()
|
||||||
completed_media = 0
|
|
||||||
successful_downloads = 0
|
|
||||||
print(f"\n📥 Downloading {total_media} media files...")
|
|
||||||
|
|
||||||
semaphore = asyncio.Semaphore(self.max_concurrent_downloads)
|
|
||||||
|
|
||||||
async def download_single_media(message):
|
|
||||||
async with semaphore:
|
|
||||||
return await self.download_media(channel, message)
|
|
||||||
|
|
||||||
batch_size = 10
|
|
||||||
for i in range(0, len(media_tasks), batch_size):
|
|
||||||
batch = media_tasks[i : i + batch_size]
|
|
||||||
tasks = [
|
|
||||||
asyncio.create_task(download_single_media(msg)) for msg in batch
|
|
||||||
]
|
|
||||||
|
|
||||||
for j, task in enumerate(tasks):
|
|
||||||
try:
|
|
||||||
media_path = await task
|
|
||||||
if media_path:
|
|
||||||
await self.update_media_path(
|
|
||||||
channel, batch[j].id, media_path
|
|
||||||
)
|
|
||||||
successful_downloads += 1
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
completed_media += 1
|
|
||||||
progress = (completed_media / total_media) * 100
|
|
||||||
bar_length = 30
|
|
||||||
filled_length = int(bar_length * completed_media // total_media)
|
|
||||||
bar = "█" * filled_length + "░" * (bar_length - filled_length)
|
|
||||||
|
|
||||||
sys.stdout.write(
|
|
||||||
f"\r📥 Media: [{bar}] {progress:.1f}% ({completed_media}/{total_media})"
|
|
||||||
)
|
|
||||||
sys.stdout.flush()
|
|
||||||
|
|
||||||
|
if total_media:
|
||||||
print(
|
print(
|
||||||
f"\n✅ Media download complete! ({successful_downloads}/{total_media} successful)"
|
f"\n✅ Media download complete! ({successful_downloads}/{total_media} successful)"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.state["channels"][channel] = last_message_id
|
self.state["channels"][channel] = last_message_id
|
||||||
self.save_state()
|
|
||||||
print(f"Completed scraping channel {channel}")
|
print(f"Completed scraping channel {channel}")
|
||||||
|
|
||||||
except Exception as e:
|
# Final state save moved to ``finally`` below so the offset
|
||||||
print(f"Error with channel {channel}: {e}")
|
# persists even when an exception aborts the scrape mid-way.
|
||||||
|
success = True
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Error with channel %s", channel)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Persist the last-known offset even on partial failure so a
|
||||||
|
# re-scrape resumes from the furthest point reached, not from the
|
||||||
|
# start. save_state() is itself best-effort (logs internally),
|
||||||
|
# so a save failure here must not mask the scrape's own result.
|
||||||
|
try:
|
||||||
|
self.state["channels"][channel] = last_message_id
|
||||||
|
self.save_state()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to save state for channel %s", channel)
|
||||||
|
|
||||||
|
return success
|
||||||
|
|
||||||
async def rescrape_media(self, channel: str):
|
async def rescrape_media(self, channel: str):
|
||||||
conn = self.get_db_connection(channel)
|
conn = self.get_db_connection(channel)
|
||||||
|
|||||||
+975
-10
File diff suppressed because it is too large
Load Diff
+97
-42
@@ -68,7 +68,34 @@ function setBusy(button, busy) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function confirmAction(message) {
|
function confirmAction(message) {
|
||||||
return window.confirm(message);
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = (value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (dialog.open) dialog.close();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
let dialog = document.getElementById('confirm-dialog');
|
||||||
|
if (!dialog) {
|
||||||
|
dialog = document.createElement('dialog');
|
||||||
|
dialog.id = 'confirm-dialog';
|
||||||
|
dialog.className = 'confirm-dialog';
|
||||||
|
dialog.innerHTML =
|
||||||
|
'<div class="confirm-dialog-body"><div class="eyebrow">Confirm</div>' +
|
||||||
|
'<p class="confirm-dialog-message muted"></p>' +
|
||||||
|
'<div class="confirm-dialog-actions">' +
|
||||||
|
'<button class="button" data-confirm-cancel type="button">Cancel</button>' +
|
||||||
|
'<button class="button primary" data-confirm-ok type="button">Confirm</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
}
|
||||||
|
dialog.querySelector('.confirm-dialog-message').textContent = message;
|
||||||
|
dialog.querySelector('[data-confirm-ok]').onclick = () => done(true);
|
||||||
|
dialog.querySelector('[data-confirm-cancel]').onclick = () => done(false);
|
||||||
|
dialog.onclose = () => done(false);
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function showToast(message, type = 'info') {
|
function showToast(message, type = 'info') {
|
||||||
@@ -202,12 +229,18 @@ function renderAccountPanel(accountId) {
|
|||||||
(item) => item.value,
|
(item) => item.value,
|
||||||
);
|
);
|
||||||
if (!runAllTracked && channels.length === 0 && enabled) {
|
if (!runAllTracked && channels.length === 0 && enabled) {
|
||||||
if (!confirmAction('Continuous scraping enabled with no selected channels. Save anyway?')) return;
|
if (!(await confirmAction('Continuous scraping enabled with no selected channels. Save anyway?'))) return;
|
||||||
}
|
}
|
||||||
await api(`/api/accounts/${accountId}/continuous`, {
|
const resp = await api(`/api/accounts/${accountId}/continuous`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ enabled, interval_minutes: intervalMinutes, run_all_tracked: runAllTracked, channels }),
|
body: JSON.stringify({ enabled, interval_minutes: intervalMinutes, run_all_tracked: runAllTracked, channels }),
|
||||||
});
|
});
|
||||||
|
if (resp.dropped_invalid && resp.dropped_invalid.length) {
|
||||||
|
const n = resp.dropped_invalid.length;
|
||||||
|
const shown = resp.dropped_invalid.slice(0, 3).join(', ');
|
||||||
|
const extra = n > 3 ? '…' : '';
|
||||||
|
showToast(`${n} invalid channel(s) skipped: ${shown}${extra}`, 'warn');
|
||||||
|
}
|
||||||
await refreshAccount(accountId);
|
await refreshAccount(accountId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -283,15 +316,17 @@ function updateSidebarAccount() {
|
|||||||
const acc = state.accounts.find((a) => a.id === state.activeAccount);
|
const acc = state.accounts.find((a) => a.id === state.activeAccount);
|
||||||
const nameEl = document.getElementById('active-account-name');
|
const nameEl = document.getElementById('active-account-name');
|
||||||
const statusEl = document.getElementById('active-account-status');
|
const statusEl = document.getElementById('active-account-status');
|
||||||
|
if (!nameEl || !statusEl) return;
|
||||||
|
statusEl.classList.remove('is-ok', 'is-error', 'is-dim');
|
||||||
if (acc) {
|
if (acc) {
|
||||||
nameEl.textContent = acc.label || acc.id;
|
nameEl.textContent = acc.label || acc.id;
|
||||||
const authOk = isAccountAuthorized(acc.auth);
|
const authOk = isAccountAuthorized(acc.auth);
|
||||||
statusEl.textContent = authOk ? 'Authorized session' : 'Needs login';
|
statusEl.textContent = authOk ? 'Authorized session' : 'Needs login';
|
||||||
statusEl.style.color = authOk ? 'var(--ok)' : 'var(--danger)';
|
statusEl.classList.add(authOk ? 'is-ok' : 'is-error');
|
||||||
} else {
|
} else {
|
||||||
nameEl.textContent = 'None';
|
nameEl.textContent = 'None';
|
||||||
statusEl.textContent = 'Add an account in Settings';
|
statusEl.textContent = 'Add an account in Settings';
|
||||||
statusEl.style.color = 'var(--dim)';
|
statusEl.classList.add('is-dim');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +342,7 @@ function renderChannels(accountId, channels) {
|
|||||||
if (!channels.length) {
|
if (!channels.length) {
|
||||||
const row = document.createElement('tr');
|
const row = document.createElement('tr');
|
||||||
row.innerHTML =
|
row.innerHTML =
|
||||||
'<td colspan="5"><div class="empty-state">No tracked channels yet. Add an ID or @username to start scraping this account.</div></td>';
|
'<td colspan="5"><div class="empty-state"><p class="muted">No tracked channels yet. Add an ID or @username to start scraping this account.</p></div></td>';
|
||||||
tbody.appendChild(row);
|
tbody.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +383,7 @@ function renderChannels(accountId, channels) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
node.querySelector('.remove-btn').addEventListener('click', async () => {
|
node.querySelector('.remove-btn').addEventListener('click', async () => {
|
||||||
if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return;
|
if (!(await confirmAction(`Remove ${channel.name} from tracked channels?`))) return;
|
||||||
await api(`/api/accounts/${accountId}/channels/remove`, {
|
await api(`/api/accounts/${accountId}/channels/remove`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ channel_id: channel.channel_id }),
|
body: JSON.stringify({ channel_id: channel.channel_id }),
|
||||||
@@ -361,6 +396,8 @@ function renderChannels(accountId, channels) {
|
|||||||
|
|
||||||
// Update channel count stat
|
// Update channel count stat
|
||||||
panel.querySelector('.channel-count').textContent = String(channels.length);
|
panel.querySelector('.channel-count').textContent = String(channels.length);
|
||||||
|
panel.querySelector('.channel-active-count').textContent = String(channels.length);
|
||||||
|
panel.querySelector('.channel-inactive-count').textContent = '0';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderJobs(accountId, jobs) {
|
function renderJobs(accountId, jobs) {
|
||||||
@@ -371,7 +408,8 @@ function renderJobs(accountId, jobs) {
|
|||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
||||||
if (!jobs.length) {
|
if (!jobs.length) {
|
||||||
root.innerHTML = '<div class="empty-state">No jobs yet. Start a scrape or export to see progress here.</div>';
|
root.innerHTML =
|
||||||
|
'<div class="empty-state"><p class="muted">No jobs yet. Start a scrape or export to see progress here.</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,7 +447,7 @@ function pollJobFallback(accountId, jobId) {
|
|||||||
try {
|
try {
|
||||||
const job = await api(`/api/jobs/${encodeURIComponent(jobId)}`);
|
const job = await api(`/api/jobs/${encodeURIComponent(jobId)}`);
|
||||||
updateRenderedJob(accountId, job);
|
updateRenderedJob(accountId, job);
|
||||||
if (['completed', 'failed'].includes(job.status)) {
|
if (['done', 'completed', 'failed'].includes(job.status)) {
|
||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
jobStreams.delete(jobId);
|
jobStreams.delete(jobId);
|
||||||
refreshAccount(accountId);
|
refreshAccount(accountId);
|
||||||
@@ -453,7 +491,7 @@ function subscribeJobStream(accountId, jobId, status) {
|
|||||||
retryCount = 0; // reset backoff on successful message
|
retryCount = 0; // reset backoff on successful message
|
||||||
const job = JSON.parse(event.data);
|
const job = JSON.parse(event.data);
|
||||||
updateRenderedJob(accountId, job);
|
updateRenderedJob(accountId, job);
|
||||||
if (['completed', 'failed'].includes(job.status)) {
|
if (['done', 'completed', 'failed'].includes(job.status)) {
|
||||||
newStream.close();
|
newStream.close();
|
||||||
jobStreams.delete(jobId);
|
jobStreams.delete(jobId);
|
||||||
refreshAccount(accountId);
|
refreshAccount(accountId);
|
||||||
@@ -616,16 +654,24 @@ function renderSummary(accountId, data) {
|
|||||||
const d = data.dashboard || data.state || {};
|
const d = data.dashboard || data.state || {};
|
||||||
const health = data.health || {};
|
const health = data.health || {};
|
||||||
panel.querySelector('.forwarding-count').textContent = String((d.forwarding_rules || []).length);
|
panel.querySelector('.forwarding-count').textContent = String((d.forwarding_rules || []).length);
|
||||||
|
panel.querySelector('.forwarding-active-count').textContent = String((d.forwarding_rules || []).length);
|
||||||
const toggle = panel.querySelector('.scrape-media-label');
|
const toggle = panel.querySelector('.scrape-media-label');
|
||||||
toggle.textContent = d.scrape_media ? 'ON' : 'OFF';
|
toggle.textContent = d.scrape_media ? 'ON' : 'OFF';
|
||||||
const healthOk = health.api_credentials && health.session_ready && health.data_dir_exists;
|
const healthOk = health.api_credentials && health.session_ready && health.data_dir_exists;
|
||||||
panel.querySelector('.account-health-label').textContent = healthOk ? 'Ready' : 'Check';
|
const healthLabel = panel.querySelector('.account-health-label');
|
||||||
panel.querySelector('.account-health-label').style.color = healthOk ? 'var(--ok)' : 'var(--warn)';
|
healthLabel.textContent = healthOk ? 'Ready' : 'Check';
|
||||||
|
healthLabel.classList.remove('is-ok', 'is-warn');
|
||||||
|
healthLabel.classList.add(healthOk ? 'is-ok' : 'is-warn');
|
||||||
panel.querySelector('.account-health-detail').textContent = [
|
panel.querySelector('.account-health-detail').textContent = [
|
||||||
`${health.message_count || 0} messages`,
|
`${health.message_count || 0} messages`,
|
||||||
`${health.media_count || 0} media`,
|
`${health.media_count || 0} media`,
|
||||||
health.active_job ? `active: ${health.active_job.status}` : 'idle',
|
health.active_job ? `active: ${health.active_job.status}` : 'idle',
|
||||||
].join(' | ');
|
].join(' | ');
|
||||||
|
const mediaCount = Number(health.media_count || 0);
|
||||||
|
panel.querySelector('.media-session-count').textContent = String(mediaCount);
|
||||||
|
panel.querySelector('.media-total-count').textContent = String(mediaCount);
|
||||||
|
panel.querySelector('.health-session-value').textContent = health.session_ready ? 'Valid' : 'Check';
|
||||||
|
panel.querySelector('.health-rate-value').textContent = health.active_job ? 'Busy' : 'OK';
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Account data loading ───────────────────────────── */
|
/* ── Account data loading ───────────────────────────── */
|
||||||
@@ -707,7 +753,9 @@ async function loadAccounts() {
|
|||||||
|
|
||||||
function renderSettingsAccounts() {
|
function renderSettingsAccounts() {
|
||||||
const container = document.getElementById('accounts-list');
|
const container = document.getElementById('accounts-list');
|
||||||
|
if (!container) return;
|
||||||
const template = document.getElementById('account-list-item-template');
|
const template = document.getElementById('account-list-item-template');
|
||||||
|
if (!template) return;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
|
|
||||||
state.accounts.forEach((acc) => {
|
state.accounts.forEach((acc) => {
|
||||||
@@ -718,12 +766,12 @@ function renderSettingsAccounts() {
|
|||||||
const authStatus = node.querySelector('.account-list-auth-status');
|
const authStatus = node.querySelector('.account-list-auth-status');
|
||||||
const authOk = isAccountAuthorized(acc.auth);
|
const authOk = isAccountAuthorized(acc.auth);
|
||||||
authStatus.textContent = authOk ? 'Authorized' : 'Needs auth';
|
authStatus.textContent = authOk ? 'Authorized' : 'Needs auth';
|
||||||
authStatus.style.color = authOk ? 'var(--ok)' : 'var(--danger)';
|
authStatus.classList.remove('is-ok', 'is-error');
|
||||||
authStatus.style.fontSize = '0.78rem';
|
authStatus.classList.add(authOk ? 'is-ok' : 'is-error');
|
||||||
|
|
||||||
node.querySelector('.account-select-btn').addEventListener('click', () => {
|
node.querySelector('.account-select-btn').addEventListener('click', () => {
|
||||||
switchAccount(acc.id);
|
switchAccount(acc.id);
|
||||||
document.getElementById('settings-dialog').close();
|
document.getElementById('settings-dialog')?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
node.querySelector('.account-export-btn').addEventListener('click', async () => {
|
node.querySelector('.account-export-btn').addEventListener('click', async () => {
|
||||||
@@ -737,7 +785,7 @@ function renderSettingsAccounts() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
node.querySelector('.account-remove-btn').addEventListener('click', async () => {
|
node.querySelector('.account-remove-btn').addEventListener('click', async () => {
|
||||||
if (!confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`)) return;
|
if (!(await confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`))) return;
|
||||||
try {
|
try {
|
||||||
const removingActive = state.activeAccount === acc.id;
|
const removingActive = state.activeAccount === acc.id;
|
||||||
await api(`/api/accounts/${acc.id}`, { method: 'DELETE' });
|
await api(`/api/accounts/${acc.id}`, { method: 'DELETE' });
|
||||||
@@ -759,6 +807,7 @@ function renderSettingsAccounts() {
|
|||||||
|
|
||||||
function updateAuthSection(accountId) {
|
function updateAuthSection(accountId) {
|
||||||
const label = document.getElementById('auth-account-label');
|
const label = document.getElementById('auth-account-label');
|
||||||
|
if (!label) return;
|
||||||
const acc = state.accounts.find((a) => a.id === accountId);
|
const acc = state.accounts.find((a) => a.id === accountId);
|
||||||
label.textContent = acc ? acc.label || acc.id : '-';
|
label.textContent = acc ? acc.label || acc.id : '-';
|
||||||
}
|
}
|
||||||
@@ -822,7 +871,7 @@ async function submitPassword(accountId) {
|
|||||||
|
|
||||||
async function scrapeAll() {
|
async function scrapeAll() {
|
||||||
if (!state.activeAccount) return;
|
if (!state.activeAccount) return;
|
||||||
if (!confirmAction('Queue scraping for all tracked channels?')) return;
|
if (!(await confirmAction('Queue scraping for all tracked channels?'))) return;
|
||||||
await api(`/api/accounts/${state.activeAccount}/jobs/scrape`, {
|
await api(`/api/accounts/${state.activeAccount}/jobs/scrape`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
@@ -832,7 +881,7 @@ async function scrapeAll() {
|
|||||||
|
|
||||||
async function exportAll() {
|
async function exportAll() {
|
||||||
if (!state.activeAccount) return;
|
if (!state.activeAccount) return;
|
||||||
if (!confirmAction('Queue export for all tracked channels?')) return;
|
if (!(await confirmAction('Queue export for all tracked channels?'))) return;
|
||||||
await api(`/api/accounts/${state.activeAccount}/jobs/export`, {
|
await api(`/api/accounts/${state.activeAccount}/jobs/export`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
@@ -887,25 +936,18 @@ async function main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Settings dialog ──
|
// ── Legacy settings dialog (removed from dashboard; Settings page owns auth) ──
|
||||||
|
// Kept guarded so old markup, if present, never throws.
|
||||||
const settingsDialog = document.getElementById('settings-dialog');
|
const settingsDialog = document.getElementById('settings-dialog');
|
||||||
const openSettingsBtn = document.getElementById('open-settings-btn');
|
const closeSettingsBtn = document.getElementById('close-settings-btn');
|
||||||
if (!openSettingsBtn.matches('a[href]')) {
|
if (settingsDialog && closeSettingsBtn) {
|
||||||
openSettingsBtn.addEventListener('click', () => {
|
closeSettingsBtn.addEventListener('click', () => settingsDialog.close());
|
||||||
// Update auth section for active account
|
|
||||||
if (state.activeAccount) {
|
|
||||||
updateAuthSection(state.activeAccount);
|
|
||||||
}
|
}
|
||||||
renderSettingsAccounts();
|
|
||||||
settingsDialog.showModal();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
document.getElementById('close-settings-btn').addEventListener('click', () => {
|
|
||||||
settingsDialog.close();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Add account form ──
|
// ── Add account form (only when legacy dialog markup exists) ──
|
||||||
document.getElementById('add-account-form').addEventListener('submit', async (event) => {
|
const addAccountForm = document.getElementById('add-account-form');
|
||||||
|
if (addAccountForm) {
|
||||||
|
addAccountForm.addEventListener('submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const accountId = document.getElementById('add-account-id').value.trim();
|
const accountId = document.getElementById('add-account-id').value.trim();
|
||||||
const label = document.getElementById('add-account-label').value.trim();
|
const label = document.getElementById('add-account-label').value.trim();
|
||||||
@@ -932,8 +974,11 @@ async function main() {
|
|||||||
showToast('Failed to add account: ' + err.message, 'error');
|
showToast('Failed to add account: ' + err.message, 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('import-account-file').addEventListener('change', async (event) => {
|
const importAccountFile = document.getElementById('import-account-file');
|
||||||
|
if (importAccountFile) {
|
||||||
|
importAccountFile.addEventListener('change', async (event) => {
|
||||||
const file = event.currentTarget.files?.[0];
|
const file = event.currentTarget.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
@@ -948,13 +993,23 @@ async function main() {
|
|||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
switchAccount(accountId);
|
switchAccount(accountId);
|
||||||
showToast(`Imported ${accountId}.`, 'success');
|
showToast(`Imported ${accountId}.`, 'success');
|
||||||
|
showToast(
|
||||||
|
'Credentials (api_id/api_hash) are not exported for security — re-enter them in Settings if needed.',
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`Failed to import account: ${err.message}`, 'error');
|
showToast(`Failed to import account: ${err.message}`, 'error');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Credentials form ──
|
// ── Credentials / auth forms (only when legacy dialog markup exists) ──
|
||||||
document.getElementById('credentials-form').addEventListener('submit', async (event) => {
|
const bindOptional = (id, evt, handler) => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.addEventListener(evt, handler);
|
||||||
|
};
|
||||||
|
|
||||||
|
bindOptional('credentials-form', 'submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!state.activeAccount) return;
|
if (!state.activeAccount) return;
|
||||||
try {
|
try {
|
||||||
@@ -965,7 +1020,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── QR login ──
|
// ── QR login ──
|
||||||
document.getElementById('start-qr-btn').addEventListener('click', async (event) => {
|
bindOptional('start-qr-btn', 'click', async (event) => {
|
||||||
setBusy(event.currentTarget, true);
|
setBusy(event.currentTarget, true);
|
||||||
try {
|
try {
|
||||||
if (!state.activeAccount) return;
|
if (!state.activeAccount) return;
|
||||||
@@ -978,7 +1033,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Phone ──
|
// ── Phone ──
|
||||||
document.getElementById('phone-form').addEventListener('submit', async (event) => {
|
bindOptional('phone-form', 'submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!state.activeAccount) return;
|
if (!state.activeAccount) return;
|
||||||
try {
|
try {
|
||||||
@@ -989,7 +1044,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Code ──
|
// ── Code ──
|
||||||
document.getElementById('code-form').addEventListener('submit', async (event) => {
|
bindOptional('code-form', 'submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!state.activeAccount) return;
|
if (!state.activeAccount) return;
|
||||||
try {
|
try {
|
||||||
@@ -1000,7 +1055,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Password ──
|
// ── Password ──
|
||||||
document.getElementById('password-form').addEventListener('submit', async (event) => {
|
bindOptional('password-form', 'submit', async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!state.activeAccount) return;
|
if (!state.activeAccount) return;
|
||||||
try {
|
try {
|
||||||
@@ -1011,7 +1066,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Media toggle ──
|
// ── Media toggle ──
|
||||||
document.getElementById('scrape-media-toggle').addEventListener('change', async (event) => {
|
bindOptional('scrape-media-toggle', 'change', async (event) => {
|
||||||
const checked = event.currentTarget.checked;
|
const checked = event.currentTarget.checked;
|
||||||
try {
|
try {
|
||||||
await toggleMedia(checked);
|
await toggleMedia(checked);
|
||||||
|
|||||||
+83
-89
@@ -4,23 +4,45 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Telegram Scraper Control Panel</title>
|
<title>Telegram Scraper Control Panel</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=6" />
|
<link rel="stylesheet" href="/static/style.css?v=9" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell">
|
<div class="app-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
|
<svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m21 3-7.6 18-3.8-7.6L2 9.6zM9.6 13.4 14 10" />
|
||||||
|
</svg>
|
||||||
<div class="eyebrow">Telegram Scraper</div>
|
<div class="eyebrow">Telegram Scraper</div>
|
||||||
<h1>Control</h1>
|
<h1>Control</h1>
|
||||||
<p class="muted">Local scraper console for channels, jobs, and continuous runs.</p>
|
<p class="muted">Local scraper console for channels, jobs, and continuous runs.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a class="nav-link active" href="/">Dashboard</a>
|
<a class="nav-link active" href="/"
|
||||||
<a class="nav-link" href="/settings">Settings</a>
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<a class="nav-link" href="/viewer">Message Viewer</a>
|
<rect x="4" y="4" width="6" height="6" />
|
||||||
<a class="nav-link" href="/swagger">API Docs</a>
|
<rect x="14" y="4" width="6" height="6" />
|
||||||
<a class="nav-link" href="/health">Health</a>
|
<rect x="4" y="14" width="6" height="6" />
|
||||||
|
<rect x="14" y="14" width="6" height="6" /></svg
|
||||||
|
>Dashboard</a
|
||||||
|
>
|
||||||
|
<a class="nav-link" href="/settings"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
d="M12 2v3m0 14v3M2 12h3m14 0h3m-2.9-7.1-2.1 2.1M4.9 19.1 7 17m0-10-2.1-2.1m12.2 14.2-2.1-2.1" /></svg
|
||||||
|
>Settings</a
|
||||||
|
>
|
||||||
|
<a class="nav-link" href="/viewer"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v12H8l-4 3z" /></svg>Message
|
||||||
|
Viewer</a
|
||||||
|
>
|
||||||
|
<a class="nav-link" href="/swagger"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m8 5-5 7 5 7m8-14 5 7-5 7M14 3l-4 18" /></svg
|
||||||
|
>API Docs</a
|
||||||
|
>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section class="status-card" id="active-account-card">
|
<section class="status-card" id="active-account-card">
|
||||||
@@ -39,6 +61,13 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="content">
|
<main class="content">
|
||||||
|
<header class="dashboard-topbar">
|
||||||
|
<div class="dashboard-title"><span class="menu-icon" aria-hidden="true">☰</span>Dashboard</div>
|
||||||
|
<div class="runtime-status">
|
||||||
|
<span class="runtime-dot"></span>Service: Running <i></i> Scraper: Idle <i></i
|
||||||
|
><span class="runtime-clock"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<!-- Account Tabs -->
|
<!-- Account Tabs -->
|
||||||
<div id="account-tabs" class="account-tabs"></div>
|
<div id="account-tabs" class="account-tabs"></div>
|
||||||
|
|
||||||
@@ -57,87 +86,17 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a class="button primary" href="/settings">Open Settings</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ════════ Settings Dialog ════════ -->
|
|
||||||
<dialog id="settings-dialog" class="settings-dialog">
|
|
||||||
<div class="dialog-shell">
|
|
||||||
<div class="dialog-header">
|
|
||||||
<div>
|
|
||||||
<div class="eyebrow">Settings</div>
|
|
||||||
<h2>Global & Accounts</h2>
|
|
||||||
</div>
|
|
||||||
<button class="button button-small" id="close-settings-btn" type="button">Close</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- ── Accounts Management ── -->
|
|
||||||
<section class="settings-section">
|
|
||||||
<div class="section-title">Accounts</div>
|
|
||||||
<div id="accounts-list" class="accounts-list"></div>
|
|
||||||
|
|
||||||
<form id="add-account-form" class="stack-form add-account-form">
|
|
||||||
<input id="add-account-id" name="account_id" placeholder="Account ID (e.g. work)" required />
|
|
||||||
<input id="add-account-label" name="label" placeholder="Display name (e.g. Work Account)" />
|
|
||||||
<input id="add-account-api-id" name="api_id" placeholder="API ID" />
|
|
||||||
<input id="add-account-api-hash" name="api_hash" placeholder="API Hash" />
|
|
||||||
<button class="button primary" type="submit">Add Account</button>
|
|
||||||
</form>
|
|
||||||
<label class="import-account-row">
|
|
||||||
<span class="muted small">Import account settings JSON</span>
|
|
||||||
<input id="import-account-file" type="file" accept="application/json,.json" />
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── Account Auth ── (shown per account selected in UI) -->
|
|
||||||
<section class="settings-section" id="account-auth-section">
|
|
||||||
<div class="section-title">Account Auth: <span id="auth-account-label">-</span></div>
|
|
||||||
<form id="credentials-form" class="stack-form">
|
|
||||||
<input id="api-id-input" name="api_id" placeholder="API ID" />
|
|
||||||
<input id="api-hash-input" name="api_hash" placeholder="API Hash" />
|
|
||||||
<button class="button" type="submit">Save credentials</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div class="auth-actions">
|
|
||||||
<button class="button primary" id="start-qr-btn" type="button">Start QR login</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="qr-wrap" class="qr-wrap hidden">
|
|
||||||
<img id="qr-image" alt="Telegram QR login" />
|
|
||||||
<p class="muted small">Open Telegram -> Settings -> Devices -> Scan QR.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="phone-form" class="stack-form">
|
|
||||||
<input id="phone-input" name="phone" placeholder="+1234567890" />
|
|
||||||
<button class="button" type="submit">Send code</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form id="code-form" class="stack-form hidden">
|
|
||||||
<input id="code-input" name="code" placeholder="Telegram code" />
|
|
||||||
<button class="button" type="submit">Confirm code</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<form id="password-form" class="stack-form hidden">
|
|
||||||
<input id="password-input" name="password" type="password" placeholder="2FA password" />
|
|
||||||
<button class="button" type="submit">Confirm password</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- ── Scraping ── -->
|
|
||||||
<section class="settings-section">
|
|
||||||
<div class="section-title">Scraping</div>
|
|
||||||
<label class="toggle-row">
|
|
||||||
<span>Download media</span>
|
|
||||||
<span class="switch">
|
|
||||||
<input id="scrape-media-toggle" type="checkbox" />
|
|
||||||
<span class="switch-slider"></span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
|
|
||||||
<!-- ════════ Templates ════════ -->
|
<!-- ════════ Templates ════════ -->
|
||||||
|
|
||||||
<!-- Account Tab -->
|
<!-- Account Tab -->
|
||||||
@@ -163,18 +122,38 @@
|
|||||||
<div class="panel stat-panel">
|
<div class="panel stat-panel">
|
||||||
<div class="section-title">Channels</div>
|
<div class="section-title">Channels</div>
|
||||||
<div class="stat-value channel-count">0</div>
|
<div class="stat-value channel-count">0</div>
|
||||||
|
<div class="stat-caption">Tracked channels</div>
|
||||||
|
<div class="stat-meta">
|
||||||
|
<span><strong class="channel-active-count">0</strong> Active</span
|
||||||
|
><span><strong class="channel-inactive-count">0</strong> Inactive</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel stat-panel">
|
<div class="panel stat-panel">
|
||||||
<div class="section-title">Media scraping</div>
|
<div class="section-title">Media scraping</div>
|
||||||
<div class="stat-value stat-compact scrape-media-label">OFF</div>
|
<div class="stat-value stat-compact scrape-media-label">OFF</div>
|
||||||
|
<div class="stat-caption">Items downloaded</div>
|
||||||
|
<div class="stat-meta">
|
||||||
|
<span><strong class="media-session-count">0</strong> This session</span
|
||||||
|
><span><strong class="media-total-count">0</strong> Total</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel stat-panel">
|
<div class="panel stat-panel">
|
||||||
<div class="section-title">Forwarding rules</div>
|
<div class="section-title">Forwarding rules</div>
|
||||||
<div class="stat-value forwarding-count">0</div>
|
<div class="stat-value forwarding-count">0</div>
|
||||||
|
<div class="stat-caption">Active rules</div>
|
||||||
|
<div class="stat-meta">
|
||||||
|
<span><strong class="forwarding-active-count">0</strong> Active</span
|
||||||
|
><span><strong>0</strong> Paused</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel stat-panel">
|
<div class="panel stat-panel">
|
||||||
<div class="section-title">Account health</div>
|
<div class="section-title">Account health</div>
|
||||||
<div class="stat-value stat-compact account-health-label">-</div>
|
<div class="stat-value stat-compact account-health-label">-</div>
|
||||||
|
<div class="stat-caption">Session status</div>
|
||||||
|
<div class="stat-meta">
|
||||||
|
<span><strong class="health-session-value">-</strong> Session</span
|
||||||
|
><span><strong class="health-rate-value">-</strong> Rate limit</span>
|
||||||
|
</div>
|
||||||
<div class="muted small account-health-detail"></div>
|
<div class="muted small account-health-detail"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -186,12 +165,22 @@
|
|||||||
<p class="muted">Per-account channel list.</p>
|
<p class="muted">Per-account channel list.</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="inline-form add-channel-form">
|
<form class="inline-form add-channel-form">
|
||||||
<input class="add-channel-id" name="channel_id" placeholder="ID or @username" required />
|
<input
|
||||||
<input class="add-channel-name" name="name" placeholder="Display name" />
|
class="add-channel-id"
|
||||||
|
name="channel_id"
|
||||||
|
placeholder="ID or @username"
|
||||||
|
aria-label="Channel ID or username"
|
||||||
|
required />
|
||||||
|
<input
|
||||||
|
class="add-channel-name"
|
||||||
|
name="name"
|
||||||
|
placeholder="Display name"
|
||||||
|
aria-label="Channel display name" />
|
||||||
<button class="button primary" type="submit">Add</button>
|
<button class="button primary" type="submit">Add</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -206,6 +195,7 @@
|
|||||||
<tbody class="channels-table"></tbody>
|
<tbody class="channels-table"></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel jobs-panel">
|
<section class="panel jobs-panel">
|
||||||
@@ -216,7 +206,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<button class="button refresh-jobs-btn">Refresh</button>
|
<button class="button refresh-jobs-btn">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
<div class="jobs-list"></div>
|
<div class="jobs-list"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -230,6 +222,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-body">
|
||||||
<form class="continuous-form">
|
<form class="continuous-form">
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<span>Enabled</span>
|
<span>Enabled</span>
|
||||||
@@ -238,9 +231,9 @@
|
|||||||
<span class="switch-slider"></span>
|
<span class="switch-slider"></span>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="stack-form">
|
<label class="field">
|
||||||
<span class="muted small">Interval, minutes</span>
|
<span class="field-label">Interval, minutes</span>
|
||||||
<input type="number" class="continuous-interval" min="1" value="1" />
|
<input type="number" class="continuous-interval" min="1" value="1" aria-label="Interval, minutes" />
|
||||||
</label>
|
</label>
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<span>All tracked channels</span>
|
<span>All tracked channels</span>
|
||||||
@@ -264,6 +257,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="log-viewer continuous-logs"></div>
|
<div class="log-viewer continuous-logs"></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -319,6 +313,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="/static/app.js?v=4"></script>
|
<script src="/static/app.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+117
-19
@@ -4,23 +4,45 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Telegram Scraper Settings</title>
|
<title>Telegram Scraper Settings</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=6" />
|
<link rel="stylesheet" href="/static/style.css?v=9" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-shell settings-page-shell">
|
<div class="app-shell settings-page-shell">
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="brand-block">
|
<div class="brand-block">
|
||||||
|
<svg class="brand-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m21 3-7.6 18-3.8-7.6L2 9.6zM9.6 13.4 14 10" />
|
||||||
|
</svg>
|
||||||
<div class="eyebrow">Telegram Scraper</div>
|
<div class="eyebrow">Telegram Scraper</div>
|
||||||
<h1>Settings</h1>
|
<h1>Settings</h1>
|
||||||
<p class="muted">Account credentials, import/export, and runtime safety controls.</p>
|
<p class="muted">Account credentials, import/export, and runtime safety controls.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a class="nav-link" href="/">Dashboard</a>
|
<a class="nav-link" href="/"
|
||||||
<a class="nav-link active" href="/settings">Settings</a>
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<a class="nav-link" href="/viewer">Message Viewer</a>
|
<rect x="4" y="4" width="6" height="6" />
|
||||||
<a class="nav-link" href="/swagger">API Docs</a>
|
<rect x="14" y="4" width="6" height="6" />
|
||||||
<a class="nav-link" href="/health">Health</a>
|
<rect x="4" y="14" width="6" height="6" />
|
||||||
|
<rect x="14" y="14" width="6" height="6" /></svg
|
||||||
|
>Dashboard</a
|
||||||
|
>
|
||||||
|
<a class="nav-link active" href="/settings"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
d="M12 2v3m0 14v3M2 12h3m14 0h3m-2.9-7.1-2.1 2.1M4.9 19.1 7 17m0-10-2.1-2.1m12.2 14.2-2.1-2.1" /></svg
|
||||||
|
>Settings</a
|
||||||
|
>
|
||||||
|
<a class="nav-link" href="/viewer"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v12H8l-4 3z" /></svg>Message
|
||||||
|
Viewer</a
|
||||||
|
>
|
||||||
|
<a class="nav-link" href="/swagger"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m8 5-5 7 5 7m8-14 5 7-5 7M14 3l-4 18" /></svg
|
||||||
|
>API Docs</a
|
||||||
|
>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<section class="status-card">
|
<section class="status-card">
|
||||||
@@ -30,11 +52,21 @@
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="content settings-page">
|
<main class="content settings-page">
|
||||||
|
<header class="dashboard-topbar">
|
||||||
|
<div class="dashboard-title"><span class="menu-icon" aria-hidden="true">☰</span>Settings</div>
|
||||||
|
<div class="runtime-status">
|
||||||
|
<span class="runtime-dot"></span>Service: Running <i></i> Scraper: Idle <i></i
|
||||||
|
><span class="runtime-clock"></span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
<section class="panel settings-hero">
|
<section class="panel settings-hero">
|
||||||
<div>
|
<div>
|
||||||
<div class="eyebrow">Account Settings</div>
|
<div class="eyebrow">Account Settings</div>
|
||||||
<h2 id="settings-title">Loading accounts</h2>
|
<h2 id="settings-title">Loading accounts</h2>
|
||||||
<p id="settings-subtitle" class="muted">Select an account to manage credentials and scraper options.</p>
|
<p id="settings-subtitle" class="muted">Select an account to manage credentials and scraper options.</p>
|
||||||
|
<p class="muted small" id="settings-hero-hint">
|
||||||
|
First step: add your first account below. Export and Delete unlock after you select an account.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="action-row">
|
<div class="action-row">
|
||||||
<button class="button" id="settings-export-btn" type="button" disabled>Export</button>
|
<button class="button" id="settings-export-btn" type="button" disabled>Export</button>
|
||||||
@@ -44,8 +76,12 @@
|
|||||||
|
|
||||||
<section id="settings-empty" class="panel hidden">
|
<section id="settings-empty" class="panel hidden">
|
||||||
<div class="empty-state">
|
<div class="empty-state">
|
||||||
|
<div class="eyebrow">Getting started</div>
|
||||||
<h2>Add first account</h2>
|
<h2>Add first account</h2>
|
||||||
<p class="muted">Create an account below or import an exported account JSON.</p>
|
<p class="muted">Create an account below or import an exported account JSON. No accounts yet.</p>
|
||||||
|
<div class="empty-actions">
|
||||||
|
<a class="button primary" href="#settings-add-account-form">Add first account</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -76,18 +112,52 @@
|
|||||||
<p class="muted">New accounts start without continuous scraping enabled.</p>
|
<p class="muted">New accounts start without continuous scraping enabled.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
<form id="settings-add-account-form" class="stack-form">
|
<form id="settings-add-account-form" class="stack-form">
|
||||||
<input id="settings-add-account-id" name="account_id" placeholder="Account ID (e.g. work)" required />
|
<label class="field">
|
||||||
<input id="settings-add-account-label" name="label" placeholder="Display name" />
|
<span class="field-label">Account ID</span>
|
||||||
<input id="settings-add-account-api-id" name="api_id" placeholder="API ID" />
|
<input
|
||||||
<input id="settings-add-account-api-hash" name="api_hash" placeholder="API Hash" />
|
id="settings-add-account-id"
|
||||||
<button class="button primary" type="submit">Add Account</button>
|
name="account_id"
|
||||||
|
placeholder="Account ID (e.g. work)"
|
||||||
|
aria-label="Account ID"
|
||||||
|
required />
|
||||||
|
<span class="field-hint">Lowercase short id used in file names and URLs.</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">Display name</span>
|
||||||
|
<input
|
||||||
|
id="settings-add-account-label"
|
||||||
|
name="label"
|
||||||
|
placeholder="Display name"
|
||||||
|
aria-label="Display name" />
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">API ID</span>
|
||||||
|
<input id="settings-add-account-api-id" name="api_id" placeholder="API ID" aria-label="API ID" />
|
||||||
|
<span class="field-hint">From my.telegram.org > API development tools.</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">API Hash</span>
|
||||||
|
<input
|
||||||
|
id="settings-add-account-api-hash"
|
||||||
|
name="api_hash"
|
||||||
|
placeholder="API Hash"
|
||||||
|
aria-label="API Hash" />
|
||||||
|
<span class="field-hint">Paste the hash exactly as shown on my.telegram.org.</span>
|
||||||
|
</label>
|
||||||
|
<button class="button primary button-block" type="submit">Add Account</button>
|
||||||
</form>
|
</form>
|
||||||
<label class="import-account-row">
|
<label class="import-account-row">
|
||||||
<span class="muted small">Import account settings JSON</span>
|
<span class="field-label">Import account settings JSON</span>
|
||||||
|
<span class="muted small"
|
||||||
|
>Choose a file previously exported from Settings. It must be account JSON, for example:</span
|
||||||
|
>
|
||||||
|
<span class="file-example">{"account_id": "work", "api_id": 12345, ...}</span>
|
||||||
<input id="settings-import-account-file" type="file" accept="application/json,.json" />
|
<input id="settings-import-account-file" type="file" accept="application/json,.json" />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -96,9 +166,20 @@
|
|||||||
<p class="muted" id="settings-auth-label">No account selected.</p>
|
<p class="muted" id="settings-auth-label">No account selected.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
|
<p class="muted small" id="settings-credentials-hint">
|
||||||
|
Select an account first, then save API credentials and complete login below.
|
||||||
|
</p>
|
||||||
<form id="settings-credentials-form" class="stack-form">
|
<form id="settings-credentials-form" class="stack-form">
|
||||||
<input id="settings-api-id-input" name="api_id" placeholder="API ID" />
|
<label class="field">
|
||||||
<input id="settings-api-hash-input" name="api_hash" placeholder="API Hash" />
|
<span class="field-label">API ID</span>
|
||||||
|
<input id="settings-api-id-input" name="api_id" placeholder="API ID" aria-label="API ID" />
|
||||||
|
<span class="field-hint">From my.telegram.org > API development tools.</span>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="field-label">API Hash</span>
|
||||||
|
<input id="settings-api-hash-input" name="api_hash" placeholder="API Hash" aria-label="API Hash" />
|
||||||
|
</label>
|
||||||
<button class="button" type="submit">Save credentials</button>
|
<button class="button" type="submit">Save credentials</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -112,20 +193,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="settings-phone-form" class="stack-form">
|
<form id="settings-phone-form" class="stack-form">
|
||||||
<input id="settings-phone-input" name="phone" placeholder="+1234567890" />
|
<label class="field">
|
||||||
|
<span class="field-label">Phone number</span>
|
||||||
|
<input id="settings-phone-input" name="phone" placeholder="+1234567890" aria-label="Phone number" />
|
||||||
|
</label>
|
||||||
<button class="button" type="submit">Send code</button>
|
<button class="button" type="submit">Send code</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form id="settings-code-form" class="stack-form hidden">
|
<form id="settings-code-form" class="stack-form hidden">
|
||||||
<input id="settings-code-input" name="code" placeholder="Telegram code" />
|
<label class="field">
|
||||||
|
<span class="field-label">Telegram code</span>
|
||||||
|
<input id="settings-code-input" name="code" placeholder="Telegram code" aria-label="Telegram code" />
|
||||||
|
</label>
|
||||||
<button class="button" type="submit">Confirm code</button>
|
<button class="button" type="submit">Confirm code</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<form id="settings-password-form" class="stack-form hidden">
|
<form id="settings-password-form" class="stack-form hidden">
|
||||||
<input id="settings-password-input" name="password" type="password" placeholder="2FA password" />
|
<label class="field">
|
||||||
|
<span class="field-label">2FA password</span>
|
||||||
|
<input
|
||||||
|
id="settings-password-input"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="2FA password"
|
||||||
|
aria-label="2FA password" />
|
||||||
|
</label>
|
||||||
<button class="button" type="submit">Confirm password</button>
|
<button class="button" type="submit">Confirm password</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -134,6 +230,7 @@
|
|||||||
<p class="muted">Account-level parser settings.</p>
|
<p class="muted">Account-level parser settings.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="panel-body">
|
||||||
<label class="toggle-row">
|
<label class="toggle-row">
|
||||||
<span>Download media</span>
|
<span>Download media</span>
|
||||||
<span class="switch">
|
<span class="switch">
|
||||||
@@ -147,10 +244,11 @@
|
|||||||
<div>Media: <span id="settings-media-count">0</span></div>
|
<div>Media: <span id="settings-media-count">0</span></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/settings.js?v=1"></script>
|
<script src="/static/settings.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+46
-6
@@ -38,6 +38,42 @@ function showToast(message, type = 'info') {
|
|||||||
}, 3600);
|
}, 3600);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmAction(message) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = (value) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
if (dialog.open) dialog.close();
|
||||||
|
resolve(value);
|
||||||
|
};
|
||||||
|
let dialog = document.getElementById('confirm-dialog');
|
||||||
|
if (!dialog) {
|
||||||
|
dialog = document.createElement('dialog');
|
||||||
|
dialog.id = 'confirm-dialog';
|
||||||
|
dialog.className = 'confirm-dialog';
|
||||||
|
dialog.innerHTML =
|
||||||
|
'<div class="confirm-dialog-body"><div class="eyebrow">Confirm</div>' +
|
||||||
|
'<p class="confirm-dialog-message muted"></p>' +
|
||||||
|
'<div class="confirm-dialog-actions">' +
|
||||||
|
'<button class="button" data-confirm-cancel type="button">Cancel</button>' +
|
||||||
|
'<button class="button primary" data-confirm-ok type="button">Confirm</button>' +
|
||||||
|
'</div></div>';
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
}
|
||||||
|
dialog.querySelector('.confirm-dialog-message').textContent = message;
|
||||||
|
dialog.querySelector('[data-confirm-ok]').onclick = () => done(true);
|
||||||
|
dialog.querySelector('[data-confirm-cancel]').onclick = () => done(false);
|
||||||
|
dialog.onclose = () => done(false);
|
||||||
|
if (!dialog.open) dialog.showModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setHealthState(el, ok, warnOnly = false) {
|
||||||
|
el.classList.remove('is-ok', 'is-warn', 'is-dim');
|
||||||
|
el.classList.add(ok ? 'is-ok' : warnOnly ? 'is-dim' : 'is-warn');
|
||||||
|
}
|
||||||
|
|
||||||
function downloadJson(filename, payload) {
|
function downloadJson(filename, payload) {
|
||||||
const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], {
|
const blob = new Blob([JSON.stringify(payload, null, 2) + '\n'], {
|
||||||
type: 'application/json',
|
type: 'application/json',
|
||||||
@@ -105,7 +141,7 @@ function renderAccountList() {
|
|||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
||||||
if (!settingsState.accounts.length) {
|
if (!settingsState.accounts.length) {
|
||||||
root.innerHTML = '<div class="empty-state">No accounts yet.</div>';
|
root.innerHTML = '<div class="empty-state"><p class="muted">No accounts yet.</p></div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,17 +204,16 @@ function renderAccountData() {
|
|||||||
: 'No account selected.';
|
: 'No account selected.';
|
||||||
|
|
||||||
document.getElementById('health-credentials').textContent = health.api_credentials ? 'Saved' : 'Missing';
|
document.getElementById('health-credentials').textContent = health.api_credentials ? 'Saved' : 'Missing';
|
||||||
document.getElementById('health-credentials').style.color = health.api_credentials ? 'var(--ok)' : 'var(--warn)';
|
setHealthState(document.getElementById('health-credentials'), Boolean(health.api_credentials));
|
||||||
document.getElementById('health-session').textContent =
|
document.getElementById('health-session').textContent =
|
||||||
health.session_ready || isAccountAuthorized(auth) ? 'Ready' : 'Missing';
|
health.session_ready || isAccountAuthorized(auth) ? 'Ready' : 'Missing';
|
||||||
document.getElementById('health-session').style.color =
|
setHealthState(document.getElementById('health-session'), Boolean(health.session_ready || isAccountAuthorized(auth)));
|
||||||
health.session_ready || isAccountAuthorized(auth) ? 'var(--ok)' : 'var(--warn)';
|
|
||||||
document.getElementById('health-continuous').textContent = continuousStatus.running
|
document.getElementById('health-continuous').textContent = continuousStatus.running
|
||||||
? 'Running'
|
? 'Running'
|
||||||
: continuousConfig.enabled
|
: continuousConfig.enabled
|
||||||
? 'Enabled'
|
? 'Enabled'
|
||||||
: 'Stopped';
|
: 'Stopped';
|
||||||
document.getElementById('health-continuous').style.color = continuousStatus.running ? 'var(--ok)' : 'var(--dim)';
|
setHealthState(document.getElementById('health-continuous'), Boolean(continuousStatus.running), true);
|
||||||
document.getElementById('health-last-scrape').textContent = displayTime(
|
document.getElementById('health-last-scrape').textContent = displayTime(
|
||||||
health.last_scrape || continuousStatus.last_iteration_at,
|
health.last_scrape || continuousStatus.last_iteration_at,
|
||||||
);
|
);
|
||||||
@@ -260,6 +295,10 @@ async function importAccount(event) {
|
|||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
await loadAccount(accountId);
|
await loadAccount(accountId);
|
||||||
showToast(`Imported ${accountId}.`, 'success');
|
showToast(`Imported ${accountId}.`, 'success');
|
||||||
|
showToast(
|
||||||
|
'Credentials (api_id/api_hash) are not exported for security — re-enter them in Settings if needed.',
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(`Failed to import account: ${err.message}`, 'error');
|
showToast(`Failed to import account: ${err.message}`, 'error');
|
||||||
}
|
}
|
||||||
@@ -341,7 +380,8 @@ async function exportAccount() {
|
|||||||
async function deleteAccount() {
|
async function deleteAccount() {
|
||||||
if (!settingsState.activeAccount) return;
|
if (!settingsState.activeAccount) return;
|
||||||
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount);
|
const account = settingsState.accounts.find((item) => item.id === settingsState.activeAccount);
|
||||||
if (!window.confirm(`Remove account "${accountLabel(account)}"? All its account data will be deleted.`)) return;
|
if (!(await confirmAction(`Remove account "${accountLabel(account)}"? All its account data will be deleted.`)))
|
||||||
|
return;
|
||||||
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}`, { method: 'DELETE' });
|
await api(`/api/accounts/${encodeURIComponent(settingsState.activeAccount)}`, { method: 'DELETE' });
|
||||||
setActiveAccount(null);
|
setActiveAccount(null);
|
||||||
await loadAccounts();
|
await loadAccounts();
|
||||||
|
|||||||
+1200
-598
File diff suppressed because it is too large
Load Diff
+32
-6
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Telegram Scraper API</title>
|
<title>Telegram Scraper API</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=6" />
|
<link rel="stylesheet" href="/static/style.css?v=9" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="viewer-shell">
|
<div class="viewer-shell">
|
||||||
@@ -17,18 +17,44 @@
|
|||||||
<a class="button button-small" href="/">Dashboard</a>
|
<a class="button button-small" href="/">Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
<nav class="nav-links">
|
<nav class="nav-links">
|
||||||
<a class="nav-link" href="/openapi.json">openapi.json</a>
|
<a class="nav-link" href="/"
|
||||||
<a class="nav-link" href="/health">Health</a>
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<a class="nav-link" href="/viewer">Message Viewer</a>
|
<rect x="4" y="4" width="6" height="6" />
|
||||||
|
<rect x="14" y="4" width="6" height="6" />
|
||||||
|
<rect x="4" y="14" width="6" height="6" />
|
||||||
|
<rect x="14" y="14" width="6" height="6" /></svg
|
||||||
|
>Dashboard</a
|
||||||
|
>
|
||||||
|
<a class="nav-link" href="/settings"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="3" />
|
||||||
|
<path
|
||||||
|
d="M12 2v3m0 14v3M2 12h3m14 0h3m-2.9-7.1-2.1 2.1M4.9 19.1 7 17m0-10-2.1-2.1m12.2 14.2-2.1-2.1" /></svg
|
||||||
|
>Settings</a
|
||||||
|
>
|
||||||
|
<a class="nav-link" href="/viewer"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h16v12H8l-4 3z" /></svg>Message
|
||||||
|
Viewer</a
|
||||||
|
>
|
||||||
|
<a class="nav-link active" href="/swagger"
|
||||||
|
><svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m8 5-5 7 5 7m8-14 5 7-5 7M14 3l-4 18" /></svg
|
||||||
|
>API Docs</a
|
||||||
|
>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main class="viewer-main">
|
<main class="viewer-main">
|
||||||
<header class="viewer-header">
|
<header class="viewer-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Endpoints</h2>
|
<div class="eyebrow">OpenAPI</div>
|
||||||
|
<h2>API Docs</h2>
|
||||||
<p class="muted">Local API surface exposed by the web UI server.</p>
|
<p class="muted">Local API surface exposed by the web UI server.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="viewer-tools">
|
||||||
|
<div class="runtime-status"><span class="runtime-dot"></span>Service: Running</div>
|
||||||
|
<a class="button button-small" href="/openapi.json">openapi.json</a>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section id="api-docs" class="api-docs"></section>
|
<section id="api-docs" class="api-docs"></section>
|
||||||
@@ -53,6 +79,6 @@
|
|||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="/static/swagger.js"></script>
|
<script src="/static/swagger.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+52
-2
@@ -19,7 +19,30 @@ function renderSpec(spec) {
|
|||||||
const methodTemplate = document.getElementById('api-method-template');
|
const methodTemplate = document.getElementById('api-method-template');
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
|
|
||||||
Object.entries(spec.paths || {}).forEach(([path, methods]) => {
|
const paths = Object.entries(spec.paths || {});
|
||||||
|
if (!paths.length) {
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.className = 'panel';
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'panel-header';
|
||||||
|
const title = document.createElement('h2');
|
||||||
|
title.textContent = 'No endpoints';
|
||||||
|
header.appendChild(title);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'panel-body';
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'empty-state';
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.className = 'muted';
|
||||||
|
text.textContent = 'The OpenAPI spec contains no paths.';
|
||||||
|
empty.appendChild(text);
|
||||||
|
body.appendChild(empty);
|
||||||
|
section.append(header, body);
|
||||||
|
root.appendChild(section);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
paths.forEach(([path, methods]) => {
|
||||||
const section = sectionTemplate.content.firstElementChild.cloneNode(true);
|
const section = sectionTemplate.content.firstElementChild.cloneNode(true);
|
||||||
section.querySelector('.api-path').textContent = path;
|
section.querySelector('.api-path').textContent = path;
|
||||||
const methodsRoot = section.querySelector('.api-methods');
|
const methodsRoot = section.querySelector('.api-methods');
|
||||||
@@ -49,5 +72,32 @@ loadSpec()
|
|||||||
.then(renderSpec)
|
.then(renderSpec)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
document.getElementById('api-docs').innerHTML = `<section class="panel">${error.message}</section>`;
|
const docsEl = document.getElementById('api-docs');
|
||||||
|
docsEl.textContent = '';
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.className = 'panel';
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'panel-header';
|
||||||
|
const title = document.createElement('h2');
|
||||||
|
title.textContent = 'Failed to load API spec';
|
||||||
|
header.appendChild(title);
|
||||||
|
const body = document.createElement('div');
|
||||||
|
body.className = 'panel-body';
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className = 'empty-state';
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.className = 'muted';
|
||||||
|
text.textContent = error.message;
|
||||||
|
const actions = document.createElement('div');
|
||||||
|
actions.className = 'empty-actions';
|
||||||
|
const retry = document.createElement('button');
|
||||||
|
retry.className = 'button';
|
||||||
|
retry.type = 'button';
|
||||||
|
retry.textContent = 'Retry';
|
||||||
|
retry.addEventListener('click', () => window.location.reload());
|
||||||
|
actions.appendChild(retry);
|
||||||
|
empty.append(text, actions);
|
||||||
|
body.appendChild(empty);
|
||||||
|
section.append(header, body);
|
||||||
|
docsEl.appendChild(section);
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Telegram Scraper Viewer</title>
|
<title>Telegram Scraper Viewer</title>
|
||||||
<link rel="stylesheet" href="/static/style.css?v=6" />
|
<link rel="stylesheet" href="/static/style.css?v=9" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="viewer-shell">
|
<div class="viewer-shell">
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<h1>Messages</h1>
|
<h1>Messages</h1>
|
||||||
<p class="muted small">
|
<p class="muted small">
|
||||||
Account:
|
Account:
|
||||||
<select id="viewer-account-select"></select>
|
<select id="viewer-account-select" aria-label="Viewer account"></select>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="sidebar-toggle" class="button button-small sidebar-toggle">☰</button>
|
<button id="sidebar-toggle" class="button button-small sidebar-toggle">☰</button>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
|
<p id="viewer-subtitle" class="muted">Reading messages from the local SQLite database.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="viewer-tools">
|
<div class="viewer-tools">
|
||||||
<input id="viewer-search" type="search" placeholder="Search messages" />
|
<input id="viewer-search" type="search" placeholder="Search messages" aria-label="Search messages" />
|
||||||
<label class="viewer-auto-refresh">
|
<label class="viewer-auto-refresh">
|
||||||
<input id="viewer-auto-refresh" type="checkbox" checked />
|
<input id="viewer-auto-refresh" type="checkbox" checked />
|
||||||
<span>Auto-refresh</span>
|
<span>Auto-refresh</span>
|
||||||
@@ -80,6 +80,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script src="/static/viewer.js"></script>
|
<script src="/static/viewer.js?v=9"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
+7
-2
@@ -395,10 +395,15 @@ function renderMessages(payload, append = false) {
|
|||||||
viewerState.oldestMessageId = null;
|
viewerState.oldestMessageId = null;
|
||||||
viewerState.newestMessageId = null;
|
viewerState.newestMessageId = null;
|
||||||
const empty = document.createElement('div');
|
const empty = document.createElement('div');
|
||||||
empty.className = 'viewer-empty-state';
|
empty.className = 'empty-state viewer-empty-state';
|
||||||
empty.textContent = viewerState.search
|
const title = document.createElement('h2');
|
||||||
|
title.textContent = viewerState.search ? 'No messages found' : 'No saved messages';
|
||||||
|
const text = document.createElement('p');
|
||||||
|
text.className = 'muted';
|
||||||
|
text.textContent = viewerState.search
|
||||||
? `No messages found for "${viewerState.search}".`
|
? `No messages found for "${viewerState.search}".`
|
||||||
: 'No saved messages in this channel yet.';
|
: 'No saved messages in this channel yet.';
|
||||||
|
empty.append(title, text);
|
||||||
root.appendChild(empty);
|
root.appendChild(empty);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+967
-245
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user