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
('insta_login', 'Instagram Login', 'string', ''),
('insta_password', 'Instagram Password', 'secret', ''),
('insta_proxy_url', 'Instagram Proxy URL (http://user:pass@ip:port)', 'string', ''),
('insta_fetch_count', 'Instagram: количество проверяемых последних постов', 'int', '5'),
('insta_delay_base_minutes', 'Instagram: пауза между аккаунтами (минут)', 'int', '35'),
('insta_delay_random_minutes', 'Instagram: разброс паузы (± минут)', 'int', '5'),
('insta_cooldown_hours', 'Instagram: отлежка при лимитах (часов)', 'int', '12')
('insta_login', '""'::jsonb, 'str', 'Instagram login', 'Username for the Instagram account used by instagrapi.', 'Instagram Parser'),
('insta_password', '""'::jsonb, 'secret', 'Instagram password', 'Password for the Instagram account used by instagrapi.', 'Instagram Parser'),
('insta_proxy_url', '""'::jsonb, 'str', 'Instagram proxy URL', 'Optional stable proxy, for example http://user:pass@host:port.', 'Instagram Parser'),
('insta_session_path', '"insta_session.json"'::jsonb, 'str', 'Instagram session path', 'Path to the persisted instagrapi session settings file.', 'Instagram Parser'),
('insta_fetch_count', '5'::jsonb, 'int', 'Posts to inspect', 'How many latest posts to inspect per account visit.', 'Instagram Parser'),
('insta_delay_base_minutes', '35'::jsonb, 'int', 'Account visit delay, minutes', 'Base pause after checking one Instagram account.', 'Instagram Parser'),
('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;
+75 -38
View File
@@ -22,7 +22,7 @@ from fastapi.templating import Jinja2Templates
from loguru import logger
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 .security import hash_password, new_token, token_hash, verify_password
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
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"))
),
-1,
@@ -956,9 +956,9 @@ def parse_source_line(line: str) -> dict[str, str]:
if url_index < 0:
if len(parts) == 1:
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": "", "error": "Не нашёл ссылку VK"}
return {"name": "", "tag": "", "url": "", "error": "Не нашёл ссылку"}
url = parts[url_index].strip()
before_url = parts[:url_index]
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": ""}
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]]:
lines = [line for line in (lines_text or "").splitlines() if line.strip()]
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()
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[]))
""",
[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")],
)
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 = []
for idx, item in enumerate(parsed, start=1):
url = item.get("url") or ""
external_id = normalize_vk_source(url) if url else ""
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 ""
row = {
"line_no": idx,
"platform": PLATFORM_VK,
"platform": platform,
"name": item.get("name") or "",
"tag": normalize_hash_tag(item.get("tag") or external_id, external_id or "source"),
"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:
row["error"] = "Такой тэг уже есть"
if not row["error"] and not external_id:
row["error"] = "Не удалось разобрать VK-ссылку"
if not row["error"]:
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:
screen_name, owner_id, resolved_name = await client.resolve_group(url)
row["external_id"] = screen_name
@@ -2318,16 +2364,7 @@ async def sources_bulk_create(
if not isinstance(item, dict) or not item.get("ok"):
continue
try:
url_val = str(item.get("url") or "").strip()
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)
platform = str(item.get("platform") or PLATFORM_VK).strip().lower() or PLATFORM_VK
row = await pool.fetchrow(
"""
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
RETURNING id
""",
platform_val,
str(item.get("name") or external_id_val or "").strip(),
normalize_hash_tag(str(item.get("tag") or external_id_val or ""), external_id_val or "source"),
url_val,
external_id_val,
platform,
str(item.get("name") or item.get("external_id") or "").strip(),
normalize_hash_tag(str(item.get("tag") or item.get("external_id") or ""), str(item.get("external_id") or "source")),
str(item.get("url") or "").strip(),
str(item.get("external_id") or "").strip(),
item.get("external_owner_id"),
bool(item.get("active", True)),
user["id"],
)
if row:
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:
logger.exception("Bulk source insert failed: {}", item)
return redirect(f"/sources?q=&status_filter=&created={created}")
@@ -2380,18 +2417,12 @@ async def source_create(
require_csrf(user, csrf_token)
platform = platform.strip().lower() or PLATFORM_VK
resolved_url = url.strip()
if "instagram.com" in resolved_url and platform == PLATFORM_VK:
if platform == PLATFORM_VK and source_platform_from_url(resolved_url) == PLATFORM_INSTAGRAM:
platform = PLATFORM_INSTAGRAM
if platform == PLATFORM_VK:
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 ""
if platform == PLATFORM_INSTAGRAM:
external_id, resolved_url = normalize_instagram_source(resolved_url)
else:
external_id = ""
external_id = normalize_vk_source(resolved_url)
external_owner_id = None
resolved_name = name.strip()
status_value = "new"
@@ -2472,7 +2503,13 @@ async def source_update(
return redirect("/login")
require_csrf(user, csrf_token)
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()
await pool.execute(
"""
@@ -2491,7 +2528,7 @@ async def source_update(
platform,
name.strip(),
normalize_hash_tag(tag or external_id or name, external_id or "source"),
url.strip(),
resolved_url,
external_id,
active == "on",
priority,
@@ -17,6 +17,7 @@
<label class="label"><span class="label-text font-bold">Площадка</span></label>
<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="instagram" {% if source and source.platform == "instagram" %}selected{% endif %}>Instagram</option>
</select>
</div>
+7 -8
View File
@@ -83,23 +83,22 @@ async def send_parser_error_alert(parser_name: str, source_name: str, error: str
return
text = (
f"🚨 <b>CRITICAL PARSER ERROR</b>\n"
f"Parser: {parser_name}\n"
f"Source: {source_name}\n\n"
f"Error: {error[:1000]}"
"Parser failed\n"
f"parser: {parser_name}\n"
f"source: {source_name or '-'}\n"
f"error: {error[:1000]}"
)
bot = Bot(token=token)
try:
for recipient_id in recipients:
while True:
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
except TelegramRetryAfter as exc:
await asyncio.sleep(float(exc.retry_after) + 1)
except Exception as e:
logger.error("Failed to send parser alert to {}: {}", recipient_id, e)
except Exception as exc:
logger.error("Failed to send parser alert to {}: {}", recipient_id, exc)
break
finally:
await bot.session.close()
+384 -366
View File
@@ -4,462 +4,480 @@ import asyncio
import hashlib
import json
import random
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
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 typing import Any
from loguru import logger
from ..config import settings
from ..constants import (
JOB_TYPE_VK_STORAGE_COPY,
MEDIA_STATUS_LINK_ONLY,
MEDIA_STATUS_PENDING,
PLATFORM_INSTAGRAM,
POST_STATUS_STORAGE_PENDING,
POST_STATUS_SKIPPED,
POST_STATUS_STORAGE_PENDING,
SOURCE_STATUS_ERROR,
SOURCE_STATUS_OK,
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 ..jobs import is_worker_enabled
from .ai_alerts import send_parser_error_alert
def make_content_hash(text: str) -> str:
return hashlib.sha256((text or "").strip().lower().encode()).hexdigest()
def make_hash(*parts: str) -> str:
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:
def __init__(self):
def __init__(self) -> None:
self.pool = None
self.heartbeat_interval_sec = 60
self.heartbeat = HeartbeatReporter("insta-parser", 60)
self.heartbeat_task: asyncio.Task | None = None
self.client: Client | None = None
self.session_file = Path("insta_session.json")
self.heartbeat = HeartbeatReporter(WORKER_INSTA_PARSER, 30)
self.client = None
self.exceptions: dict[str, type[BaseException]] = {}
async def init(self):
async def init(self) -> None:
self.pool = await get_pool()
async def get_active_source(self) -> dict | None:
async with self.pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, external_id, name, last_checked_at, last_parsed_at
FROM sources
WHERE platform = $1 AND active = TRUE
ORDER BY last_checked_at NULLS FIRST
LIMIT 1
""",
PLATFORM_INSTAGRAM
)
async def active_source(self) -> dict | None:
row = await self.pool.fetchrow(
"""
SELECT *
FROM sources
WHERE platform=$1
AND active=TRUE
AND archived_at IS NULL
ORDER BY last_checked_at NULLS FIRST, priority ASC, id ASC
LIMIT 1
""",
PLATFORM_INSTAGRAM,
)
return dict(row) if row else None
async def get_known_hashes(self, hashes: list[str]) -> set[str]:
if not hashes:
return set()
async with self.pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT content_hash
FROM posts
WHERE content_hash = ANY($1::text[])
""",
hashes,
)
return {str(r["content_hash"]) for r in rows}
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 get_known_post_ids(self, source_id: int, external_post_ids: list[str]) -> set[str]:
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()
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
FROM posts
WHERE source_id = $1 AND vk_post_id = ANY($2::bigint[])
""",
source_id,
numeric_ids,
)
return {str(r["vk_post_id"]) for r in rows}
except ValueError:
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 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()
WHERE id = $3
""",
SOURCE_STATUS_ERROR,
reason[:1000],
source_id,
)
async def known_content_hashes(self, hashes: list[str]) -> set[str]:
if not hashes:
return set()
rows = await self.pool.fetch(
"""
SELECT content_hash
FROM raw_posts
WHERE content_hash=ANY($1::text[])
""",
hashes,
)
return {str(row["content_hash"]) for row in rows}
async def mark_source_ok(self, source_id: int, last_parsed_at: datetime | None = None) -> None:
async with self.pool.acquire() as conn:
await conn.execute(
"""
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 set_cooldown(self, hours: int, reason: str) -> None:
until = datetime.now(tz=timezone.utc) + timedelta(hours=max(1, hours))
await self.pool.execute(
"""
INSERT INTO app_settings(key, value_json, value_type, title, description, category)
VALUES('insta_cooldown_until', $1::jsonb, 'str', 'Cooldown until', $2, 'Instagram Parser')
ON CONFLICT (key) DO UPDATE
SET value_json=$1::jsonb,
description=$2,
updated_at=NOW()
""",
json.dumps(until.isoformat()),
reason[:1000],
)
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:
dt = datetime.fromisoformat(val)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except ValueError:
pass
return None
async def cooldown_until(self) -> datetime | None:
value = await fetch_setting("insta_cooldown_until", "")
if not value:
return None
try:
dt = datetime.fromisoformat(str(value))
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
except ValueError:
return None
async def save_post_and_media(self, source_id: int, post: dict, status: str, skip_reason: str | None) -> int | None:
text = (post.get("caption_text") or "").strip()
content_hash = make_content_hash(text)
posted_at = post.get("taken_at")
if not posted_at:
posted_at = datetime.now(tz=timezone.utc)
elif posted_at.tzinfo is None:
posted_at = posted_at.replace(tzinfo=timezone.utc)
def setup_client(self, login: str, password: str, proxy: str, session_path: str) -> None:
if self.client is not None:
return
Client, exceptions = load_instagrapi()
self.exceptions = exceptions
client = Client()
if proxy:
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"])
photos = []
videos = []
media_type = post.get("media_type")
if media_type == 1:
url = post.get("thumbnail_url")
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 def save_post(self, source: dict, post: dict, status: str, skip_reason: str | None, media: list[dict]) -> int | None:
source_id = int(source["id"])
external_post_id = str(post["pk"])
raw_text = str(post.get("caption_text") or "").strip()
media_ids = ",".join(item["original_attachment_id"] for item in media)
text_hash = make_hash(raw_text)
content_hash = make_hash(raw_text, media_ids)
has_downloadable_media = any(item["media_type"] == "photo" for item in media)
async with self.pool.acquire() as conn:
async with conn.transaction():
post_id = await conn.fetchval(
raw_post_id = await conn.fetchval(
"""
INSERT INTO posts (
source_id, vk_post_id, vk_owner_id, posted_at,
raw_text, raw_json, content_hash,
status, skip_reason
INSERT INTO raw_posts(
source_id, platform, external_post_id, external_owner_id,
original_url, raw_text, raw_json, text_hash, content_hash,
posted_at, status, skip_reason
)
VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8::post_status,$9)
ON CONFLICT (source_id, vk_post_id) DO NOTHING
VALUES($1,$2,$3,$4,$5,$6,$7::jsonb,$8,$9,$10,$11,$12)
ON CONFLICT (source_id, external_post_id) DO NOTHING
RETURNING id
""",
source_id,
pk,
int(post.get("user", {}).get("pk") or 0),
posted_at,
text,
PLATFORM_INSTAGRAM,
external_post_id,
media_user_pk(post),
original_url(post),
raw_text,
json.dumps(post, default=str, ensure_ascii=False),
text_hash,
content_hash,
media_taken_at(post),
status,
skip_reason,
)
if post_id is None:
if raw_post_id is None:
return None
for p_url in photos:
if not p_url: continue
for item in media:
await conn.execute(
"""
INSERT INTO post_media (post_id, media_type, vk_url)
VALUES ($1, 'photo', $2)
INSERT INTO raw_post_media(
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,
str(p_url),
raw_post_id,
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 not v_url: continue
if status == POST_STATUS_STORAGE_PENDING and has_downloadable_media:
await conn.execute(
"""
INSERT INTO post_media (post_id, media_type, vk_url)
VALUES ($1, 'video', $2)
""",
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')
INSERT INTO jobs(type, entity_type, entity_id, payload_json, status)
VALUES($1, 'raw_post', $2, '{}'::jsonb, 'pending')
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:
if self.client:
return True
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_source(self, source: dict, fetch_count: int, request_pause_sec: float) -> int:
username = str(source.get("external_id") or "").strip().lstrip("@")
if not username:
await self.mark_source_error(int(source["id"]), "empty instagram username")
return 0
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)
medias = await asyncio.to_thread(self.client.user_medias, user_id, fetch_count)
except (UserNotFound, PrivateAccount) as e:
logger.warning(f"Source {username} is inaccessible: {e}")
await self.deactivate_source(source_id, str(e))
return
except Exception as e:
logger.error(f"Error fetching {username}: {e}")
raise
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)
posts = [as_dict(item) for item in medias]
if not posts:
await self.mark_source_ok(int(source["id"]), None)
return 0
if not medias:
await self.mark_source_ok(source_id)
return
known_ids = await self.known_post_ids(int(source["id"]), [str(post.get("pk")) for post in posts if post.get("pk")])
candidates = [post for post in posts if str(post.get("pk")) not in known_ids]
candidates = [m.model_dump() for m in medias]
pks = [str(m["pk"]) for m in candidates]
known_pks = await self.get_known_post_ids(source_id, pks)
candidates = [c for c in candidates if str(c["pk"]) not in known_pks]
candidate_hashes = [make_content_hash(c.get("caption_text") or "") for c in candidates]
known_hashes = await self.get_known_hashes(candidate_hashes)
dedupe_content_hash = await fetch_bool_setting("insta_dedupe_content_hash", True)
hash_by_pk: dict[str, str] = {}
if dedupe_content_hash:
hashes = []
for post in candidates:
media = extract_instagram_media(post)
media_ids = ",".join(item["original_attachment_id"] for item in media)
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
max_seen_dt = None
max_seen: datetime | None = None
for post in candidates:
try:
post_dt = post.get("taken_at")
if post_dt:
if post_dt.tzinfo is None:
post_dt = post_dt.replace(tzinfo=timezone.utc)
if max_seen_dt is None or post_dt > max_seen_dt:
max_seen_dt = post_dt
posted_at = media_taken_at(post)
if max_seen is None or posted_at > max_seen:
max_seen = posted_at
content_hash = hash_by_pk.get(str(post.get("pk")))
if dedupe_content_hash and content_hash in known_hashes:
continue
text = (post.get("caption_text") or "").strip()
content_hash = make_content_hash(text)
if content_hash in known_hashes:
continue
text = str(post.get("caption_text") or "").strip()
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"
media_type = post.get("media_type")
has_media = media_type in (1, 2, 8)
if not has_media:
if await self.save_post_and_media(source_id, post, POST_STATUS_SKIPPED, "no_media"):
saved += 1
continue
if len(text) < min_text_length:
if await self.save_post_and_media(source_id, post, POST_STATUS_SKIPPED, "text_too_short"):
saved += 1
continue
if skip_reason and not store_skipped:
continue
status = POST_STATUS_SKIPPED if skip_reason else POST_STATUS_STORAGE_PENDING
raw_id = await self.save_post(source, post, status, skip_reason, media)
if raw_id:
saved += 1
if content_hash:
known_hashes.add(content_hash)
if await self.save_post_and_media(source_id, post, POST_STATUS_STORAGE_PENDING, None):
saved += 1
except Exception as e:
logger.error(f"Failed to save insta post {post.get('pk')}: {e}")
await self.mark_source_ok(int(source["id"]), max_seen or source.get("last_parsed_at"))
logger.info("Parsed Instagram source {}: fetched={} known={} saved={}", username, len(posts), len(known_ids), saved)
return saved
await self.mark_source_ok(source_id, max_seen_dt)
logger.info(f"Source {username}: saved {saved} posts")
async def run_once(self) -> None:
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):
login = str(await fetch_setting("insta_login", "")).strip()
password = str(await fetch_setting("insta_password", "")).strip()
proxy = str(await fetch_setting("insta_proxy_url", "")).strip() or None
fetch_count = await fetch_int_setting("insta_fetch_count", 5)
delay_base = await fetch_int_setting("insta_delay_base_minutes", 35)
delay_random = await fetch_int_setting("insta_delay_random_minutes", 5)
cooldown_hours = await fetch_int_setting("insta_cooldown_hours", 12)
min_text_length = await fetch_int_setting("min_text_length", settings.min_text_length)
until = await self.cooldown_until()
if until and until > datetime.now(tz=timezone.utc):
await self.heartbeat.beat(self.pool, status="cooldown", meta={"until": until.isoformat()})
return
login = str(await fetch_setting("insta_login", "") or "").strip()
password = str(await fetch_setting("insta_password", "") or "").strip()
proxy = str(await fetch_setting("insta_proxy_url", "") or "").strip()
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:
logger.warning("Instagram login/password not set in app_settings. Skipping.")
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.")
await self.heartbeat.beat(self.pool, status="missing_credentials")
return
try:
await asyncio.to_thread(self.setup_client, login, password, proxy)
except ChallengeRequired as e:
error_msg = f"Challenge Required! Cannot login. Need manual verification.\n{e}"
logger.error(error_msg)
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}")
await asyncio.to_thread(self.setup_client, login, password, proxy, session_path)
except Exception as exc:
await self.set_cooldown(cooldown_hours, f"login failed: {exc}")
await send_parser_error_alert(WORKER_INSTA_PARSER, "login", str(exc))
return
source = await self.get_active_source()
source = await self.active_source()
if not source:
logger.debug("No active instagram sources found.")
await self.heartbeat.beat(self.pool, status="idle")
return
try:
await self.parse_single_source(source, fetch_count, min_text_length)
delay = delay_base * 60 + random.uniform(-delay_random * 60, delay_random * 60)
delay = max(60, delay)
logger.info(f"Sleeping for {delay/60:.1f} minutes before next parse.")
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)
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)
async def _heartbeat_loop(self) -> None:
async def run_loop(self) -> None:
await self.init()
logger.info("{} started", WORKER_INSTA_PARSER)
while True:
try:
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
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)
async def main():
async def main() -> None:
logger.remove()
logger.add(lambda msg: print(msg, end=""))
worker = InstaParserWorker()
await worker.run_loop()
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())