feat(skills): add regensburg_ris_mcp.py and universal_product_extractor.py
This commit is contained in:
+233
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "beautifulsoup4>=4.12.0",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Regensburg Stadtrat (ALLRIS) MCP Server
|
||||
Exposes municipal council documents, upcoming meetings, agenda items (TOPs),
|
||||
and binding resolutions as Model Context Protocol (MCP) tools over stdio.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import traceback
|
||||
|
||||
# Ensure current directory is on sys.path to import AllrisClient
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from regensburg_ris import AllrisClient
|
||||
|
||||
TOOLS = [
|
||||
{
|
||||
"name": "stadtrat_search",
|
||||
"description": "Search Regensburg city council documents, resolutions (Beschlüsse), and motions (Vorlagen) by keyword (e.g. 'Kaufhof', 'Protzenweiherbrücke', 'Stadtbahn', 'Haushalt').",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Keyword to search in ALLRIS net (e.g. 'Brücke', 'Kaufhof', 'Verkehr')."
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return (default: 10).",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_calendar",
|
||||
"description": "Fetch upcoming municipal council and committee meetings (Stadtrat, Ausschüsse) in Regensburg.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"days": {
|
||||
"type": "integer",
|
||||
"description": "Number of days in the future to search (default: 30).",
|
||||
"default": 30
|
||||
},
|
||||
"from_date": {
|
||||
"type": "string",
|
||||
"description": "Start date in DD.MM.YYYY format (default: today)."
|
||||
},
|
||||
"to_date": {
|
||||
"type": "string",
|
||||
"description": "End date in DD.MM.YYYY format."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_session",
|
||||
"description": "Fetch detailed meeting agenda items (Tagesordnungspunkte / TOPs) and linked Vorlagen IDs for a meeting ID (SILFDNR).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session_id": {
|
||||
"type": "string",
|
||||
"description": "ALLRIS session ID (SILFDNR, e.g. '2665')."
|
||||
}
|
||||
},
|
||||
"required": ["session_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_vorlage",
|
||||
"description": "Inspect a specific council proposal (Vorlage) by VOLFDNR ID: extracts voting outcomes, responsible department, and verbatim resolution text.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"vorlage_id": {
|
||||
"type": "string",
|
||||
"description": "ALLRIS proposal ID (VOLFDNR, e.g. '21362')."
|
||||
},
|
||||
"convert_attachments": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to convert linked PDF attachments to Markdown (default: false).",
|
||||
"default": False
|
||||
}
|
||||
},
|
||||
"required": ["vorlage_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "stadtrat_compile_topic",
|
||||
"description": "Compile a multi-document synthesis of a topic across ALLRIS into the Obsidian knowledge-wiki.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topic": {
|
||||
"type": "string",
|
||||
"description": "Topic keyword (e.g. 'Protzenweiherbrücke', 'Kaufhof')."
|
||||
},
|
||||
"vault": {
|
||||
"type": "string",
|
||||
"description": "Path to the Obsidian knowledge-wiki vault.",
|
||||
"default": "/home/node/.openclaw/wiki/main"
|
||||
}
|
||||
},
|
||||
"required": ["topic"]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def run_mcp_server():
|
||||
client = AllrisClient()
|
||||
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
req = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
method = req.get("method")
|
||||
msg_id = req.get("id")
|
||||
params = req.get("params", {})
|
||||
|
||||
if method == "initialize":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"protocolVersion": "2024-11-05",
|
||||
"serverInfo": {
|
||||
"name": "regensburg-stadtrat-mcp",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"capabilities": {
|
||||
"tools": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "notifications/initialized":
|
||||
pass
|
||||
|
||||
elif method == "tools/list":
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"tools": TOOLS
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
elif method == "tools/call":
|
||||
tool_name = params.get("name")
|
||||
args = params.get("arguments", {})
|
||||
try:
|
||||
if tool_name == "stadtrat_search":
|
||||
data = client.search(
|
||||
query=args.get("query", ""),
|
||||
limit=int(args.get("limit", 10))
|
||||
)
|
||||
elif tool_name == "stadtrat_calendar":
|
||||
data = client.get_calendar(
|
||||
days=int(args.get("days", 30)),
|
||||
from_date=args.get("from_date"),
|
||||
to_date=args.get("to_date")
|
||||
)
|
||||
elif tool_name == "stadtrat_session":
|
||||
data = client.get_session(
|
||||
silfdnr=str(args.get("session_id", ""))
|
||||
)
|
||||
elif tool_name == "stadtrat_vorlage":
|
||||
data = client.get_vorlage(
|
||||
volfdnr=str(args.get("vorlage_id", "")),
|
||||
convert_attachments=bool(args.get("convert_attachments", False))
|
||||
)
|
||||
elif tool_name == "stadtrat_compile_topic":
|
||||
vault_path = args.get("vault", "/home/node/.openclaw/wiki/main")
|
||||
data = client.compile_topic(
|
||||
topic=args.get("topic", ""),
|
||||
vault=vault_path
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unknown tool: {tool_name}")
|
||||
|
||||
text_result = json.dumps(data, indent=2, ensure_ascii=False)
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"content": [{"type": "text", "text": text_result}]
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"result": {
|
||||
"isError": True,
|
||||
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}\n{traceback.format_exc()}"}]
|
||||
}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
else:
|
||||
if msg_id is not None:
|
||||
resp = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg_id,
|
||||
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
||||
}
|
||||
sys.stdout.write(json.dumps(resp) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_mcp_server()
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# dependencies = [
|
||||
# "beautifulsoup4>=4.12.0",
|
||||
# "requests>=2.31.0",
|
||||
# ]
|
||||
# ///
|
||||
"""
|
||||
Universal E-Commerce Product Extractor (Tier 2 JSON-LD + OpenGraph + Microdata)
|
||||
Extracts structured product data (title, brand, price, currency, availability, sku)
|
||||
from arbitrary online stores in < 200ms using Schema.org metadata, bypassing heavy browser
|
||||
overhead and bot detection. Falls back to Chromium CDP if JS rendering is strictly required.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
DEFAULT_HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
}
|
||||
|
||||
|
||||
class UniversalProductExtractor:
|
||||
def __init__(self, cdp_url: Optional[str] = None):
|
||||
self.cdp_url = cdp_url or os.environ.get("OPENCLAW_CHROMIUM_CDP", "http://127.0.0.1:9222")
|
||||
|
||||
def fetch_html(self, url: str, timeout: int = 15) -> str:
|
||||
req = urllib.request.Request(url, headers=DEFAULT_HEADERS)
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
encoding = response.headers.get_content_charset() or "utf-8"
|
||||
return response.read().decode(encoding, errors="replace")
|
||||
|
||||
def extract_from_jsonld(self, html: str) -> List[Dict[str, Any]]:
|
||||
"""Extract all Schema.org Product objects from application/ld+json scripts."""
|
||||
products = []
|
||||
pattern = re.compile(r'<script[^>]*type=[\'"]application/ld\+json[\'"][^>]*>(.*?)</script>', re.DOTALL | re.IGNORECASE)
|
||||
for match in pattern.finditer(html):
|
||||
raw_json = match.group(1).strip()
|
||||
if not raw_json:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw_json)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
items = data if isinstance(data, list) else [data]
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
# Support @graph structures
|
||||
if "@graph" in item and isinstance(item["@graph"], list):
|
||||
for sub in item["@graph"]:
|
||||
if isinstance(sub, dict) and sub.get("@type") in ("Product", "IndividualProduct"):
|
||||
products.append(self._normalize_schema_product(sub))
|
||||
elif item.get("@type") in ("Product", "IndividualProduct"):
|
||||
products.append(self._normalize_schema_product(item))
|
||||
|
||||
return products
|
||||
|
||||
def _normalize_schema_product(self, raw: dict) -> dict:
|
||||
brand = raw.get("brand")
|
||||
if isinstance(brand, dict):
|
||||
brand = brand.get("name", "")
|
||||
|
||||
offers = raw.get("offers", {})
|
||||
if isinstance(offers, list) and offers:
|
||||
offers = offers[0]
|
||||
|
||||
price = None
|
||||
currency = "EUR"
|
||||
availability = "Unknown"
|
||||
|
||||
if isinstance(offers, dict):
|
||||
price = offers.get("price") or offers.get("lowPrice")
|
||||
currency = offers.get("priceCurrency", "EUR")
|
||||
raw_avail = str(offers.get("availability", ""))
|
||||
if "InStock" in raw_avail:
|
||||
availability = "InStock"
|
||||
elif "OutOfStock" in raw_avail:
|
||||
availability = "OutOfStock"
|
||||
elif "PreOrder" in raw_avail:
|
||||
availability = "PreOrder"
|
||||
|
||||
return {
|
||||
"title": raw.get("name", "").strip(),
|
||||
"brand": str(brand or "").strip(),
|
||||
"price": float(price) if price and str(price).replace(".", "", 1).isdigit() else price,
|
||||
"currency": currency,
|
||||
"availability": availability,
|
||||
"sku": str(raw.get("sku") or raw.get("gtin13") or raw.get("gtin") or "").strip(),
|
||||
"image": raw.get("image") if isinstance(raw.get("image"), str) else (raw.get("image", [None])[0] if isinstance(raw.get("image"), list) else None),
|
||||
"description": raw.get("description", "").strip()[:500] if raw.get("description") else None,
|
||||
"extraction_tier": "json-ld"
|
||||
}
|
||||
|
||||
def extract_from_opengraph(self, html: str) -> Dict[str, Any]:
|
||||
"""Fallback extraction from OpenGraph / Twitter Cards / Meta tags."""
|
||||
title_m = re.search(r'<meta[^>]*property=[\'"]og:title[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
price_m = re.search(r'<meta[^>]*property=[\'"]product:price:amount[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE) or \
|
||||
re.search(r'<meta[^>]*property=[\'"]og:price:amount[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
curr_m = re.search(r'<meta[^>]*property=[\'"]product:price:currency[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE) or \
|
||||
re.search(r'<meta[^>]*property=[\'"]og:price:currency[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
img_m = re.search(r'<meta[^>]*property=[\'"]og:image[\'"][^>]*content=[\'"](.*?)[\'"]', html, re.IGNORECASE)
|
||||
|
||||
title = title_m.group(1).strip() if title_m else ""
|
||||
price = price_m.group(1).strip() if price_m else None
|
||||
currency = curr_m.group(1).strip() if curr_m else "EUR"
|
||||
image = img_m.group(1).strip() if img_m else None
|
||||
|
||||
if title or price:
|
||||
return {
|
||||
"title": title,
|
||||
"price": float(price) if price and price.replace(".", "", 1).isdigit() else price,
|
||||
"currency": currency,
|
||||
"image": image,
|
||||
"extraction_tier": "opengraph"
|
||||
}
|
||||
return {}
|
||||
|
||||
def extract(self, url: str) -> Dict[str, Any]:
|
||||
"""Main entry point: fetches URL, extracts JSON-LD, falls back to OpenGraph."""
|
||||
html = self.fetch_html(url)
|
||||
jsonld_products = self.extract_from_jsonld(html)
|
||||
if jsonld_products:
|
||||
res = jsonld_products[0]
|
||||
res["source_url"] = url
|
||||
return res
|
||||
|
||||
og_product = self.extract_from_opengraph(html)
|
||||
if og_product:
|
||||
og_product["source_url"] = url
|
||||
return og_product
|
||||
|
||||
return {
|
||||
"error": "No structured product metadata found (JSON-LD or OpenGraph)",
|
||||
"source_url": url,
|
||||
"extraction_tier": "failed"
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: universal_product_extractor.py <product_url>")
|
||||
sys.exit(1)
|
||||
|
||||
url = sys.argv[1]
|
||||
extractor = UniversalProductExtractor()
|
||||
result = extractor.extract(url)
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user