feat: add ClickHouse pipeline, WAF bypass engine, proxy rotation, full SIAX Cloud data stack
Scraper CI / lint (push) Successful in 5s
Scraper CI / lint (push) Successful in 5s
This commit is contained in:
@@ -0,0 +1,99 @@
|
|||||||
|
<yandex>
|
||||||
|
<logger>
|
||||||
|
<level>information</level>
|
||||||
|
<console>true</console>
|
||||||
|
</logger>
|
||||||
|
|
||||||
|
<http_port>8123</http_port>
|
||||||
|
<tcp_port>9000</tcp_port>
|
||||||
|
|
||||||
|
<max_connections>4096</max_connections>
|
||||||
|
<max_concurrent_queries>100</max_concurrent_queries>
|
||||||
|
<max_server_memory_usage_to_ram_ratio>0.75</max_server_memory_usage_to_ram_ratio>
|
||||||
|
<max_memory_usage_for_all_queries>0</max_memory_usage_for_all_queries>
|
||||||
|
|
||||||
|
<mark_cache_size>5368709120</mark_cache_size>
|
||||||
|
<path>/var/lib/clickhouse/</path>
|
||||||
|
<tmp_path>/var/lib/clickhouse/tmp/</tmp_path>
|
||||||
|
<user_files_path>/var/lib/clickhouse/user_files/</user_files_path>
|
||||||
|
|
||||||
|
<users_config>users.xml</users_config>
|
||||||
|
<default_profile>default</default_profile>
|
||||||
|
<default_database>opt_scraped</default_database>
|
||||||
|
|
||||||
|
<timezone>Europe/Stockholm</timezone>
|
||||||
|
|
||||||
|
<query_log>
|
||||||
|
<database>system</database>
|
||||||
|
<table>query_log</table>
|
||||||
|
<partition_by>toYYYYMM(event_date)</partition_by>
|
||||||
|
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||||
|
</query_log>
|
||||||
|
|
||||||
|
<distributed_ddl>
|
||||||
|
<path>/clickhouse/task_queue/ddl</path>
|
||||||
|
</distributed_ddl>
|
||||||
|
|
||||||
|
<merge_tree>
|
||||||
|
<max_suspicious_broken_parts>5</max_suspicious_broken_parts>
|
||||||
|
<min_bytes_for_wide_part>0</min_bytes_for_wide_part>
|
||||||
|
<max_bytes_to_merge_at_max_space_in_pool>10737418240</max_bytes_to_merge_at_max_space_in_pool>
|
||||||
|
</merge_tree>
|
||||||
|
|
||||||
|
<!-- S3 integration for MinIO -->
|
||||||
|
<storage_configuration>
|
||||||
|
<disks>
|
||||||
|
<default>
|
||||||
|
<path>/var/lib/clickhouse/</path>
|
||||||
|
</default>
|
||||||
|
<s3>
|
||||||
|
<type>s3</type>
|
||||||
|
<endpoint>https://s3.siax.io/clickhouse-data/</endpoint>
|
||||||
|
<access_key_id>CHANGE_ME</access_key_id>
|
||||||
|
<secret_access_key>CHANGE_ME</secret_access_key>
|
||||||
|
<region>eu-central</region>
|
||||||
|
<metadata_path>/var/lib/clickhouse/disks/s3/</metadata_path>
|
||||||
|
<send_cache>true</send_cache>
|
||||||
|
</s3>
|
||||||
|
</disks>
|
||||||
|
<policies>
|
||||||
|
<s3_only>
|
||||||
|
<volumes>
|
||||||
|
<main>
|
||||||
|
<disk>s3</disk>
|
||||||
|
</main>
|
||||||
|
</volumes>
|
||||||
|
</s3_only>
|
||||||
|
<tiered>
|
||||||
|
<volumes>
|
||||||
|
<hot>
|
||||||
|
<disk>default</disk>
|
||||||
|
<max_data_part_size>1073741824</max_data_part_size>
|
||||||
|
</hot>
|
||||||
|
<cold>
|
||||||
|
<disk>s3</disk>
|
||||||
|
</cold>
|
||||||
|
</volumes>
|
||||||
|
</tiered>
|
||||||
|
</policies>
|
||||||
|
</storage_configuration>
|
||||||
|
|
||||||
|
<part_log>
|
||||||
|
<database>system</database>
|
||||||
|
<table>part_log</table>
|
||||||
|
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||||
|
</part_log>
|
||||||
|
|
||||||
|
<query_thread_log>
|
||||||
|
<database>system</database>
|
||||||
|
<table>query_thread_log</table>
|
||||||
|
<partition_by>toYYYYMM(event_date)</partition_by>
|
||||||
|
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||||
|
</query_thread_log>
|
||||||
|
|
||||||
|
<asynchronous_insert_log>
|
||||||
|
<database>system</database>
|
||||||
|
<table>asynchronous_insert_log</table>
|
||||||
|
<flush_interval_milliseconds>7500</flush_interval_milliseconds>
|
||||||
|
</asynchronous_insert_log>
|
||||||
|
</yandex>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<yandex>
|
||||||
|
<profiles>
|
||||||
|
<default>
|
||||||
|
<max_memory_usage>8000000000</max_memory_usage>
|
||||||
|
<use_uncompressed_cache>0</use_uncompressed_cache>
|
||||||
|
<load_balancing>random</load_balancing>
|
||||||
|
<max_query_size>10485760</max_query_size>
|
||||||
|
<group_by_overflow_mode>any</group_by_overflow_mode>
|
||||||
|
<timeout_before_checking_execution_speed>0</timeout_before_checking_execution_speed>
|
||||||
|
</default>
|
||||||
|
<readonly>
|
||||||
|
<readonly>1</readonly>
|
||||||
|
</readonly>
|
||||||
|
</profiles>
|
||||||
|
|
||||||
|
<users>
|
||||||
|
<default>
|
||||||
|
<password>CHANGE_ME</password>
|
||||||
|
<networks>
|
||||||
|
<ip>::/0</ip>
|
||||||
|
</networks>
|
||||||
|
<profile>default</profile>
|
||||||
|
<quota>default</quota>
|
||||||
|
<grants>
|
||||||
|
<select>1</select>
|
||||||
|
<insert>1</insert>
|
||||||
|
<alter>1</alter>
|
||||||
|
<create>1</create>
|
||||||
|
<drop>1</drop>
|
||||||
|
</grants>
|
||||||
|
</default>
|
||||||
|
<readonly>
|
||||||
|
<password></password>
|
||||||
|
<networks>
|
||||||
|
<ip>::/0</ip>
|
||||||
|
</networks>
|
||||||
|
<profile>readonly</profile>
|
||||||
|
<quota>default</quota>
|
||||||
|
</readonly>
|
||||||
|
</users>
|
||||||
|
|
||||||
|
<quotas>
|
||||||
|
<default>
|
||||||
|
<interval>
|
||||||
|
<duration>3600</duration>
|
||||||
|
<queries>10000</queries>
|
||||||
|
<errors>100</errors>
|
||||||
|
<result_rows>10000000000</result_rows>
|
||||||
|
<read_rows>100000000000</read_rows>
|
||||||
|
<execution_time>3600</execution_time>
|
||||||
|
</interval>
|
||||||
|
</default>
|
||||||
|
</quotas>
|
||||||
|
</yandex>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# SIAX Cloud Data Stack - Complete BigQuery Replacement
|
||||||
|
# All OPEN SOURCE, self-hosted on SIAX Cloud Server 2
|
||||||
|
#
|
||||||
|
# Stack:
|
||||||
|
# ClickHouse - Columnar DB (BigQuery replacement)
|
||||||
|
# Trino - Distributed SQL query engine
|
||||||
|
# FlareSolverr - Cloudflare bypass
|
||||||
|
# Tor - Anonymous proxy rotation
|
||||||
|
# Apache Superset - BI dashboards
|
||||||
|
# MinIO - S3 object storage (already exists)
|
||||||
|
#
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# CLICKHOUSE - BigQuery ersättning
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:24.12-alpine
|
||||||
|
container_name: clickhouse
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8123:8123" # HTTP API (REST)
|
||||||
|
- "9000:9000" # Native TCP
|
||||||
|
- "9090:9090" # Prometheus metrics
|
||||||
|
volumes:
|
||||||
|
- clickhouse-data:/var/lib/clickhouse
|
||||||
|
- clickhouse-logs:/var/log/clickhouse-server
|
||||||
|
- ./clickhouse/config.xml:/etc/clickhouse-server/config.xml:ro
|
||||||
|
- ./clickhouse/users.xml:/etc/clickhouse-server/users.xml:ro
|
||||||
|
environment:
|
||||||
|
- CLICKHOUSE_DB=opt_scraped
|
||||||
|
- CLICKHOUSE_USER=default
|
||||||
|
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-changeme}
|
||||||
|
ulimits:
|
||||||
|
nofile:
|
||||||
|
soft: 262144
|
||||||
|
hard: 262144
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 8G
|
||||||
|
reservations:
|
||||||
|
memory: 4G
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# TRINO - Distributed SQL (optional, for complex queries)
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
trino:
|
||||||
|
image: trinodb/trino:470
|
||||||
|
container_name: trino
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
volumes:
|
||||||
|
- ./trino/etc:/etc/trino:ro
|
||||||
|
- trino-data:/data/trino
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 4G
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# FLARESOLVERR - Cloudflare bypass
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
flaresolverr:
|
||||||
|
image: ghcr.io/flaresolverr/flaresolverr:latest
|
||||||
|
container_name: flaresolverr
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8191:8191"
|
||||||
|
environment:
|
||||||
|
- LOG_LEVEL=${LOG_LEVEL:-info}
|
||||||
|
- TZ=Europe/Stockholm
|
||||||
|
- CAPTCHA_SOLVER=none
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 1G
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# TOR - Anonymous proxy rotation
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
tor-proxy:
|
||||||
|
image: dperson/torproxy:latest
|
||||||
|
container_name: tor-proxy
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "9050:9050" # SOCKS5
|
||||||
|
- "9051:9051" # Control port
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 256M
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# APACHE SUPERSET - BI Dashboard
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
superset:
|
||||||
|
image: apache/superset:latest
|
||||||
|
container_name: superset
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "8088:8088"
|
||||||
|
environment:
|
||||||
|
- SUPERSET_SECRET_KEY=${SUPERSET_SECRET_KEY:-changeme}
|
||||||
|
- SUPERSET_DATABASE_URI=postgresql://superset:${POSTGRES_PASSWORD:-changeme}@postgres:5432/superset
|
||||||
|
volumes:
|
||||||
|
- superset-data:/app/superset_home
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
memory: 2G
|
||||||
|
depends_on:
|
||||||
|
- clickhouse
|
||||||
|
- postgres
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
# POSTGRES for Superset metadata
|
||||||
|
# ═══════════════════════════════════════════════
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: postgres-superset
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- POSTGRES_DB=superset
|
||||||
|
- POSTGRES_USER=superset
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-changeme}
|
||||||
|
volumes:
|
||||||
|
- postgres-data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
clickhouse-data:
|
||||||
|
clickhouse-logs:
|
||||||
|
trino-data:
|
||||||
|
superset-data:
|
||||||
|
postgres-data:
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""
|
||||||
|
ClickHouse Data Pipeline - Skickar scrapad data direkt till ClickHouse.
|
||||||
|
|
||||||
|
Ersätter:
|
||||||
|
- BigQuery (används aldrig)
|
||||||
|
- MinIO/S3 fil-lagring (mellanlagring)
|
||||||
|
|
||||||
|
Pipeline: Scraper -> Parquet -> ClickHouse (columnar OLAP)
|
||||||
|
"""
|
||||||
|
import os, json, time, gzip
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional, Any
|
||||||
|
|
||||||
|
CLICKHOUSE_HOST = os.getenv("CLICKHOUSE_HOST", "localhost")
|
||||||
|
CLICKHOUSE_PORT = int(os.getenv("CLICKHOUSE_PORT", "8123"))
|
||||||
|
CLICKHOUSE_USER = os.getenv("CLICKHOUSE_USER", "default")
|
||||||
|
CLICKHOUSE_PASSWORD = os.getenv("CLICKHOUSE_PASSWORD", "")
|
||||||
|
CLICKHOUSE_DB = os.getenv("CLICKHOUSE_DB", "opt_scraped")
|
||||||
|
S3_ENDPOINT = os.getenv("S3_ENDPOINT", "https://s3.siax.io")
|
||||||
|
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY", "")
|
||||||
|
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY", "")
|
||||||
|
|
||||||
|
|
||||||
|
class ClickHouseWriter:
|
||||||
|
"""Write scraped data to ClickHouse via HTTP interface."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.client = None
|
||||||
|
self._connect()
|
||||||
|
|
||||||
|
def _connect(self):
|
||||||
|
from clickhouse_driver import Client
|
||||||
|
self.client = Client(
|
||||||
|
host=CLICKHOUSE_HOST,
|
||||||
|
port=CLICKHOUSE_PORT,
|
||||||
|
user=CLICKHOUSE_USER,
|
||||||
|
password=CLICKHOUSE_PASSWORD,
|
||||||
|
database=CLICKHOUSE_DB,
|
||||||
|
settings={"max_memory_usage": 8000000000},
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_database(self):
|
||||||
|
self.client.execute(f"CREATE DATABASE IF NOT EXISTS {CLICKHOUSE_DB}")
|
||||||
|
|
||||||
|
def ensure_tables(self):
|
||||||
|
"""Create all required tables for scraped data."""
|
||||||
|
|
||||||
|
# Companies table (multi-country)
|
||||||
|
self.client.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS companies (
|
||||||
|
org_nr String,
|
||||||
|
country FixedString(2),
|
||||||
|
source String,
|
||||||
|
name String,
|
||||||
|
legal_form String DEFAULT '',
|
||||||
|
status String DEFAULT '',
|
||||||
|
address String DEFAULT '',
|
||||||
|
postal_code String DEFAULT '',
|
||||||
|
city String DEFAULT '',
|
||||||
|
country_code String DEFAULT '',
|
||||||
|
email String DEFAULT '',
|
||||||
|
phone String DEFAULT '',
|
||||||
|
website String DEFAULT '',
|
||||||
|
employees UInt32 DEFAULT 0,
|
||||||
|
founded_date Date DEFAULT '1970-01-01',
|
||||||
|
registered_capital Float64 DEFAULT 0,
|
||||||
|
currency FixedString(3) DEFAULT '',
|
||||||
|
industry_code String DEFAULT '',
|
||||||
|
industry_description String DEFAULT '',
|
||||||
|
lei String DEFAULT '',
|
||||||
|
last_updated DateTime DEFAULT now(),
|
||||||
|
raw_json String DEFAULT '',
|
||||||
|
scraped_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(last_updated)
|
||||||
|
ORDER BY (country, org_nr)
|
||||||
|
PARTITION BY country
|
||||||
|
SETTINGS index_granularity = 8192
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Contacts table (board members, executives)
|
||||||
|
self.client.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS contacts (
|
||||||
|
org_nr String,
|
||||||
|
country FixedString(2),
|
||||||
|
full_name String,
|
||||||
|
role String DEFAULT '',
|
||||||
|
email String DEFAULT '',
|
||||||
|
phone String DEFAULT '',
|
||||||
|
source String DEFAULT '',
|
||||||
|
confidence Float32 DEFAULT 0,
|
||||||
|
last_updated DateTime DEFAULT now(),
|
||||||
|
scraped_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(last_updated)
|
||||||
|
ORDER BY (country, org_nr, full_name)
|
||||||
|
PARTITION BY country
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Vehicles table (for DE vehicle data)
|
||||||
|
self.client.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS vehicles (
|
||||||
|
registration String,
|
||||||
|
country FixedString(2),
|
||||||
|
vin String DEFAULT '',
|
||||||
|
make String DEFAULT '',
|
||||||
|
model String DEFAULT '',
|
||||||
|
year UInt16 DEFAULT 0,
|
||||||
|
fuel_type String DEFAULT '',
|
||||||
|
owner_name String DEFAULT '',
|
||||||
|
owner_org_nr String DEFAULT '',
|
||||||
|
last_updated DateTime DEFAULT now(),
|
||||||
|
scraped_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = ReplacingMergeTree(last_updated)
|
||||||
|
ORDER BY (country, registration)
|
||||||
|
PARTITION BY country
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Raw scrape log (for debugging/audit)
|
||||||
|
self.client.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS scrape_log (
|
||||||
|
url String,
|
||||||
|
source String,
|
||||||
|
country FixedString(2),
|
||||||
|
method String DEFAULT '',
|
||||||
|
status UInt16 DEFAULT 0,
|
||||||
|
elapsed_ms UInt32 DEFAULT 0,
|
||||||
|
bytes UInt32 DEFAULT 0,
|
||||||
|
cf_blocked UInt8 DEFAULT 0,
|
||||||
|
error String DEFAULT '',
|
||||||
|
scraped_at DateTime DEFAULT now()
|
||||||
|
) ENGINE = MergeTree
|
||||||
|
ORDER BY (country, scraped_at)
|
||||||
|
PARTITION BY toYYYYMM(scraped_at)
|
||||||
|
TTL scraped_at + INTERVAL 90 DAY
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Statistics for monitoring
|
||||||
|
self.client.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS scrape_stats (
|
||||||
|
country FixedString(2),
|
||||||
|
source String,
|
||||||
|
date Date DEFAULT today(),
|
||||||
|
total_requests UInt64 DEFAULT 0,
|
||||||
|
successful UInt64 DEFAULT 0,
|
||||||
|
cf_blocked UInt64 DEFAULT 0,
|
||||||
|
errors UInt64 DEFAULT 0,
|
||||||
|
total_bytes UInt64 DEFAULT 0,
|
||||||
|
avg_elapsed_ms Float64 DEFAULT 0,
|
||||||
|
PRIMARY KEY (country, source, date)
|
||||||
|
) ENGINE = SummingMergeTree
|
||||||
|
ORDER BY (country, source, date)
|
||||||
|
""")
|
||||||
|
|
||||||
|
def insert_company(self, org_nr: str, country: str, source: str, **fields):
|
||||||
|
"""Insert or update a company record."""
|
||||||
|
data = {
|
||||||
|
"org_nr": org_nr,
|
||||||
|
"country": country,
|
||||||
|
"source": source,
|
||||||
|
"name": fields.get("name", ""),
|
||||||
|
"legal_form": fields.get("legal_form", ""),
|
||||||
|
"status": fields.get("status", ""),
|
||||||
|
"address": fields.get("address", ""),
|
||||||
|
"postal_code": fields.get("postal_code", ""),
|
||||||
|
"city": fields.get("city", ""),
|
||||||
|
"country_code": fields.get("country_code", ""),
|
||||||
|
"email": fields.get("email", ""),
|
||||||
|
"phone": fields.get("phone", ""),
|
||||||
|
"website": fields.get("website", ""),
|
||||||
|
"employees": int(fields.get("employees", 0)),
|
||||||
|
"founded_date": fields.get("founded_date", "1970-01-01"),
|
||||||
|
"registered_capital": float(fields.get("registered_capital", 0)),
|
||||||
|
"currency": fields.get("currency", ""),
|
||||||
|
"industry_code": fields.get("industry_code", ""),
|
||||||
|
"industry_description": fields.get("industry_description", ""),
|
||||||
|
"lei": fields.get("lei", ""),
|
||||||
|
"raw_json": json.dumps(fields.get("raw_data", {}), ensure_ascii=False),
|
||||||
|
}
|
||||||
|
self.client.execute(
|
||||||
|
"INSERT INTO companies VALUES",
|
||||||
|
[data],
|
||||||
|
)
|
||||||
|
|
||||||
|
def insert_contact(self, org_nr: str, country: str, full_name: str, role: str = "",
|
||||||
|
email: str = "", phone: str = "", source: str = "", confidence: float = 0):
|
||||||
|
self.client.execute(
|
||||||
|
"INSERT INTO contacts VALUES",
|
||||||
|
[{
|
||||||
|
"org_nr": org_nr, "country": country, "full_name": full_name,
|
||||||
|
"role": role, "email": email, "phone": phone,
|
||||||
|
"source": source, "confidence": confidence,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
def log_scrape(self, url: str, source: str, country: str, method: str,
|
||||||
|
status: int, elapsed_ms: int, bytes_count: int,
|
||||||
|
cf_blocked: bool = False, error: str = ""):
|
||||||
|
self.client.execute(
|
||||||
|
"INSERT INTO scrape_log VALUES",
|
||||||
|
[{
|
||||||
|
"url": url[:500], "source": source, "country": country,
|
||||||
|
"method": method, "status": status, "elapsed_ms": elapsed_ms,
|
||||||
|
"bytes": bytes_count, "cf_blocked": 1 if cf_blocked else 0, "error": error,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
|
||||||
|
def query(self, sql: str) -> list:
|
||||||
|
"""Execute SQL query and return results."""
|
||||||
|
return self.client.execute(sql)
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
if self.client:
|
||||||
|
self.client.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
class DuckDBWriter:
|
||||||
|
"""Alternative: DuckDB for local/embedded analytics."""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "/data/opt_scraped.duckdb"):
|
||||||
|
import duckdb
|
||||||
|
self.db_path = db_path
|
||||||
|
self.conn = duckdb.connect(db_path)
|
||||||
|
self._ensure_tables()
|
||||||
|
|
||||||
|
def _ensure_tables(self):
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS companies AS
|
||||||
|
SELECT * FROM read_parquet('/data/parquet/*.parquet') WHERE 1=0
|
||||||
|
""")
|
||||||
|
self.conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS scrape_log (
|
||||||
|
url VARCHAR, source VARCHAR, country VARCHAR,
|
||||||
|
method VARCHAR, status INTEGER, elapsed_ms INTEGER,
|
||||||
|
bytes INTEGER, cf_blocked BOOLEAN, scraped_at TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
def import_parquet(self, parquet_dir: str = "/data/parquet"):
|
||||||
|
"""Import all parquet files into DuckDB."""
|
||||||
|
import glob
|
||||||
|
files = glob.glob(f"{parquet_dir}/*.parquet")
|
||||||
|
if not files:
|
||||||
|
return 0
|
||||||
|
self.conn.execute(f"""
|
||||||
|
INSERT INTO companies
|
||||||
|
SELECT * FROM read_parquet({files!r})
|
||||||
|
""")
|
||||||
|
return self.conn.execute("SELECT count(*) FROM companies").fetchone()[0]
|
||||||
|
|
||||||
|
def query(self, sql: str) -> list:
|
||||||
|
return self.conn.execute(sql).fetchall()
|
||||||
|
|
||||||
|
def to_parquet(self, table: str, output_path: str):
|
||||||
|
"""Export table to parquet for archival."""
|
||||||
|
self.conn.execute(f"""
|
||||||
|
COPY (SELECT * FROM {table}) TO '{output_path}'
|
||||||
|
(FORMAT PARQUET, CODEC ZSTD)
|
||||||
|
""")
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
self.conn.close()
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
"""
|
||||||
|
Proxy Rotator & WAF Bypass - ALLA gratis metoder.
|
||||||
|
|
||||||
|
Metoder:
|
||||||
|
1. Header rotation (6 User-Agents x sprak x devices)
|
||||||
|
2. curl_cffi TLS fingerprint (chrome124, chrome110, edge99, safari17)
|
||||||
|
3. cloudscraper (basic CF bypass)
|
||||||
|
4. FlareSolverr (heavy CF bypass, via Docker)
|
||||||
|
5. Tor rotation (SOCKS5, gratis)
|
||||||
|
6. Free proxy pool (scraped from 5+ sources)
|
||||||
|
7. Playwright browser automation
|
||||||
|
8. nodriver (undetected browser)
|
||||||
|
9. Fake identity generation
|
||||||
|
10. Session cookie reuse
|
||||||
|
"""
|
||||||
|
import os, json, time, random, socket, asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
from curl_cffi import requests as curl_requests
|
||||||
|
import cloudscraper
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# ── Config ──
|
||||||
|
FLARESOLVERR_URL = os.getenv("FLARESOLVERR_URL", "http://localhost:8191/v1")
|
||||||
|
TOR_PROXY = os.getenv("TOR_PROXY", "socks5://localhost:9050")
|
||||||
|
USE_FLARESOLVERR = os.getenv("USE_FLARESOLVERR", "false").lower() == "true"
|
||||||
|
USE_TOR = os.getenv("USE_TOR", "false").lower() == "true"
|
||||||
|
|
||||||
|
# ── Header Pool ──
|
||||||
|
USER_AGENTS = [
|
||||||
|
# Chrome Windows
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
|
||||||
|
# Safari Mac
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.6 Safari/605.1.15",
|
||||||
|
# Firefox
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:127.0) Gecko/20100101 Firefox/127.0",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0",
|
||||||
|
# Edge
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0",
|
||||||
|
# Linux
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||||
|
# Mobile
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1",
|
||||||
|
"Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
|
||||||
|
]
|
||||||
|
|
||||||
|
ACCEPT_LANGUAGES = {
|
||||||
|
"DK": "da-DK,da;q=0.9,en;q=0.8,de;q=0.7",
|
||||||
|
"NO": "nb-NO,nb;q=0.9,no;q=0.8,en;q=0.7,da;q=0.6",
|
||||||
|
"DE": "de-DE,de;q=0.9,en;q=0.8,fr;q=0.7",
|
||||||
|
"SE": "sv-SE,sv;q=0.9,en;q=0.8",
|
||||||
|
"GB": "en-GB,en;q=0.9,fr;q=0.8",
|
||||||
|
"FI": "fi-FI,fi;q=0.9,en;q=0.8,sv;q=0.7",
|
||||||
|
"FR": "fr-FR,fr;q=0.9,en;q=0.8",
|
||||||
|
"NL": "nl-NL,nl;q=0.9,en;q=0.8",
|
||||||
|
"PL": "pl-PL,pl;q=0.9,en;q=0.8",
|
||||||
|
}
|
||||||
|
|
||||||
|
SEC_CH_UA = {
|
||||||
|
"chrome124": '"Chromium";v="124", "Google Chrome";v="124", "Not-A.Brand";v="99"',
|
||||||
|
"chrome125": '"Chromium";v="125", "Google Chrome";v="125", "Not-A.Brand";v="99"',
|
||||||
|
"edge124": '"Chromium";v="124", "Microsoft Edge";v="124", "Not-A.Brand";v="99"',
|
||||||
|
}
|
||||||
|
|
||||||
|
ACCEPT_ENCODINGS = ["gzip, deflate, br", "gzip, deflate", "gzip, deflate, br, zstd"]
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_headers(country="DE", impersonate="chrome124"):
|
||||||
|
ua = random.choice(USER_AGENTS)
|
||||||
|
lang = ACCEPT_LANGUAGES.get(country, "en-US,en;q=0.9")
|
||||||
|
enc = random.choice(ACCEPT_ENCODINGS)
|
||||||
|
sec_ch = SEC_CH_UA.get(impersonate, SEC_CH_UA["chrome124"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"User-Agent": ua,
|
||||||
|
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||||
|
"Accept-Language": lang,
|
||||||
|
"Accept-Encoding": enc,
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Sec-Ch-Ua": sec_ch,
|
||||||
|
"Sec-Ch-Ua-Mobile": "?0",
|
||||||
|
"Sec-Ch-Ua-Platform": '"Windows"' if "Windows" in ua else ('"macOS"' if "Mac" in ua else '"Linux"'),
|
||||||
|
"Sec-Fetch-Dest": "document",
|
||||||
|
"Sec-Fetch-Mode": "navigate",
|
||||||
|
"Sec-Fetch-Site": "none",
|
||||||
|
"Sec-Fetch-User": "?1",
|
||||||
|
"Upgrade-Insecure-Requests": "1",
|
||||||
|
"DNT": "1",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def check_cf_blocked(text: str) -> bool:
|
||||||
|
"""Check if response contains Cloudflare challenge."""
|
||||||
|
indicators = [
|
||||||
|
"cf-wrapper", "Just a moment", "Checking your browser",
|
||||||
|
"Cloudflare", "__cf_chl_f_tk", "cf-browser-verification",
|
||||||
|
"challenge-form", "Attention Required", "cf-error-page",
|
||||||
|
"cloudflare.com/cdn-cgi", "/cdn-cgi/",
|
||||||
|
]
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
lower = text[:10000].lower()
|
||||||
|
for ind in indicators:
|
||||||
|
if ind in lower:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ProxyRotator:
|
||||||
|
"""Free proxy pool with rotation."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.proxies = []
|
||||||
|
self.current = 0
|
||||||
|
self._load_free_proxies()
|
||||||
|
|
||||||
|
def _load_free_proxies(self):
|
||||||
|
"""Load proxies from free sources."""
|
||||||
|
sources = [
|
||||||
|
"https://raw.githubusercontent.com/TheSpeedX/SOCKS-List/master/http.txt",
|
||||||
|
"https://raw.githubusercontent.com/ShiftyTR/Proxy-List/master/http.txt",
|
||||||
|
"https://raw.githubusercontent.com/clarketm/proxy-list/master/proxy-list-raw.txt",
|
||||||
|
]
|
||||||
|
for url in sources:
|
||||||
|
try:
|
||||||
|
r = requests.get(url, timeout=5)
|
||||||
|
for line in r.text.strip().split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if ":" in line and not line.startswith("#"):
|
||||||
|
parts = line.split(":")
|
||||||
|
if len(parts) == 2:
|
||||||
|
self.proxies.append({"http": f"http://{line}", "https": f"http://{line}"})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
# Deduplicate
|
||||||
|
seen = set()
|
||||||
|
unique = []
|
||||||
|
for p in self.proxies:
|
||||||
|
if p["http"] not in seen:
|
||||||
|
seen.add(p["http"])
|
||||||
|
unique.append(p)
|
||||||
|
self.proxies = unique
|
||||||
|
print(f" ProxyRotator: loaded {len(self.proxies)} free proxies")
|
||||||
|
|
||||||
|
def get_proxy(self) -> Optional[dict]:
|
||||||
|
if not self.proxies:
|
||||||
|
return None
|
||||||
|
self.current = (self.current + 1) % len(self.proxies)
|
||||||
|
return self.proxies[self.current]
|
||||||
|
|
||||||
|
def get_tor_proxy(self) -> dict:
|
||||||
|
return {"http": TOR_PROXY, "https": TOR_PROXY}
|
||||||
|
|
||||||
|
|
||||||
|
class WAFBypass:
|
||||||
|
"""Multi-method WAF bypass engine."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.cloudscraper = cloudscraper.create_scraper(
|
||||||
|
browser={"browser": "chrome", "platform": "windows", "mobile": False}
|
||||||
|
)
|
||||||
|
self.proxy = ProxyRotator()
|
||||||
|
self.session_pool = {}
|
||||||
|
self.curl_methods = {
|
||||||
|
"chrome124": lambda url, h: curl_requests.get(url, impersonate="chrome124", headers=h, timeout=15),
|
||||||
|
"chrome110": lambda url, h: curl_requests.get(url, impersonate="chrome110", headers=h, timeout=15),
|
||||||
|
"edge99": lambda url, h: curl_requests.get(url, impersonate="edge99", headers=h, timeout=15),
|
||||||
|
"safari17": lambda url, h: curl_requests.get(url, impersonate="safari17_0", headers=h, timeout=15),
|
||||||
|
}
|
||||||
|
|
||||||
|
def try_flaresolverr(self, url: str) -> Optional[str]:
|
||||||
|
"""Bypass CF via FlareSolverr Docker container."""
|
||||||
|
if not USE_FLARESOLVERR:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = {
|
||||||
|
"cmd": "request.get",
|
||||||
|
"url": url,
|
||||||
|
"maxTimeout": 60000,
|
||||||
|
"session": f"session_{hash(url) % 10000}",
|
||||||
|
}
|
||||||
|
r = requests.post(FLARESOLVERR_URL, json=payload, timeout=65)
|
||||||
|
data = r.json()
|
||||||
|
if data.get("status") == "ok":
|
||||||
|
solution = data.get("solution", {})
|
||||||
|
return solution.get("response", "")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_tor(self, url: str, headers: dict) -> Optional[requests.Response]:
|
||||||
|
"""Route through Tor network."""
|
||||||
|
if not USE_TOR:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
proxies = self.proxy.get_tor_proxy()
|
||||||
|
r = requests.get(url, headers=headers, proxies=proxies, timeout=30)
|
||||||
|
return r
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_curl(self, url: str, country: str = "DE") -> Optional[requests.Response]:
|
||||||
|
"""Try all curl_cffi impersonations in order."""
|
||||||
|
for name, fn in self.curl_methods.items():
|
||||||
|
try:
|
||||||
|
headers = rotate_headers(country, name.replace("curl_cffi_", "").split("_")[0] if "curl_" in name else "chrome124")
|
||||||
|
r = fn(url, headers)
|
||||||
|
if r.status_code == 200 and not check_cf_blocked(str(r.content[:5000])):
|
||||||
|
return r
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_cloudscraper(self, url: str, country: str = "DE") -> Optional[requests.Response]:
|
||||||
|
"""Try cloudscraper with different configs."""
|
||||||
|
for browser in [
|
||||||
|
{"browser": "chrome", "platform": "windows", "mobile": False},
|
||||||
|
{"browser": "firefox", "platform": "windows", "mobile": False},
|
||||||
|
{"browser": "chrome", "platform": "macos", "mobile": False},
|
||||||
|
]:
|
||||||
|
try:
|
||||||
|
sc = cloudscraper.create_scraper(browser=browser)
|
||||||
|
sc.headers.update(rotate_headers(country))
|
||||||
|
r = sc.get(url, timeout=15)
|
||||||
|
if r.status_code == 200 and not check_cf_blocked(str(r.content[:5000])):
|
||||||
|
return r
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
def try_playwright(self, url: str) -> Optional[str]:
|
||||||
|
"""Use Playwright headless browser as last resort."""
|
||||||
|
try:
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
with sync_playwright() as p:
|
||||||
|
browser = p.chromium.launch(headless=True)
|
||||||
|
context = browser.new_context(
|
||||||
|
user_agent=random.choice(USER_AGENTS),
|
||||||
|
locale="en-US",
|
||||||
|
viewport={"width": 1920, "height": 1080},
|
||||||
|
)
|
||||||
|
page = context.new_page()
|
||||||
|
page.goto(url, timeout=30000)
|
||||||
|
content = page.content()
|
||||||
|
browser.close()
|
||||||
|
if not check_cf_blocked(content):
|
||||||
|
return content
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def bypass(self, url: str, country: str = "DE") -> tuple:
|
||||||
|
"""
|
||||||
|
Full bypass chain. Returns (content: str, method: str, elapsed: float).
|
||||||
|
|
||||||
|
Chain order:
|
||||||
|
1. curl_cffi chrome124 (fastest, 0.1-0.3s)
|
||||||
|
2. cloudscraper (basic CF, 0.3-0.5s)
|
||||||
|
3. curl_cffi edge99 (alt fingerprint)
|
||||||
|
4. curl_cffi safari17 (Eniro bypass)
|
||||||
|
5. Tor proxy rotation (anonymous, 2-5s)
|
||||||
|
6. FlareSolverr (heavy CF, 5-15s)
|
||||||
|
7. Playwright (full browser, 10-30s)
|
||||||
|
"""
|
||||||
|
start = time.time()
|
||||||
|
|
||||||
|
# Level 1: curl_cffi TLS fingerprint
|
||||||
|
r = self.try_curl(url, country)
|
||||||
|
if r and r.status_code == 200:
|
||||||
|
return (r.text, "curl_cffi", time.time() - start)
|
||||||
|
|
||||||
|
# Level 2: cloudscraper
|
||||||
|
r = self.try_cloudscraper(url, country)
|
||||||
|
if r and r.status_code == 200:
|
||||||
|
return (r.text, "cloudscraper", time.time() - start)
|
||||||
|
|
||||||
|
# Level 3: Tor
|
||||||
|
if USE_TOR:
|
||||||
|
r = self.try_tor(url, rotate_headers(country))
|
||||||
|
if r and r.status_code == 200:
|
||||||
|
return (r.text, "tor", time.time() - start)
|
||||||
|
|
||||||
|
# Level 4: FlareSolverr
|
||||||
|
content = self.try_flaresolverr(url)
|
||||||
|
if content:
|
||||||
|
return (content, "flaresolverr", time.time() - start)
|
||||||
|
|
||||||
|
# Level 5: Playwright (last resort)
|
||||||
|
content = self.try_playwright(url)
|
||||||
|
if content:
|
||||||
|
return (content, "playwright", time.time() - start)
|
||||||
|
|
||||||
|
return (None, "all_failed", time.time() - start)
|
||||||
|
|
||||||
|
def bypass_with_proxy(self, url: str, country: str = "DE") -> tuple:
|
||||||
|
"""Bypass with proxy rotation for each method."""
|
||||||
|
start = time.time()
|
||||||
|
proxy = self.proxy.get_proxy()
|
||||||
|
headers = rotate_headers(country)
|
||||||
|
|
||||||
|
for name, fn in self.curl_methods.items():
|
||||||
|
try:
|
||||||
|
if proxy:
|
||||||
|
r = fn(url, headers)
|
||||||
|
else:
|
||||||
|
r = fn(url, headers)
|
||||||
|
if r.status_code == 200 and not check_cf_blocked(str(r.content[:5000])):
|
||||||
|
return (r.text, f"curl_cffi+proxy_{name}", time.time() - start)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return self.bypass(url, country)
|
||||||
Reference in New Issue
Block a user