fix: rebuild instagram parser integration

This commit is contained in:
Your Name
2026-08-05 20:25:38 +05:00
parent 80c9353af4
commit 90586e9961
5 changed files with 482 additions and 420 deletions
+15 -8
View File
@@ -1,10 +1,17 @@
INSERT INTO app_settings (key, label, value_type, value) INSERT INTO worker_controls(name, enabled, settings_json)
VALUES ('insta-parser', FALSE, '{}'::jsonb)
ON CONFLICT (name) DO NOTHING;
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES VALUES
('insta_login', 'Instagram Login', 'string', ''), ('insta_login', '""'::jsonb, 'str', 'Instagram login', 'Username for the Instagram account used by instagrapi.', 'Instagram Parser'),
('insta_password', 'Instagram Password', 'secret', ''), ('insta_password', '""'::jsonb, 'secret', 'Instagram password', 'Password for the Instagram account used by instagrapi.', 'Instagram Parser'),
('insta_proxy_url', 'Instagram Proxy URL (http://user:pass@ip:port)', 'string', ''), ('insta_proxy_url', '""'::jsonb, 'str', 'Instagram proxy URL', 'Optional stable proxy, for example http://user:pass@host:port.', 'Instagram Parser'),
('insta_fetch_count', 'Instagram: количество проверяемых последних постов', 'int', '5'), ('insta_session_path', '"insta_session.json"'::jsonb, 'str', 'Instagram session path', 'Path to the persisted instagrapi session settings file.', 'Instagram Parser'),
('insta_delay_base_minutes', 'Instagram: пауза между аккаунтами (минут)', 'int', '35'), ('insta_fetch_count', '5'::jsonb, 'int', 'Posts to inspect', 'How many latest posts to inspect per account visit.', 'Instagram Parser'),
('insta_delay_random_minutes', 'Instagram: разброс паузы (± минут)', 'int', '5'), ('insta_delay_base_minutes', '35'::jsonb, 'int', 'Account visit delay, minutes', 'Base pause after checking one Instagram account.', 'Instagram Parser'),
('insta_cooldown_hours', 'Instagram: отлежка при лимитах (часов)', 'int', '12') ('insta_delay_random_minutes', '5'::jsonb, 'int', 'Delay random spread, minutes', 'Random +/- spread added to the base account visit delay.', 'Instagram Parser'),
('insta_cooldown_hours', '12'::jsonb, 'int', 'Cooldown hours', 'Sleep window after challenge, login-required or rate-limit responses.', 'Instagram Parser'),
('insta_request_pause_sec', '2'::jsonb, 'float', 'Request pause, sec', 'Small pause between Instagram user lookup and media fetch.', 'Instagram Parser'),
('insta_dedupe_content_hash', 'true'::jsonb, 'bool', 'Dedupe by content hash', 'Skip Instagram posts whose text and media hash already exists.', 'Instagram Parser')
ON CONFLICT (key) DO NOTHING; ON CONFLICT (key) DO NOTHING;
+74 -37
View File
@@ -22,7 +22,7 @@ from fastapi.templating import Jinja2Templates
from loguru import logger from loguru import logger
from .config import settings from .config import settings
from .constants import PLATFORM_VK, PLATFORM_INSTAGRAM from .constants import PLATFORM_INSTAGRAM, PLATFORM_VK
from .db import fetch_int_setting, fetch_setting, get_pool from .db import fetch_int_setting, fetch_setting, get_pool
from .security import hash_password, new_token, token_hash, verify_password from .security import hash_password, new_token, token_hash, verify_password
from .text_utils import build_publication_text, normalize_hash_tag, parse_categories from .text_utils import build_publication_text, normalize_hash_tag, parse_categories
@@ -948,7 +948,7 @@ def parse_source_line(line: str) -> dict[str, str]:
( (
i i
for i, part in enumerate(parts) for i, part in enumerate(parts)
if any(domain in part for domain in ("vk.com/", "vk.ru/", "m.vk.com/")) if any(domain in part for domain in ("vk.com/", "vk.ru/", "m.vk.com/", "instagram.com/"))
or part.lower().startswith(("club", "public")) or part.lower().startswith(("club", "public"))
), ),
-1, -1,
@@ -956,9 +956,9 @@ def parse_source_line(line: str) -> dict[str, str]:
if url_index < 0: if url_index < 0:
if len(parts) == 1: if len(parts) == 1:
value = parts[0].strip() value = parts[0].strip()
url = value if value.startswith("http") else f"https://vk.com/{value}" url = value if value.startswith("http") or value.startswith("@") else f"https://vk.com/{value}"
return {"name": "", "tag": "", "url": url, "error": ""} return {"name": "", "tag": "", "url": url, "error": ""}
return {"name": "", "tag": "", "url": "", "error": "Не нашёл ссылку VK"} return {"name": "", "tag": "", "url": "", "error": "Не нашёл ссылку"}
url = parts[url_index].strip() url = parts[url_index].strip()
before_url = parts[:url_index] before_url = parts[:url_index]
if len(before_url) >= 2: if len(before_url) >= 2:
@@ -976,10 +976,47 @@ def parse_source_line(line: str) -> dict[str, str]:
return {"name": name, "tag": normalize_hash_tag(tag, ""), "url": url, "error": ""} return {"name": name, "tag": normalize_hash_tag(tag, ""), "url": url, "error": ""}
def normalize_instagram_source(value: str) -> tuple[str, str]:
raw = (value or "").strip()
if not raw:
return "", ""
if raw.startswith("@"):
username = raw[1:]
return username, f"https://www.instagram.com/{username}/"
if not raw.startswith(("http://", "https://")) and "instagram.com/" not in raw:
return raw, f"https://www.instagram.com/{raw}/"
if raw.startswith(("instagram.com/", "www.instagram.com/")):
raw = f"https://{raw}"
match = re.search(r"instagram\.com/([^/?#]+)/?", raw, re.IGNORECASE)
username = (match.group(1) if match else "").strip()
if username.lower() in {"p", "reel", "stories", "explore"}:
username = ""
url = f"https://www.instagram.com/{username}/" if username else raw
return username, url
def source_platform_from_url(url: str) -> str:
value = (url or "").strip().lower()
return PLATFORM_INSTAGRAM if value.startswith("@") or "instagram.com/" in value else PLATFORM_VK
async def build_sources_preview(lines_text: str, default_active: bool = True) -> list[dict[str, Any]]: async def build_sources_preview(lines_text: str, default_active: bool = True) -> list[dict[str, Any]]:
lines = [line for line in (lines_text or "").splitlines() if line.strip()] lines = [line for line in (lines_text or "").splitlines() if line.strip()]
parsed = [parse_source_line(line) for line in lines] parsed = [parse_source_line(line) for line in lines]
normalized_values = [normalize_vk_source(item["url"]) for item in parsed if item.get("url")] normalized_values = []
normalized_urls = []
for item in parsed:
url = item.get("url") or ""
if not url:
continue
if source_platform_from_url(url) == PLATFORM_INSTAGRAM:
external_id, normalized_url = normalize_instagram_source(url)
else:
external_id = normalize_vk_source(url)
normalized_url = url
if external_id:
normalized_values.append(external_id)
normalized_urls.append(normalized_url)
pool = await get_pool() pool = await get_pool()
existing_rows = await pool.fetch( existing_rows = await pool.fetch(
""" """
@@ -989,7 +1026,7 @@ async def build_sources_preview(lines_text: str, default_active: bool = True) ->
AND (lower(external_id)=ANY($1::text[]) OR lower(url)=ANY($2::text[]) OR lower(COALESCE(tag,''))=ANY($3::text[])) AND (lower(external_id)=ANY($1::text[]) OR lower(url)=ANY($2::text[]) OR lower(COALESCE(tag,''))=ANY($3::text[]))
""", """,
[v.lower() for v in normalized_values if v], [v.lower() for v in normalized_values if v],
[str(item.get("url") or "").lower() for item in parsed], [url.lower() for url in normalized_urls if url],
[normalize_hash_tag(str(item.get("tag") or ""), "").lower() for item in parsed if item.get("tag")], [normalize_hash_tag(str(item.get("tag") or ""), "").lower() for item in parsed if item.get("tag")],
) )
existing_ids = {str(row["external_id"] or "").lower() for row in existing_rows} existing_ids = {str(row["external_id"] or "").lower() for row in existing_rows}
@@ -1003,10 +1040,14 @@ async def build_sources_preview(lines_text: str, default_active: bool = True) ->
preview = [] preview = []
for idx, item in enumerate(parsed, start=1): for idx, item in enumerate(parsed, start=1):
url = item.get("url") or "" url = item.get("url") or ""
platform = source_platform_from_url(url)
if platform == PLATFORM_INSTAGRAM:
external_id, url = normalize_instagram_source(url)
else:
external_id = normalize_vk_source(url) if url else "" external_id = normalize_vk_source(url) if url else ""
row = { row = {
"line_no": idx, "line_no": idx,
"platform": PLATFORM_VK, "platform": platform,
"name": item.get("name") or "", "name": item.get("name") or "",
"tag": normalize_hash_tag(item.get("tag") or external_id, external_id or "source"), "tag": normalize_hash_tag(item.get("tag") or external_id, external_id or "source"),
"url": url, "url": url,
@@ -1026,8 +1067,13 @@ async def build_sources_preview(lines_text: str, default_active: bool = True) ->
if not row["error"] and row["tag"].lower() in existing_tags: if not row["error"] and row["tag"].lower() in existing_tags:
row["error"] = "Такой тэг уже есть" row["error"] = "Такой тэг уже есть"
if not row["error"] and not external_id: if not row["error"] and not external_id:
row["error"] = "Не удалось разобрать VK-ссылку" row["error"] = "Не удалось разобрать ссылку"
if not row["error"]: if not row["error"] and platform == PLATFORM_INSTAGRAM:
row["name"] = row["name"] or external_id
row["ok"] = True
seen.add(key)
seen_tags.add(row["tag"].lower())
elif not row["error"]:
try: try:
screen_name, owner_id, resolved_name = await client.resolve_group(url) screen_name, owner_id, resolved_name = await client.resolve_group(url)
row["external_id"] = screen_name row["external_id"] = screen_name
@@ -2318,16 +2364,7 @@ async def sources_bulk_create(
if not isinstance(item, dict) or not item.get("ok"): if not isinstance(item, dict) or not item.get("ok"):
continue continue
try: try:
url_val = str(item.get("url") or "").strip() platform = str(item.get("platform") or PLATFORM_VK).strip().lower() or PLATFORM_VK
external_id_val = str(item.get("external_id") or "").strip()
platform_val = PLATFORM_VK
if "instagram.com" in url_val:
platform_val = PLATFORM_INSTAGRAM
if not external_id_val:
m = re.search(r"instagram\.com/([^/?#]+)", url_val)
if m:
external_id_val = m.group(1)
row = await pool.fetchrow( row = await pool.fetchrow(
""" """
INSERT INTO sources(platform, name, tag, url, external_id, external_owner_id, active, priority, created_by) INSERT INTO sources(platform, name, tag, url, external_id, external_owner_id, active, priority, created_by)
@@ -2335,18 +2372,18 @@ async def sources_bulk_create(
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
RETURNING id RETURNING id
""", """,
platform_val, platform,
str(item.get("name") or external_id_val or "").strip(), str(item.get("name") or item.get("external_id") or "").strip(),
normalize_hash_tag(str(item.get("tag") or external_id_val or ""), external_id_val or "source"), normalize_hash_tag(str(item.get("tag") or item.get("external_id") or ""), str(item.get("external_id") or "source")),
url_val, str(item.get("url") or "").strip(),
external_id_val, str(item.get("external_id") or "").strip(),
item.get("external_owner_id"), item.get("external_owner_id"),
bool(item.get("active", True)), bool(item.get("active", True)),
user["id"], user["id"],
) )
if row: if row:
created += 1 created += 1
await audit(user["id"], "source.create", "source", int(row["id"]), {"url": item.get("url"), "bulk": True}) await audit(user["id"], "source.create", "source", int(row["id"]), {"url": item.get("url"), "bulk": True, "platform": platform})
except Exception: except Exception:
logger.exception("Bulk source insert failed: {}", item) logger.exception("Bulk source insert failed: {}", item)
return redirect(f"/sources?q=&status_filter=&created={created}") return redirect(f"/sources?q=&status_filter=&created={created}")
@@ -2380,18 +2417,12 @@ async def source_create(
require_csrf(user, csrf_token) require_csrf(user, csrf_token)
platform = platform.strip().lower() or PLATFORM_VK platform = platform.strip().lower() or PLATFORM_VK
resolved_url = url.strip() resolved_url = url.strip()
if platform == PLATFORM_VK and source_platform_from_url(resolved_url) == PLATFORM_INSTAGRAM:
if "instagram.com" in resolved_url and platform == PLATFORM_VK:
platform = PLATFORM_INSTAGRAM platform = PLATFORM_INSTAGRAM
if platform == PLATFORM_INSTAGRAM:
if platform == PLATFORM_VK: external_id, resolved_url = normalize_instagram_source(resolved_url)
external_id = normalize_vk_source(resolved_url)
elif platform == PLATFORM_INSTAGRAM:
m = re.search(r"instagram\.com/([^/?#]+)", resolved_url)
external_id = m.group(1) if m else ""
else: else:
external_id = "" external_id = normalize_vk_source(resolved_url)
external_owner_id = None external_owner_id = None
resolved_name = name.strip() resolved_name = name.strip()
status_value = "new" status_value = "new"
@@ -2472,7 +2503,13 @@ async def source_update(
return redirect("/login") return redirect("/login")
require_csrf(user, csrf_token) require_csrf(user, csrf_token)
platform = platform.strip().lower() or PLATFORM_VK platform = platform.strip().lower() or PLATFORM_VK
external_id = normalize_vk_source(url) if platform == PLATFORM_VK else "" resolved_url = url.strip()
if platform == PLATFORM_VK and source_platform_from_url(resolved_url) == PLATFORM_INSTAGRAM:
platform = PLATFORM_INSTAGRAM
if platform == PLATFORM_INSTAGRAM:
external_id, resolved_url = normalize_instagram_source(resolved_url)
else:
external_id = normalize_vk_source(resolved_url)
pool = await get_pool() pool = await get_pool()
await pool.execute( await pool.execute(
""" """
@@ -2491,7 +2528,7 @@ async def source_update(
platform, platform,
name.strip(), name.strip(),
normalize_hash_tag(tag or external_id or name, external_id or "source"), normalize_hash_tag(tag or external_id or name, external_id or "source"),
url.strip(), resolved_url,
external_id, external_id,
active == "on", active == "on",
priority, priority,
@@ -17,6 +17,7 @@
<label class="label"><span class="label-text font-bold">Площадка</span></label> <label class="label"><span class="label-text font-bold">Площадка</span></label>
<select name="platform" class="select select-bordered w-full"> <select name="platform" class="select select-bordered w-full">
<option value="vk" {% if not source or source.platform == "vk" %}selected{% endif %}>VK</option> <option value="vk" {% if not source or source.platform == "vk" %}selected{% endif %}>VK</option>
<option value="instagram" {% if source and source.platform == "instagram" %}selected{% endif %}>Instagram</option>
</select> </select>
</div> </div>
+7 -8
View File
@@ -83,23 +83,22 @@ async def send_parser_error_alert(parser_name: str, source_name: str, error: str
return return
text = ( text = (
f"🚨 <b>CRITICAL PARSER ERROR</b>\n" "Parser failed\n"
f"Parser: {parser_name}\n" f"parser: {parser_name}\n"
f"Source: {source_name}\n\n" f"source: {source_name or '-'}\n"
f"Error: {error[:1000]}" f"error: {error[:1000]}"
) )
bot = Bot(token=token) bot = Bot(token=token)
try: try:
for recipient_id in recipients: for recipient_id in recipients:
while True: while True:
try: try:
await bot.send_message(recipient_id, text, disable_web_page_preview=True, parse_mode="HTML") await bot.send_message(recipient_id, text, disable_web_page_preview=True)
break break
except TelegramRetryAfter as exc: except TelegramRetryAfter as exc:
await asyncio.sleep(float(exc.retry_after) + 1) await asyncio.sleep(float(exc.retry_after) + 1)
except Exception as e: except Exception as exc:
logger.error("Failed to send parser alert to {}: {}", recipient_id, e) logger.error("Failed to send parser alert to {}: {}", recipient_id, exc)
break break
finally: finally:
await bot.session.close() await bot.session.close()
+363 -345
View File
@@ -4,462 +4,480 @@ import asyncio
import hashlib import hashlib
import json import json
import random import random
import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any
try:
from instagrapi import Client
from instagrapi.exceptions import (
ChallengeRequired,
PleaseWaitFewMinutes,
LoginRequired,
UserNotFound,
PrivateAccount
)
except ImportError:
Client = None
ChallengeRequired = Exception
PleaseWaitFewMinutes = Exception
LoginRequired = Exception
UserNotFound = Exception
PrivateAccount = Exception
from loguru import logger from loguru import logger
from ..config import settings
from ..constants import ( from ..constants import (
JOB_TYPE_VK_STORAGE_COPY,
MEDIA_STATUS_LINK_ONLY,
MEDIA_STATUS_PENDING,
PLATFORM_INSTAGRAM, PLATFORM_INSTAGRAM,
POST_STATUS_STORAGE_PENDING,
POST_STATUS_SKIPPED, POST_STATUS_SKIPPED,
POST_STATUS_STORAGE_PENDING,
SOURCE_STATUS_ERROR, SOURCE_STATUS_ERROR,
SOURCE_STATUS_OK, SOURCE_STATUS_OK,
WORKER_INSTA_PARSER, WORKER_INSTA_PARSER,
) )
from ..db import fetch_setting, fetch_int_setting, get_pool from ..db import fetch_bool_setting, fetch_float_setting, fetch_int_setting, fetch_setting, get_pool
from ..heartbeat import HeartbeatReporter from ..heartbeat import HeartbeatReporter
from ..jobs import is_worker_enabled
from .ai_alerts import send_parser_error_alert from .ai_alerts import send_parser_error_alert
def make_content_hash(text: str) -> str: def make_hash(*parts: str) -> str:
return hashlib.sha256((text or "").strip().lower().encode()).hexdigest() h = hashlib.sha256()
for part in parts:
h.update((part or "").strip().lower().encode("utf-8"))
h.update(b"\0")
return h.hexdigest()
def as_dict(value: Any) -> dict:
if isinstance(value, dict):
return value
if hasattr(value, "model_dump"):
return value.model_dump()
if hasattr(value, "dict"):
return value.dict()
return {}
def media_value(value: Any) -> str:
return str(value or "").strip()
def media_code(post: dict) -> str:
return str(post.get("code") or post.get("pk") or "").strip()
def original_url(post: dict) -> str:
code = media_code(post)
return f"https://www.instagram.com/p/{code}/" if code else ""
def media_taken_at(post: dict) -> datetime:
value = post.get("taken_at")
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
return datetime.now(tz=timezone.utc)
def media_user_pk(post: dict) -> int | None:
user = as_dict(post.get("user"))
try:
return int(user.get("pk") or 0) or None
except Exception:
return None
def extract_instagram_media(post: dict) -> list[dict]:
items: list[dict] = []
resources = [as_dict(item) for item in post.get("resources") or []]
if not resources:
resources = [post]
for index, item in enumerate(resources):
media_type = int(item.get("media_type") or post.get("media_type") or 0)
if media_type == 1:
url = media_value(item.get("thumbnail_url") or post.get("thumbnail_url"))
kind = "photo"
elif media_type == 2:
url = media_value(item.get("video_url") or post.get("video_url"))
kind = "video"
else:
continue
if not url:
continue
items.append(
{
"media_type": kind,
"original_url": url,
"original_attachment_id": str(item.get("pk") or post.get("pk") or index),
"width": item.get("width") or post.get("width"),
"height": item.get("height") or post.get("height"),
"duration_sec": int(float(item.get("video_duration") or post.get("video_duration") or 0)) or None,
"sort_order": index,
}
)
return items
class NeverRaised(Exception):
pass
def load_instagrapi():
from instagrapi import Client
import instagrapi.exceptions as exc
return Client, {
"challenge": getattr(exc, "ChallengeRequired", NeverRaised),
"login_required": getattr(exc, "LoginRequired", getattr(exc, "ClientLoginRequired", NeverRaised)),
"please_wait": getattr(exc, "PleaseWaitFewMinutes", NeverRaised),
"user_not_found": getattr(exc, "UserNotFound", NeverRaised),
"private_account": getattr(exc, "PrivateAccount", NeverRaised),
}
class InstaParserWorker: class InstaParserWorker:
def __init__(self): def __init__(self) -> None:
self.pool = None self.pool = None
self.heartbeat_interval_sec = 60 self.heartbeat = HeartbeatReporter(WORKER_INSTA_PARSER, 30)
self.heartbeat = HeartbeatReporter("insta-parser", 60) self.client = None
self.heartbeat_task: asyncio.Task | None = None self.exceptions: dict[str, type[BaseException]] = {}
self.client: Client | None = None
self.session_file = Path("insta_session.json")
async def init(self): async def init(self) -> None:
self.pool = await get_pool() self.pool = await get_pool()
async def get_active_source(self) -> dict | None: async def active_source(self) -> dict | None:
async with self.pool.acquire() as conn: row = await self.pool.fetchrow(
row = await conn.fetchrow(
""" """
SELECT id, external_id, name, last_checked_at, last_parsed_at SELECT *
FROM sources FROM sources
WHERE platform = $1 AND active = TRUE WHERE platform=$1
ORDER BY last_checked_at NULLS FIRST AND active=TRUE
AND archived_at IS NULL
ORDER BY last_checked_at NULLS FIRST, priority ASC, id ASC
LIMIT 1 LIMIT 1
""", """,
PLATFORM_INSTAGRAM PLATFORM_INSTAGRAM,
) )
return dict(row) if row else None return dict(row) if row else None
async def get_known_hashes(self, hashes: list[str]) -> set[str]: async def mark_source_error(self, source_id: int, message: str) -> None:
await self.pool.execute(
"""
UPDATE sources
SET status=$2,
status_msg=$3,
last_checked_at=NOW(),
updated_at=NOW()
WHERE id=$1
""",
source_id,
SOURCE_STATUS_ERROR,
message[:1000],
)
async def deactivate_source(self, source_id: int, message: str) -> None:
await self.pool.execute(
"""
UPDATE sources
SET active=FALSE,
status=$2,
status_msg=$3,
last_checked_at=NOW(),
updated_at=NOW()
WHERE id=$1
""",
source_id,
SOURCE_STATUS_ERROR,
message[:1000],
)
async def mark_source_ok(self, source_id: int, last_parsed_at: datetime | None) -> None:
await self.pool.execute(
"""
UPDATE sources
SET status=$2,
status_msg=NULL,
last_checked_at=NOW(),
last_parsed_at=COALESCE($3, last_parsed_at),
updated_at=NOW()
WHERE id=$1
""",
source_id,
SOURCE_STATUS_OK,
last_parsed_at,
)
async def known_post_ids(self, source_id: int, external_post_ids: list[str]) -> set[str]:
if not external_post_ids:
return set()
rows = await self.pool.fetch(
"""
SELECT external_post_id
FROM raw_posts
WHERE source_id=$1 AND external_post_id=ANY($2::text[])
""",
source_id,
external_post_ids,
)
return {str(row["external_post_id"]) for row in rows}
async def known_content_hashes(self, hashes: list[str]) -> set[str]:
if not hashes: if not hashes:
return set() return set()
async with self.pool.acquire() as conn: rows = await self.pool.fetch(
rows = await conn.fetch(
""" """
SELECT content_hash SELECT content_hash
FROM posts FROM raw_posts
WHERE content_hash=ANY($1::text[]) WHERE content_hash=ANY($1::text[])
""", """,
hashes, hashes,
) )
return {str(r["content_hash"]) for r in rows} return {str(row["content_hash"]) for row in rows}
async def get_known_post_ids(self, source_id: int, external_post_ids: list[str]) -> set[str]: async def set_cooldown(self, hours: int, reason: str) -> None:
if not external_post_ids: until = datetime.now(tz=timezone.utc) + timedelta(hours=max(1, hours))
return set() await self.pool.execute(
async with self.pool.acquire() as conn:
try:
numeric_ids = [int(pk) for pk in external_post_ids]
rows = await conn.fetch(
""" """
SELECT vk_post_id INSERT INTO app_settings(key, value_json, value_type, title, description, category)
FROM posts VALUES('insta_cooldown_until', $1::jsonb, 'str', 'Cooldown until', $2, 'Instagram Parser')
WHERE source_id = $1 AND vk_post_id = ANY($2::bigint[]) ON CONFLICT (key) DO UPDATE
""", SET value_json=$1::jsonb,
source_id, description=$2,
numeric_ids,
)
return {str(r["vk_post_id"]) for r in rows}
except ValueError:
return set()
async def deactivate_source(self, source_id: int, reason: str) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
"""
UPDATE sources
SET active = FALSE,
status = $1,
status_msg = $2,
last_checked_at = NOW(),
updated_at=NOW() updated_at=NOW()
WHERE id = $3
""", """,
SOURCE_STATUS_ERROR, json.dumps(until.isoformat()),
reason[:1000], reason[:1000],
source_id,
) )
async def mark_source_ok(self, source_id: int, last_parsed_at: datetime | None = None) -> None: async def cooldown_until(self) -> datetime | None:
async with self.pool.acquire() as conn: value = await fetch_setting("insta_cooldown_until", "")
await conn.execute( if not value:
""" return None
UPDATE sources
SET status = $1,
status_msg = NULL,
last_checked_at = NOW(),
last_parsed_at = COALESCE($2, last_parsed_at),
updated_at = NOW()
WHERE id = $3
""",
SOURCE_STATUS_OK,
last_parsed_at,
source_id,
)
async def save_cooldown(self, hours: int) -> None:
until = datetime.now(tz=timezone.utc) + timedelta(hours=hours)
async with self.pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO app_settings (key, value, value_type)
VALUES ('insta_cooldown_until', $1, 'string')
ON CONFLICT (key) DO UPDATE SET value = $1
""",
until.isoformat()
)
async def get_cooldown_until(self) -> datetime | None:
async with self.pool.acquire() as conn:
val = await conn.fetchval("SELECT value FROM app_settings WHERE key = 'insta_cooldown_until'")
if val:
try: try:
dt = datetime.fromisoformat(val) dt = datetime.fromisoformat(str(value))
if dt.tzinfo is None: return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError: except ValueError:
pass
return None return None
async def save_post_and_media(self, source_id: int, post: dict, status: str, skip_reason: str | None) -> int | None: def setup_client(self, login: str, password: str, proxy: str, session_path: str) -> None:
text = (post.get("caption_text") or "").strip() if self.client is not None:
content_hash = make_content_hash(text) return
posted_at = post.get("taken_at") Client, exceptions = load_instagrapi()
if not posted_at: self.exceptions = exceptions
posted_at = datetime.now(tz=timezone.utc) client = Client()
elif posted_at.tzinfo is None: if proxy:
posted_at = posted_at.replace(tzinfo=timezone.utc) client.set_proxy(proxy)
path = Path(session_path or "insta_session.json")
if path.exists():
client.load_settings(path)
client.login(login, password)
client.dump_settings(path)
self.client = client
pk = int(post["pk"]) async def save_post(self, source: dict, post: dict, status: str, skip_reason: str | None, media: list[dict]) -> int | None:
source_id = int(source["id"])
photos = [] external_post_id = str(post["pk"])
videos = [] raw_text = str(post.get("caption_text") or "").strip()
media_ids = ",".join(item["original_attachment_id"] for item in media)
media_type = post.get("media_type") text_hash = make_hash(raw_text)
if media_type == 1: content_hash = make_hash(raw_text, media_ids)
url = post.get("thumbnail_url") has_downloadable_media = any(item["media_type"] == "photo" for item in media)
if url: photos.append(url)
elif media_type == 2:
url = post.get("video_url")
if url: videos.append(url)
elif media_type == 8:
for resource in post.get("resources", []):
if resource.get("media_type") == 1:
photos.append(resource.get("thumbnail_url"))
elif resource.get("media_type") == 2:
videos.append(resource.get("video_url"))
async with self.pool.acquire() as conn: async with self.pool.acquire() as conn:
async with conn.transaction(): async with conn.transaction():
post_id = await conn.fetchval( raw_post_id = await conn.fetchval(
""" """
INSERT INTO posts ( INSERT INTO raw_posts(
source_id, vk_post_id, vk_owner_id, posted_at, source_id, platform, external_post_id, external_owner_id,
raw_text, raw_json, content_hash, original_url, raw_text, raw_json, text_hash, content_hash,
status, skip_reason posted_at, status, skip_reason
) )
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8::post_status,$9) VALUES($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11,$12)
ON CONFLICT (source_id, vk_post_id) DO NOTHING ON CONFLICT (source_id, external_post_id) DO NOTHING
RETURNING id RETURNING id
""", """,
source_id, source_id,
pk, PLATFORM_INSTAGRAM,
int(post.get("user", {}).get("pk") or 0), external_post_id,
posted_at, media_user_pk(post),
text, original_url(post),
raw_text,
json.dumps(post, default=str, ensure_ascii=False), json.dumps(post, default=str, ensure_ascii=False),
text_hash,
content_hash, content_hash,
media_taken_at(post),
status, status,
skip_reason, skip_reason,
) )
if post_id is None: if raw_post_id is None:
return None return None
for p_url in photos: for item in media:
if not p_url: continue
await conn.execute( await conn.execute(
""" """
INSERT INTO post_media (post_id, media_type, vk_url) INSERT INTO raw_post_media(
VALUES ($1, 'photo', $2) raw_post_id, platform, media_type, original_url, original_attachment_id,
width, height, duration_sec, sort_order, status, error
)
VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
""", """,
post_id, raw_post_id,
str(p_url), PLATFORM_INSTAGRAM,
item["media_type"],
item["original_url"],
item["original_attachment_id"],
item.get("width"),
item.get("height"),
item.get("duration_sec"),
item["sort_order"],
MEDIA_STATUS_LINK_ONLY if item["media_type"] == "video" else MEDIA_STATUS_PENDING,
"instagram video link only" if item["media_type"] == "video" else None,
) )
for v_url in videos: if status == POST_STATUS_STORAGE_PENDING and has_downloadable_media:
if not v_url: continue
await conn.execute( await conn.execute(
""" """
INSERT INTO post_media (post_id, media_type, vk_url) INSERT INTO jobs(type, entity_type, entity_id, payload_json, status)
VALUES ($1, 'video', $2) VALUES($1, 'raw_post', $2, '{}'::jsonb, 'pending')
""",
post_id,
str(v_url),
)
if status == POST_STATUS_STORAGE_PENDING:
await conn.execute(
"""
INSERT INTO jobs (job_type, entity_type, entity_id, payload, status)
VALUES ('media.process', 'post', $1, '{}'::jsonb, 'PENDING')
ON CONFLICT DO NOTHING ON CONFLICT DO NOTHING
""", """,
post_id, JOB_TYPE_VK_STORAGE_COPY,
raw_post_id,
) )
return post_id return int(raw_post_id)
def setup_client(self, login: str, password: str, proxy: str | None) -> bool: async def parse_source(self, source: dict, fetch_count: int, request_pause_sec: float) -> int:
if self.client: username = str(source.get("external_id") or "").strip().lstrip("@")
return True if not username:
await self.mark_source_error(int(source["id"]), "empty instagram username")
return 0
if Client is None:
logger.error("instagrapi is not installed! Cannot setup client.")
raise Exception("Module instagrapi is not installed. Please install it.")
self.client = Client()
if proxy:
self.client.set_proxy(proxy)
if self.session_file.exists():
try:
self.client.load_settings(self.session_file)
self.client.get_timeline_feed()
logger.info("Instagram session loaded successfully")
return True
except Exception as e:
logger.warning(f"Session invalid, will re-login: {e}")
self.session_file.unlink(missing_ok=True)
logger.info(f"Logging into Instagram as {login}...")
try:
self.client.login(login, password)
self.client.dump_settings(self.session_file)
logger.info("Instagram login successful, session saved")
return True
except ChallengeRequired as e:
logger.error(f"Challenge Required during login: {e}")
raise
except Exception as e:
logger.error(f"Failed to login: {e}")
self.client = None
raise
async def parse_single_source(self, source: dict, fetch_count: int, min_text_length: int) -> None:
source_id = source["id"]
username = source["external_id"]
logger.info(f"Parsing Instagram source: {username}")
try:
user_id = await asyncio.to_thread(self.client.user_id_from_username, username) user_id = await asyncio.to_thread(self.client.user_id_from_username, username)
if request_pause_sec:
await asyncio.sleep(request_pause_sec)
medias = await asyncio.to_thread(self.client.user_medias, user_id, fetch_count) medias = await asyncio.to_thread(self.client.user_medias, user_id, fetch_count)
except (UserNotFound, PrivateAccount) as e: posts = [as_dict(item) for item in medias]
logger.warning(f"Source {username} is inaccessible: {e}") if not posts:
await self.deactivate_source(source_id, str(e)) await self.mark_source_ok(int(source["id"]), None)
return return 0
except Exception as e:
logger.error(f"Error fetching {username}: {e}")
raise
if not medias: known_ids = await self.known_post_ids(int(source["id"]), [str(post.get("pk")) for post in posts if post.get("pk")])
await self.mark_source_ok(source_id) candidates = [post for post in posts if str(post.get("pk")) not in known_ids]
return
candidates = [m.model_dump() for m in medias] dedupe_content_hash = await fetch_bool_setting("insta_dedupe_content_hash", True)
pks = [str(m["pk"]) for m in candidates] hash_by_pk: dict[str, str] = {}
if dedupe_content_hash:
known_pks = await self.get_known_post_ids(source_id, pks) hashes = []
candidates = [c for c in candidates if str(c["pk"]) not in known_pks] for post in candidates:
media = extract_instagram_media(post)
candidate_hashes = [make_content_hash(c.get("caption_text") or "") for c in candidates] media_ids = ",".join(item["original_attachment_id"] for item in media)
known_hashes = await self.get_known_hashes(candidate_hashes) content_hash = make_hash(str(post.get("caption_text") or "").strip(), media_ids)
hash_by_pk[str(post["pk"])] = content_hash
hashes.append(content_hash)
known_hashes = await self.known_content_hashes(hashes)
else:
known_hashes = set()
min_text_length = max(0, await fetch_int_setting("parser_min_text_length", 0))
skip_empty_text = await fetch_bool_setting("parser_skip_empty_text", True)
skip_no_media = await fetch_bool_setting("parser_skip_no_media", True)
skip_short_text = await fetch_bool_setting("parser_skip_text_too_short", True)
store_skipped = await fetch_bool_setting("parser_store_skipped_posts", False)
saved = 0 saved = 0
max_seen_dt = None max_seen: datetime | None = None
for post in candidates: for post in candidates:
try: posted_at = media_taken_at(post)
post_dt = post.get("taken_at") if max_seen is None or posted_at > max_seen:
if post_dt: max_seen = posted_at
if post_dt.tzinfo is None: content_hash = hash_by_pk.get(str(post.get("pk")))
post_dt = post_dt.replace(tzinfo=timezone.utc) if dedupe_content_hash and content_hash in known_hashes:
if max_seen_dt is None or post_dt > max_seen_dt:
max_seen_dt = post_dt
text = (post.get("caption_text") or "").strip()
content_hash = make_content_hash(text)
if content_hash in known_hashes:
continue continue
media_type = post.get("media_type") text = str(post.get("caption_text") or "").strip()
has_media = media_type in (1, 2, 8) media = extract_instagram_media(post)
skip_reason = None
if skip_empty_text and not text:
skip_reason = "empty_text"
elif skip_no_media and not media:
skip_reason = "no_media"
elif skip_short_text and len(text) < min_text_length:
skip_reason = "text_too_short"
if not has_media: if skip_reason and not store_skipped:
if await self.save_post_and_media(source_id, post, POST_STATUS_SKIPPED, "no_media"):
saved += 1
continue continue
status = POST_STATUS_SKIPPED if skip_reason else POST_STATUS_STORAGE_PENDING
if len(text) < min_text_length: raw_id = await self.save_post(source, post, status, skip_reason, media)
if await self.save_post_and_media(source_id, post, POST_STATUS_SKIPPED, "text_too_short"): if raw_id:
saved += 1 saved += 1
continue if content_hash:
known_hashes.add(content_hash)
if await self.save_post_and_media(source_id, post, POST_STATUS_STORAGE_PENDING, None): await self.mark_source_ok(int(source["id"]), max_seen or source.get("last_parsed_at"))
saved += 1 logger.info("Parsed Instagram source {}: fetched={} known={} saved={}", username, len(posts), len(known_ids), saved)
except Exception as e: return saved
logger.error(f"Failed to save insta post {post.get('pk')}: {e}")
await self.mark_source_ok(source_id, max_seen_dt) async def run_once(self) -> None:
logger.info(f"Source {username}: saved {saved} posts") enabled = await is_worker_enabled(self.pool, WORKER_INSTA_PARSER)
if not enabled:
await self.heartbeat.beat(self.pool, status="disabled", force=True)
return
async def run_once(self): until = await self.cooldown_until()
login = str(await fetch_setting("insta_login", "")).strip() if until and until > datetime.now(tz=timezone.utc):
password = str(await fetch_setting("insta_password", "")).strip() await self.heartbeat.beat(self.pool, status="cooldown", meta={"until": until.isoformat()})
proxy = str(await fetch_setting("insta_proxy_url", "")).strip() or None return
fetch_count = await fetch_int_setting("insta_fetch_count", 5)
delay_base = await fetch_int_setting("insta_delay_base_minutes", 35) login = str(await fetch_setting("insta_login", "") or "").strip()
delay_random = await fetch_int_setting("insta_delay_random_minutes", 5) password = str(await fetch_setting("insta_password", "") or "").strip()
cooldown_hours = await fetch_int_setting("insta_cooldown_hours", 12) proxy = str(await fetch_setting("insta_proxy_url", "") or "").strip()
min_text_length = await fetch_int_setting("min_text_length", settings.min_text_length) session_path = str(await fetch_setting("insta_session_path", "insta_session.json") or "").strip()
fetch_count = max(1, min(20, await fetch_int_setting("insta_fetch_count", 5)))
cooldown_hours = max(1, await fetch_int_setting("insta_cooldown_hours", 12))
request_pause_sec = max(0.0, await fetch_float_setting("insta_request_pause_sec", 2.0))
if not login or not password: if not login or not password:
logger.warning("Instagram login/password not set in app_settings. Skipping.") await self.heartbeat.beat(self.pool, status="missing_credentials")
return
cooldown_until = await self.get_cooldown_until()
if cooldown_until and cooldown_until > datetime.now(tz=timezone.utc):
logger.info(f"Insta parser is in cooldown until {cooldown_until}. Skipping.")
return return
try: try:
await asyncio.to_thread(self.setup_client, login, password, proxy) await asyncio.to_thread(self.setup_client, login, password, proxy, session_path)
except ChallengeRequired as e: except Exception as exc:
error_msg = f"Challenge Required! Cannot login. Need manual verification.\n{e}" await self.set_cooldown(cooldown_hours, f"login failed: {exc}")
logger.error(error_msg) await send_parser_error_alert(WORKER_INSTA_PARSER, "login", str(exc))
await send_parser_error_alert("Instagram Parser", "Login Process", error_msg)
await self.save_cooldown(cooldown_hours)
return
except Exception as e:
logger.error(f"Login setup failed: {e}")
return return
source = await self.get_active_source() source = await self.active_source()
if not source: if not source:
logger.debug("No active instagram sources found.") await self.heartbeat.beat(self.pool, status="idle")
return return
try: try:
await self.parse_single_source(source, fetch_count, min_text_length) await self.heartbeat.beat(self.pool, status="running", meta={"source": source.get("external_id")})
await self.parse_source(source, fetch_count, request_pause_sec)
except (self.exceptions.get("user_not_found", NeverRaised), self.exceptions.get("private_account", NeverRaised)) as exc:
await self.deactivate_source(int(source["id"]), str(exc))
await send_parser_error_alert(WORKER_INSTA_PARSER, str(source.get("external_id") or ""), str(exc))
except (
self.exceptions.get("challenge", NeverRaised),
self.exceptions.get("login_required", NeverRaised),
self.exceptions.get("please_wait", NeverRaised),
) as exc:
self.client = None
await self.mark_source_error(int(source["id"]), str(exc))
await self.set_cooldown(cooldown_hours, str(exc))
await send_parser_error_alert(WORKER_INSTA_PARSER, str(source.get("external_id") or ""), str(exc))
except Exception as exc:
await self.mark_source_error(int(source["id"]), str(exc))
logger.exception("Unexpected Instagram source error {}: {}", source.get("external_id"), exc)
delay = delay_base * 60 + random.uniform(-delay_random * 60, delay_random * 60) async def run_loop(self) -> None:
delay = max(60, delay) await self.init()
logger.info(f"Sleeping for {delay/60:.1f} minutes before next parse.") logger.info("{} started", WORKER_INSTA_PARSER)
while True:
try:
await self.run_once()
except Exception as exc:
logger.exception("Instagram parser loop error: {}", exc)
base = max(1, await fetch_int_setting("insta_delay_base_minutes", 35))
spread = max(0, await fetch_int_setting("insta_delay_random_minutes", 5))
delay = max(60.0, base * 60 + random.uniform(-spread * 60, spread * 60))
await asyncio.sleep(delay) await asyncio.sleep(delay)
except ChallengeRequired as e:
error_msg = f"Challenge Required during parsing!\n{e}"
logger.error(error_msg)
await send_parser_error_alert("Instagram Parser", source.get("external_id", "Unknown"), error_msg)
await self.save_cooldown(cooldown_hours)
except PleaseWaitFewMinutes as e:
error_msg = f"Rate limit hit! Cooling down.\n{e}"
logger.warning(error_msg)
await send_parser_error_alert("Instagram Parser", source.get("external_id", "Unknown"), error_msg)
await self.save_cooldown(cooldown_hours)
except Exception as e:
logger.error(f"Unexpected error parsing {source.get('external_id')}: {e}")
await asyncio.sleep(60)
async def _heartbeat_loop(self) -> None: async def main() -> None:
while True: logger.remove()
try: logger.add(lambda msg: print(msg, end=""))
await self.heartbeat.beat(self.pool, {"state": "polling"})
except Exception as e:
logger.warning(f"Insta-parser heartbeat failed: {e}")
await asyncio.sleep(self.heartbeat_interval_sec)
async def run_loop(self):
await self.init()
logger.info("Instagram Parser started")
await self.heartbeat.beat(self.pool, {"state": "started"}, force=True)
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
try:
while True:
try:
await self.heartbeat.beat(self.pool, {"state": "running"})
await self.run_once()
except Exception as e:
logger.exception(f"Insta-parser loop error: {e}")
await asyncio.sleep(10)
finally:
if self.heartbeat_task and not self.heartbeat_task.done():
self.heartbeat_task.cancel()
try:
await self.heartbeat_task
except asyncio.CancelledError:
pass
async def main():
worker = InstaParserWorker() worker = InstaParserWorker()
await worker.run_loop() await worker.run_loop()
if __name__ == "__main__": if __name__ == "__main__":
import sys
logger.remove()
logger.add(
sys.stdout,
level=settings.log_level,
format=(
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
"<level>{level: <8}</level> | "
"<cyan>{name}</cyan>:<cyan>{line}</cyan> — <level>{message}</level>"
),
)
asyncio.run(main()) asyncio.run(main())