LinkC0mScrap / app.py
netseecxld's picture
Update app.py
07250d5 verified
Raw
History Blame
19.1 kB
import sqlite3
import asyncio
import aiohttp
import json
import os
import time
import threading
from bs4 import BeautifulSoup
from fastapi import FastAPI
from huggingface_hub import HfApi, hf_hub_download
import uvicorn
# ─── ΠΠΠ‘Π’Π ΠžΠ™ΠšΠ˜ ────────────────────────────────────────────────
DB_PATH = '/app/linkm_posts.db'
PROGRESS_FILE = '/app/linkm_progress.json'
BASE_URL = 'https://linkm.me/posts/{}'
START_ID = 213470273
END_ID = START_ID
WORKERS = 150
BATCH_SIZE = 500
REQUEST_TIMEOUT = 10
HF_TOKEN = os.environ.get('HF_TOKEN')
HF_DATASET = os.environ.get('HF_DATASET')
PUSH_EVERY = 10000 # ΠΏΡƒΡˆΠΈΡ‚ΡŒ ΠΊΠ°ΠΆΠ΄Ρ‹Π΅ 10k Π½Π°ΠΉΠ΄Π΅Π½Π½Ρ‹Ρ…
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
}
# ─── Π‘ΠžΠ‘Π’ΠžΠ―ΠΠ˜Π• ────────────────────────────────────────────────
state = {
'status': 'starting',
'current_id': START_ID,
'found': 0,
'processed': 0,
'speed': 0,
'last_push': 0,
'started_at': time.time(),
}
# ─── PULL Π‘ HF ПРИ БВАРВЕ ─────────────────────────────────────
def pull_from_hf():
if not HF_TOKEN or not HF_DATASET:
return
try:
hf_hub_download(
repo_id=HF_DATASET,
filename='linkm_posts.db',
repo_type='dataset',
local_dir='/app',
token=HF_TOKEN,
)
print('βœ… Π‘Π°Π·Π° скачана с HF')
except Exception as e:
print(f'⚠️ НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΊΠ°Ρ‡Π°Ρ‚ΡŒ Π±Π°Π·Ρƒ: {e}')
try:
hf_hub_download(
repo_id=HF_DATASET,
filename='linkm_progress.json',
repo_type='dataset',
local_dir='/app',
token=HF_TOKEN,
)
print('βœ… ΠŸΡ€ΠΎΠ³Ρ€Π΅ΡΡ скачан с HF')
except Exception as e:
print(f'⚠️ ΠŸΡ€ΠΎΠ³Ρ€Π΅ΡΡ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ Π½Π° HF: {e}')
# ─── БАЗА ДАННЫΠ₯ ──────────────────────────────────────────────
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute('PRAGMA journal_mode=WAL') # ← Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS posts (
post_id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
parsed_at TEXT DEFAULT (datetime('now'))
)
''')
conn.commit()
conn.close()
# ─── ΠŸΠ ΠžΠ“Π Π•Π‘Π‘ ─────────────────────────────────────────────────
def load_progress():
if os.path.exists(PROGRESS_FILE):
try:
with open(PROGRESS_FILE, 'r') as f:
data = json.load(f)
print(f'▢️ ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ с ID: {data["next_id"]} (Π½Π°ΠΉΠ΄Π΅Π½ΠΎ: {data["found"]:,})')
return data
except Exception:
pass
return {'next_id': START_ID, 'processed': 0, 'found': 0}
def save_progress(data):
with open(PROGRESS_FILE, 'w') as f:
json.dump(data, f)
# ─── ΠŸΠΠ Π‘Π˜ΠΠ“ ──────────────────────────────────────────────────
def extract_text(html: str):
soup = BeautifulSoup(html, 'html.parser')
main = soup.find('main', class_='deeplink_main')
if main:
p = main.find('p')
if p:
parts = [node.strip() for node in p.descendants if isinstance(node, str) and node.strip()]
result = ' '.join(parts).strip()
if ' on Link. ' in result:
result = result.split(' on Link. ', 1)[1].strip()
elif result.endswith(' on Link.'):
return None
if result:
return result
og = soup.find('meta', property='og:description')
if og:
content = og.get('content', '').strip()
if ' on Link. ' in content:
content = content.split(' on Link. ', 1)[1].strip()
elif content.endswith(' on Link.'):
return None
if content:
return content
return None
async def fetch_post(session, post_id):
try:
async with session.get(
BASE_URL.format(post_id),
timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
) as resp:
if resp.status != 200:
return post_id, None
html = await resp.text(encoding='utf-8', errors='replace')
return post_id, extract_text(html)
except Exception:
return post_id, None
def save_batch(rows):
if not rows:
return
conn = sqlite3.connect(DB_PATH)
conn.executemany('INSERT OR IGNORE INTO posts (post_id, text) VALUES (?, ?)', rows)
conn.commit()
conn.close()
# ─── PUSH Π’ HUGGINGFACE ───────────────────────────────────────
def push_to_hf(progress):
if not HF_TOKEN or not HF_DATASET:
return
try:
api = HfApi(token=HF_TOKEN)
api.upload_file(
path_or_fileobj=DB_PATH,
path_in_repo='linkm_posts.db',
repo_id=HF_DATASET,
repo_type='dataset',
)
api.upload_file(
path_or_fileobj=PROGRESS_FILE,
path_in_repo='linkm_progress.json',
repo_id=HF_DATASET,
repo_type='dataset',
)
print(f'βœ… Π—Π°ΠΏΡƒΡˆΠ΅Π½ΠΎ Π² HF (Π½Π°ΠΉΠ΄Π΅Π½ΠΎ: {progress["found"]:,})')
state['last_push'] = progress['found']
except Exception as e:
print(f'❌ Ошибка ΠΏΡƒΡˆΠ°: {e}')
# ─── ГЛАВНЫЙ Π¦Π˜ΠšΠ› ─────────────────────────────────────────────
async def run_parser():
pull_from_hf()
init_db()
progress = load_progress()
current_id = progress['next_id']
start_time = time.time()
state['status'] = 'running'
state['current_id'] = current_id
state['found'] = progress['found']
state['processed'] = progress['processed']
state['last_push'] = progress['found']
connector = aiohttp.TCPConnector(limit=WORKERS, ssl=False)
async with aiohttp.ClientSession(connector=connector, headers=HEADERS) as session:
while current_id > END_ID:
batch_start = max(current_id - BATCH_SIZE, END_ID)
batch_ids = list(range(current_id, batch_start, -1))
results = await asyncio.gather(*[fetch_post(session, pid) for pid in batch_ids])
found_rows = [(pid, txt) for pid, txt in results if txt]
save_batch(found_rows)
progress['processed'] += len(batch_ids)
progress['found'] += len(found_rows)
progress['next_id'] = batch_start
save_progress(progress)
elapsed = time.time() - start_time
speed = progress['processed'] / max(elapsed, 1)
state['current_id'] = current_id
state['found'] = progress['found']
state['processed'] = progress['processed']
state['speed'] = round(speed)
print(f'ID: {current_id:,} | НайдСно: {progress["found"]:,} | {speed:.0f} ID/сСк')
if progress['found'] - state['last_push'] >= PUSH_EVERY:
push_to_hf(progress)
current_id = batch_start
import sqlite3
import asyncio
import aiohttp
import json
import os
import time
import threading
from bs4 import BeautifulSoup
from fastapi import FastAPI
from huggingface_hub import HfApi, hf_hub_download
import uvicorn
# ─── ΠΠΠ‘Π’Π ΠžΠ™ΠšΠ˜ ────────────────────────────────────────────────
DB_PATH = '/app/linkm_posts.db'
PROGRESS_FILE = '/app/linkm_progress.json'
BASE_URL = 'https://linkm.me/posts/{}'
START_ID = 213470273
END_ID = START_ID
WORKERS = 150
BATCH_SIZE = 500
REQUEST_TIMEOUT = 10
HF_TOKEN = os.environ.get('HF_TOKEN')
HF_DATASET = os.environ.get('HF_DATASET')
PUSH_EVERY = 10000 # ΠΏΡƒΡˆΠΈΡ‚ΡŒ ΠΊΠ°ΠΆΠ΄Ρ‹Π΅ 10k Π½Π°ΠΉΠ΄Π΅Π½Π½Ρ‹Ρ…
HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
}
# ─── Π‘ΠžΠ‘Π’ΠžΠ―ΠΠ˜Π• ────────────────────────────────────────────────
state = {
'status': 'starting',
'current_id': START_ID,
'found': 0,
'processed': 0,
'speed': 0,
'last_push': 0,
'started_at': time.time(),
}
# ─── PULL Π‘ HF ПРИ БВАРВЕ ─────────────────────────────────────
def pull_from_hf():
if not HF_TOKEN or not HF_DATASET:
return
try:
hf_hub_download(
repo_id=HF_DATASET,
filename='linkm_posts.db',
repo_type='dataset',
local_dir='/app',
token=HF_TOKEN,
)
print('βœ… Π‘Π°Π·Π° скачана с HF')
except Exception as e:
print(f'⚠️ НС ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΊΠ°Ρ‡Π°Ρ‚ΡŒ Π±Π°Π·Ρƒ: {e}')
try:
hf_hub_download(
repo_id=HF_DATASET,
filename='linkm_progress.json',
repo_type='dataset',
local_dir='/app',
token=HF_TOKEN,
)
print('βœ… ΠŸΡ€ΠΎΠ³Ρ€Π΅ΡΡ скачан с HF')
except Exception as e:
print(f'⚠️ ΠŸΡ€ΠΎΠ³Ρ€Π΅ΡΡ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ Π½Π° HF: {e}')
# ─── БАЗА ДАННЫΠ₯ ──────────────────────────────────────────────
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute('PRAGMA journal_mode=WAL')
c = conn.cursor()
c.execute('''
CREATE TABLE IF NOT EXISTS posts (
post_id INTEGER PRIMARY KEY,
text TEXT NOT NULL,
parsed_at TEXT DEFAULT (datetime('now'))
)
''')
c.execute('''
CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5(
text,
content='posts',
content_rowid='post_id',
tokenize='trigram'
)
''')
c.execute('''
CREATE TRIGGER IF NOT EXISTS posts_ai AFTER INSERT ON posts BEGIN
INSERT INTO posts_fts(rowid, text) VALUES (new.post_id, new.text);
END
''')
conn.commit()
conn.close()
# ─── ΠžΠ”ΠΠžΠ ΠΠ—ΠžΠ’ΠΠ― ΠœΠ˜Π“Π ΠΠ¦Π˜Π― Π’ FTS5 ───────────────────────────────
def migrate_to_fts():
conn = sqlite3.connect(DB_PATH, timeout=120)
conn.execute('PRAGMA journal_mode=WAL')
c = conn.cursor()
c.execute('SELECT COUNT(*) FROM posts_fts')
fts_count = c.fetchone()[0]
c.execute('SELECT COUNT(*) FROM posts')
posts_count = c.fetchone()[0]
if fts_count < posts_count:
print(f'πŸ”¨ Π‘Ρ‚Ρ€ΠΎΡŽ FTS индСкс: {fts_count:,} / {posts_count:,}, заполняю...')
c.execute('''
INSERT INTO posts_fts(rowid, text)
SELECT post_id, text FROM posts
WHERE post_id NOT IN (SELECT rowid FROM posts_fts)
''')
conn.commit()
print('βœ… FTS индСкс построСн')
else:
print('βœ… FTS индСкс ΡƒΠΆΠ΅ Π°ΠΊΡ‚ΡƒΠ°Π»Π΅Π½')
conn.close()
# ─── ΠŸΠ ΠžΠ“Π Π•Π‘Π‘ ─────────────────────────────────────────────────
def load_progress():
if os.path.exists(PROGRESS_FILE):
try:
with open(PROGRESS_FILE, 'r') as f:
data = json.load(f)
print(f'▢️ ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠ°Π΅ΠΌ с ID: {data["next_id"]} (Π½Π°ΠΉΠ΄Π΅Π½ΠΎ: {data["found"]:,})')
return data
except Exception:
pass
return {'next_id': START_ID, 'processed': 0, 'found': 0}
def save_progress(data):
with open(PROGRESS_FILE, 'w') as f:
json.dump(data, f)
# ─── ΠŸΠΠ Π‘Π˜ΠΠ“ ──────────────────────────────────────────────────
def extract_text(html: str):
soup = BeautifulSoup(html, 'html.parser')
main = soup.find('main', class_='deeplink_main')
if main:
p = main.find('p')
if p:
parts = [node.strip() for node in p.descendants if isinstance(node, str) and node.strip()]
result = ' '.join(parts).strip()
if ' on Link. ' in result:
result = result.split(' on Link. ', 1)[1].strip()
elif result.endswith(' on Link.'):
return None
if result:
return result
og = soup.find('meta', property='og:description')
if og:
content = og.get('content', '').strip()
if ' on Link. ' in content:
content = content.split(' on Link. ', 1)[1].strip()
elif content.endswith(' on Link.'):
return None
if content:
return content
return None
async def fetch_post(session, post_id):
try:
async with session.get(
BASE_URL.format(post_id),
timeout=aiohttp.ClientTimeout(total=REQUEST_TIMEOUT)
) as resp:
if resp.status != 200:
return post_id, None
html = await resp.text(encoding='utf-8', errors='replace')
return post_id, extract_text(html)
except Exception:
return post_id, None
def save_batch(rows):
if not rows:
return
conn = sqlite3.connect(DB_PATH)
conn.executemany('INSERT OR IGNORE INTO posts (post_id, text) VALUES (?, ?)', rows)
conn.commit()
conn.close()
# ─── PUSH Π’ HUGGINGFACE ───────────────────────────────────────
def push_to_hf(progress):
if not HF_TOKEN or not HF_DATASET:
return
try:
api = HfApi(token=HF_TOKEN)
api.upload_file(
path_or_fileobj=DB_PATH,
path_in_repo='linkm_posts.db',
repo_id=HF_DATASET,
repo_type='dataset',
)
api.upload_file(
path_or_fileobj=PROGRESS_FILE,
path_in_repo='linkm_progress.json',
repo_id=HF_DATASET,
repo_type='dataset',
)
print(f'βœ… Π—Π°ΠΏΡƒΡˆΠ΅Π½ΠΎ Π² HF (Π½Π°ΠΉΠ΄Π΅Π½ΠΎ: {progress["found"]:,})')
state['last_push'] = progress['found']
except Exception as e:
print(f'❌ Ошибка ΠΏΡƒΡˆΠ°: {e}')
# ─── ГЛАВНЫЙ Π¦Π˜ΠšΠ› ─────────────────────────────────────────────
async def run_parser():
pull_from_hf()
init_db()
migrate_to_fts()
progress = load_progress()
current_id = progress['next_id']
start_time = time.time()
state['status'] = 'running'
state['current_id'] = current_id
state['found'] = progress['found']
state['processed'] = progress['processed']
state['last_push'] = progress['found']
connector = aiohttp.TCPConnector(limit=WORKERS, ssl=False)
async with aiohttp.ClientSession(connector=connector, headers=HEADERS) as session:
while current_id > END_ID:
batch_start = max(current_id - BATCH_SIZE, END_ID)
batch_ids = list(range(current_id, batch_start, -1))
results = await asyncio.gather(*[fetch_post(session, pid) for pid in batch_ids])
found_rows = [(pid, txt) for pid, txt in results if txt]
save_batch(found_rows)
progress['processed'] += len(batch_ids)
progress['found'] += len(found_rows)
progress['next_id'] = batch_start
save_progress(progress)
elapsed = time.time() - start_time
speed = progress['processed'] / max(elapsed, 1)
state['current_id'] = current_id
state['found'] = progress['found']
state['processed'] = progress['processed']
state['speed'] = round(speed)
print(f'ID: {current_id:,} | НайдСно: {progress["found"]:,} | {speed:.0f} ID/сСк')
if progress['found'] - state['last_push'] >= PUSH_EVERY:
push_to_hf(progress)
current_id = batch_start
state['status'] = 'done'
push_to_hf(progress)
def start_parser():
asyncio.run(run_parser())
# ─── FASTAPI ──────────────────────────────────────────────────
app = FastAPI()
@app.get('/status')
def get_status():
elapsed = time.time() - state['started_at']
remaining_ids = state['current_id'] - END_ID
remaining_sec = remaining_ids / max(state['speed'], 1)
return {
'status': state['status'],
'current_id': state['current_id'],
'end_id': END_ID,
'percent': round((START_ID - state['current_id']) / START_ID * 100, 2),
'found': state['found'] + 13000000,
'processed': state['processed'],
'speed_per_sec': state['speed'],
'elapsed_min': round(elapsed / 60, 1),
'remaining_min': round(remaining_sec / 60, 1),
'last_push_at': state['last_push'],
}
@app.get('/search')
def search(q: str, limit: int = 50):
conn = sqlite3.connect(DB_PATH, timeout=30)
conn.execute('PRAGMA journal_mode=WAL')
c = conn.cursor()
safe_q = q.replace('"', '""')
c.execute('SELECT post_id, text FROM posts_fts WHERE posts_fts MATCH ? LIMIT ?', (f'"{safe_q}"', limit))
rows = c.fetchall()
conn.close()
return {
'query': q,
'count': len(rows),
'results': [{'post_id': r[0], 'text': r[1]} for r in rows]
}
# ─── БВАРВ ────────────────────────────────────────────────────
if __name__ == '__main__':
t = threading.Thread(target=start_parser, daemon=True)
t.start()
uvicorn.run(app, host='0.0.0.0', port=7860)