"""ETERNAL SourceForge: project discovery + file tree walk + text/archive scan. Discovery: allura_sitemap (13 шардов × ~10k /p/-слагов, lastmod ежедневно, без Cloudflare). /directory/?q= за Turnstile-challenge — мёртв для unattended loop. Курсор шарда: data/sourceforge_sitemap_cursor.txt; пул: data/sourceforge_projects.txt. Liveness: GET https://sourceforge.net/rest/p// (trailing slash обязателен; status "moved"/404 → skip). Мертвы: /api/*, /rest/search/, /rest/p/ (listing). Files: https://sourceforge.net/projects/

/files/ (HTML parse of files_list table) https://sourceforge.net/projects/

/files//download (mirror redirect) Auth: none """ import os as _os BASE = _os.path.dirname(_os.path.abspath(__file__)) _os.makedirs(_os.path.join(BASE, "data"), exist_ok=True) import hashlib import io import re import sqlite3 import sys import tarfile import threading import time import zipfile import requests DB = _os.environ.get("KF_DB") or _os.path.join(BASE, "data", "keys.db") LOG = BASE + r"/data/sourceforge_eternal.log" CURSOR_FILE = BASE + r"/data/sourceforge_cursor.txt" SEEN_FILE = BASE + r"/data/sourceforge_seen.txt" PROJECTS_FILE = BASE + r"/data/sourceforge_projects.txt" N_THREADS = 4 PROJECTS_PER_CYCLE = 5 # 5 projects per cycle → ~80 projects in 16 cycles (~24h) MAX_DEPTH = 3 # max subfolder depth to recurse MAX_FOLDERS_PER_DEPTH = 10 # max folder listings per depth level (rate-limit guard) MAX_ITEMS = 200 # per cycle cap; --once uses min(MAX_ITEMS, 5) FILE_CAP = 500_000 # max single text file ARCHIVE_CAP = 5 * 1024 * 1024 # max source archive to unpack SLEEP = 90 from key_patterns import scan_text GARBAGE = ("xxxx", "your", "YOUR", "EXAMPLE", "example", "n0tr3al", "placeholder", "CHANGEME", "changeme", "dummy", "DUMMY", "abcdefghijklmnopqrst", "1234567890abcdef") TEXT_EXT = (".txt", ".md", ".rst", ".cfg", ".ini", ".conf", ".env", ".xml", ".json", ".yaml", ".yml", ".properties", ".py", ".sh", ".bat", ".ps1", ".php", ".pl", ".rb", ".js", ".ts", ".java", ".c", ".h", ".cpp", ".hpp", ".sql", ".html", ".css", ".dockerfile", ".toml") UA = {"User-Agent": "Wget/1.21.3"} # Curated seed — popular open-source projects likely to bundle text/config files SEED_PROJECTS = [ # IDEs / Editors "notepadplusplus", "codeblocks", "geany", "dev-cpp", # Web / CMS / Database "xampp", "wampserver", "wordpress", "joomla", "drupal", "moodle", "phpmyadmin", "adminer", "mysqlcc", "postgresql", "sqlitebrowser", "heidisql", # Networking / Security "wireshark", "nmap", "putty", "openssh", "openvpn", "torproject", "truecrypt", "veracrypt", "gpg4win", "keepass", "password-safe", # File transfer / FTP "filezilla", "winscp", "smartftp", "cyberduck", # Multimedia "audacity", "vlc", "ffmpeg", "handbrake", "shotcut", "obsproject", "blender", "gimp", "inkscape", # Development tools "gitforwindows", "tortoisegit", "tortoisesvn", "mercurial", "cmake", "mingw", "cygwin", "nsis", "innosetup", "python", "rubyinstaller", "perl", # Utilities "sevenzip", "peazip", "ventoy", "rufus", "crystaldiskinfo", "hwmonitor", "speedfan", "sysinternals", "processhacker", # System "clamwin", "spybot", "ccleaner", "defraggler", "wiztree", "everything", # Emulators / Games "dosbox", "pcsx2", "dolphin-emu", "mame", "desmume", "visualboyadvance", # Science / Math "octave", "scilab", "geogebra", # Office "libreoffice", "openoffice", "abiword", "sumatrapdf", "foxitreader", # Chat / Comms "pidgin", "irssi", "hexchat", "miranda-ng", "thunderbird", # Other "scrcpy.mirror", "portableapps", "chocolate-doom", "golang", "rustdesk", ] # Filter seed to unique non-empty strings SEED_PROJECTS = sorted(set(s for s in SEED_PROJECTS if s and s.strip())) # Discovery: allura_sitemap — полный живой список проектов SF (без Cloudflare) SITEMAP_INDEX = "https://sourceforge.net/allura_sitemap/sitemap.xml" _SHARD_RE = re.compile( r'(https://sourceforge\.net/allura_sitemap/sitemap-\d+\.xml)') _P_SLUG_RE = re.compile(r'sourceforge\.net/p/([a-z0-9][a-z0-9._-]{0,48})/') _PROJ_SLUG_RE = re.compile( r'sourceforge\.net/projects/([a-z0-9][a-z0-9._-]{0,48})/') SITEMAP_CURSOR_FILE = BASE + r"/data/sourceforge_sitemap_cursor.txt" # Session with trust_env=False to bypass local dashscope proxy S = requests.Session() S.trust_env = False def log(msg): line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n" with open(LOG, "a", encoding="utf-8") as f: f.write(line) conn = sqlite3.connect(DB, timeout=120, check_same_thread=False, isolation_level=None) lock = threading.Lock() total = 0 def db_add(key, prov, repo): global total with lock: conn.execute( "INSERT INTO keys (hash, val, prov, status, found, repo) " "VALUES (?,?,?,?,?,?) " "ON CONFLICT(hash) DO UPDATE SET status='NEW', " "found=excluded.found, repo=excluded.repo " "WHERE keys.status IN ('DEAD','ERR')", (hashlib.sha1(key.encode()).hexdigest()[:16], key, prov, "NEW", "sourceforge", repo)) total += 1 def load_cursor(): try: with open(CURSOR_FILE, encoding="utf-8") as f: return int(f.read().strip() or 0) except (FileNotFoundError, ValueError): return 0 def save_cursor(c): with open(CURSOR_FILE, "w", encoding="utf-8") as f: f.write(str(c)) def load_seen(): s = set() try: with open(SEEN_FILE, encoding="utf-8") as f: for line in f: line = line.strip() if line: s.add(line) except FileNotFoundError: pass return s seen = load_seen() def mark_seen(p): seen.add(p) with open(SEEN_FILE, "a", encoding="utf-8") as f: f.write(p + "\n") def is_junk(v): if any(g in v for g in GARBAGE): return True if len(set(v)) < 6: return True return False def sf_get(url, timeout=20): """Thin wrapper with retry/backoff and 403 handling. Uses module session S (trust_env=False) to bypass local proxy. """ for attempt in (1, 2, 3): try: r = S.get(url, headers=UA, timeout=timeout) if r.status_code in (429, 503): ra = int(r.headers.get("Retry-After", 10 * attempt)) time.sleep(ra) continue if r.status_code == 403: # Rate-limited; brief wait then retry time.sleep(5 * attempt) continue if r.status_code == 404: return None if r.status_code != 200: raise RuntimeError(f"HTTP {r.status_code}") return r except (requests.ConnectionError, requests.Timeout, RuntimeError): if attempt == 3: raise time.sleep(5 * attempt) return None def list_dir(project, folder=""): """Parse files_list table from project's files page. Yields (is_folder, name, display_name). is_folder=True: name is relative URL folder path is_folder=False: name is full relative file path (for /download) """ if folder: url = f"https://sourceforge.net/projects/{project}/files/{folder}/" else: url = f"https://sourceforge.net/projects/{project}/files/" try: r = sf_get(url, timeout=20) except Exception: return if r is None: return html = r.text # Find tbody idx = html.find('", idx) tbody_end = html.find("", tbody_start) if tbody_start == -1 or tbody_end == -1: return tbody = html[tbody_start:tbody_end + 8] # Folders — match both relative and absolute href, attributes may span lines esc = re.escape(project) pattern = r']*title="Click to enter' for m in re.finditer(pattern, tbody, re.DOTALL): name = m.group(1) if not name or name.endswith("/stats/timeline"): continue yield True, name, "" # Files pattern = r']*title="Click to download ([^"]+)"' for m in re.finditer(pattern, tbody, re.DOTALL): yield False, m.group(1), m.group(2) def get_mirror_url(download_page_url): """Fetch the download landing page and extract direct mirror URL.""" try: r = S.get(download_page_url, headers=UA, timeout=20, allow_redirects=False) except Exception: return None if r is None: return None # Follow redirect chain to get the final mirror location = r.headers.get("Location", "") if location: return location return None def _read_capped(r, cap): """Stream-read r up to cap bytes; None if oversized. Closes r. Caps BEFORE the full body lands in RAM (Content-Length pre-check + stream-and-abort).""" cl = r.headers.get("Content-Length") if cl and cl.isdigit() and int(cl) > cap: r.close() return None chunks = [] size = 0 try: for chunk in r.iter_content(65536): size += len(chunk) if size > cap: return None chunks.append(chunk) finally: r.close() return b"".join(chunks) def download_file(download_page_url, cap): """Download file from SourceForge via redirect chain, capped at `cap` bytes. The /download URL redirects (302) through mirrors to the actual file. Returns (content, final_url) or (None, None) on failure/oversize. """ for attempt in (1, 2, 3): try: r = S.get(download_page_url, headers=UA, timeout=60, allow_redirects=True, stream=True) if r.status_code == 200: data = _read_capped(r, cap) return (data, r.url) if data else (None, None) if r.status_code in (403, 404): r.close() return None, None if r.status_code == 302: loc = r.headers.get("Location", "") r.close() if loc: r2 = S.get(loc, headers=UA, timeout=60, stream=True) if r2.status_code == 200: data = _read_capped(r2, cap) return (data, loc) if data else (None, None) r2.close() return None, None r.close() raise RuntimeError(f"HTTP {r.status_code}") except (requests.ConnectionError, requests.Timeout, RuntimeError): if attempt == 3: return None, None time.sleep(5 * attempt) return None, None def scan_content(text, repo_label): """Run pattern scan + junk filter, db_add hits.""" n = 0 for prov, key in scan_text(text): if isinstance(key, tuple): key = key[0] if is_junk(key) or len(key) < 30: continue db_add(key, prov, repo_label) n += 1 return n def scan_text_file(content, file_path, project): """Scan text content from a downloaded file.""" return scan_content(content, f"sf:{project}:{file_path}") def scan_archive(content, archive_path, project): """Extract and scan text files from a zip/tar archive.""" n = 0 repo_label = f"sf:{project}:{archive_path}" # Try zip try: zf = zipfile.ZipFile(io.BytesIO(content)) for m in zf.infolist(): if m.is_dir() or m.file_size > FILE_CAP: continue fn = m.filename.lower() if not any(fn.endswith(e) for e in TEXT_EXT): continue try: data = zf.read(m).decode("utf-8", errors="ignore") except Exception: continue n += scan_content(data, repo_label + "/" + m.filename) return n except (zipfile.BadZipFile, Exception): pass # Try tar.gz / tar.bz2 try: tf = tarfile.open(fileobj=io.BytesIO(content), mode="r:*") for m in tf.getmembers(): if m.isfile() and m.size <= FILE_CAP: fn = m.name.lower() if not any(fn.endswith(e) for e in TEXT_EXT): continue try: fobj = tf.extractfile(m) if fobj is None: continue data = fobj.read().decode("utf-8", errors="ignore") except Exception: continue n += scan_content(data, repo_label + "/" + m.name) except Exception: pass return n def walk_project(project): """Walk a project's file tree and collect candidate files. Returns list of (is_archive, path_info) where: is_archive=True: path_info = (archive_url, archive_ext, archive_path) is_archive=False: path_info = (text_url, file_path) Capped by MAX_FOLDERS_PER_DEPTH per level. """ candidates = [] stack = [""] # folder paths relative to /files/ depth = 0 while stack and depth <= MAX_DEPTH: next_stack = [] for folder in stack[:MAX_FOLDERS_PER_DEPTH]: for is_folder, name, _ in list_dir(project, folder): if is_folder: if "/stats/" in name: continue next_stack.append(name) else: ext = _os.path.splitext(name)[1].lower() if ext in TEXT_EXT: candidates.append((False, (name, name))) elif ext in (".zip", ".tar.gz", ".tar.bz2", ".tgz", ".tar", ".gz", ".bz2", ".xz"): candidates.append((True, (name, ext, name))) stack = next_stack depth += 1 return candidates[:MAX_ITEMS] def work(project): """Walk project, download candidates, scan. Returns (ok_keys, fail_count).""" if not project_alive(project): log(f"skip {project}: dead/moved") return 0, 0 ok = 0 fail = 0 candidates = walk_project(project) for is_archive, info in candidates: if is_archive: path, ext, archive_path = info else: path, file_path = info sid = f"{project}:{path}" if sid in seen: continue # Download via redirect chain dl_page = f"https://sourceforge.net/projects/{project}/files/{path}/download" content, final_url = download_file( dl_page, ARCHIVE_CAP if is_archive else FILE_CAP) if content is None: fail += 1 continue if is_archive: if len(content) > ARCHIVE_CAP: continue n = scan_archive(content, archive_path, project) else: if len(content) > FILE_CAP: continue text = content.decode("utf-8", errors="ignore") n = scan_text_file(text, path, project) if n >= 0: ok += n mark_seen(sid) return ok, fail def load_sitemap_cursor(): try: with open(SITEMAP_CURSOR_FILE, encoding="utf-8") as f: return int(f.read().strip() or 0) except (FileNotFoundError, ValueError): return 0 def save_sitemap_cursor(v): tmp = SITEMAP_CURSOR_FILE + ".tmp" with open(tmp, "w", encoding="utf-8") as f: f.write(str(v)) _os.replace(tmp, SITEMAP_CURSOR_FILE) def discover_projects(): """Один шард allura_sitemap за вызов → слаг-пул (ротация по курсору). Курсор.advance только при успешном фетче шарда; ошибка → повтор того же. """ try: r = sf_get(SITEMAP_INDEX, timeout=30) if r is None: return [] shards = _SHARD_RE.findall(r.text) if not shards: return [] ci = load_sitemap_cursor() % len(shards) r2 = sf_get(shards[ci], timeout=90) if r2 is None: return [] except Exception: return [] save_sitemap_cursor(ci + 1) out, seen_l = [], set() for rx in (_P_SLUG_RE, _PROJ_SLUG_RE): for s in rx.findall(r2.text): if s not in seen_l: seen_l.add(s) out.append(s) return out def load_pool(): """Seed + discovered pool, deduped, seed first.""" pool = list(SEED_PROJECTS) known = set(pool) try: with open(PROJECTS_FILE, encoding="utf-8") as f: for line in f: s = line.strip() if s and s not in known: known.add(s) pool.append(s) except FileNotFoundError: pass return pool def append_pool(new_slugs): """Append unseen slugs to PROJECTS_FILE. Returns count added.""" if not new_slugs: return 0 known = set(load_pool()) fresh = [s for s in new_slugs if s not in known] if fresh: with open(PROJECTS_FILE, "a", encoding="utf-8") as f: f.write("\n".join(fresh) + "\n") return len(fresh) def project_alive(project): """REST liveness: /rest/p// (404 или status=moved → False).""" try: r = sf_get(f"https://sourceforge.net/rest/p/{project}/", timeout=15) except Exception: return True # сеть/403 — даём шанс walk'у if r is None: return False try: j = r.json() except ValueError: return True return j.get("status") != "moved" def cycle(): global total total = 0 idx = load_cursor() n_new = append_pool(discover_projects()) if n_new: log(f"discovery sitemap shard: +{n_new} new projects") projects = load_pool() items = [] for _ in range(PROJECTS_PER_CYCLE): p = projects[idx % len(projects)] idx += 1 items.append(p) save_cursor(idx) ok = fail = 0 from concurrent.futures import ThreadPoolExecutor, as_completed with ThreadPoolExecutor(max_workers=N_THREADS) as ex: futs = {ex.submit(work, p): p for p in items} for fut in as_completed(futs): p = futs[fut] try: res_ok, res_fail = fut.result() except Exception: res_ok, res_fail = 0, 1 ok += res_ok fail += res_fail log(f"cycle: idx={idx} projects={len(items)} ok={ok} fail={fail} +{total} keys") def main(): log("sourceforge_eternal started") if "--once" in sys.argv: global MAX_ITEMS, PROJECTS_PER_CYCLE MAX_ITEMS = min(MAX_ITEMS, 5) PROJECTS_PER_CYCLE = 1 cycle() return while True: try: cycle() except Exception as e: log(f"cycle-err {type(e).__name__}: {e}") time.sleep(SLEEP) if __name__ == "__main__": main()