refactor(parse): add base html and json parsers

This commit is contained in:
2026-09-21 22:17:50 +03:00
parent 5d025998b9
commit 0baa1277ef
8 changed files with 186 additions and 98 deletions
+20 -20
View File
@@ -1,14 +1,15 @@
import datetime import datetime
import json
import logging import logging
import re import re
from typing import Literal, NamedTuple from dataclasses import dataclass
from typing import Any, Literal
from bs4 import BeautifulSoup, Tag from bs4 import Tag
from gallery.sketch.parse.core import ParseError, Parser from gallery.sketch.parse.core import ParsedContext, ParseError, Parser
from gallery.sketch.parse.date import parse_date from gallery.sketch.parse.date import parse_date
from gallery.sketch.parse.html import TableParser, TableValueParser from gallery.sketch.parse.html import HTMLContextParser, TableParser, TableValueParser
from gallery.sketch.parse.json import JSONParser
from gallery.sketch.weather.model import ( from gallery.sketch.weather.model import (
Cloudness, Cloudness,
Location, Location,
@@ -198,42 +199,41 @@ class GismeteoTableParser(TableParser[WeatherValue]):
return WeatherValue.model_validate(data) return WeatherValue.model_validate(data)
class ForecastData(NamedTuple): @dataclass
class ForecastData:
date: datetime.date date: datetime.date
period: Literal["day", "days"] period: Literal["day", "days"]
data: str data: str
values: int | None = None values: int | None = None
class GismeteoForecastParser(Parser[ForecastData, WeatherResponse]): class GismeteoForecastParser(HTMLContextParser[ForecastData, WeatherResponse]):
location_parser = LocationNameParser() location_parser = LocationNameParser()
table_parser = GismeteoTableParser() table_parser = GismeteoTableParser()
def parse(self, data: ForecastData) -> WeatherResponse: def extract(self, data: ParsedContext[ForecastData, Tag]) -> WeatherResponse:
location = self.location_parser.parse(data.data) location = self.location_parser.parse(data.context.data)
soup = BeautifulSoup(data.data, features="html.parser") values = self.table_parser.parse(data.data)
values = self.table_parser.parse(soup) if data.context.values and len(values) > data.context.values:
if data.values and len(values) > data.values: n = int(len((values)) / data.context.values)
n = int(len((values)) / data.values)
values = [ values = [
merge_weather_values(values[i].date, values[i : i + n], "average") for i in range(0, len(values), n) merge_weather_values(values[i].date, values[i : i + n], "average") for i in range(0, len(values), n)
] ]
if data.period == "days": if data.context.period == "days":
fix_dates_sequence(values) fix_dates_sequence(values)
return WeatherResponse( return WeatherResponse(
location=location, location=location,
date=data.date, date=data.context.date,
period=data.period, period=data.context.period,
values=values, values=values,
) )
class GismeteoLocationParser(Parser[str, list[Location]]): class GismeteoLocationParser(JSONParser[list[Location]]):
def parse(self, data: str) -> list[Location]: def extract(self, data: Any) -> list[Location]:
content = json.loads(data)
result = [] result = []
for item in content["data"]: for item in data["data"]:
result.append( result.append(
Location( Location(
id=f"{item['slug']}-{item['id']}", id=f"{item['slug']}-{item['id']}",
+17 -16
View File
@@ -1,9 +1,10 @@
import datetime import datetime
import json
import re import re
from typing import NamedTuple from dataclasses import dataclass
from typing import Any
from gallery.sketch.parse.core import Parser from gallery.sketch.parse.core import ParsedContext, Parser
from gallery.sketch.parse.json import JSONContextParser
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from . import PROVIDER from . import PROVIDER
@@ -15,20 +16,20 @@ class DateParser(Parser[str, datetime.date]):
return datetime.date.strptime(matched.group(1), data) return datetime.date.strptime(matched.group(1), data)
class ScheduleData(NamedTuple): @dataclass
class ScheduleData:
date: datetime.date date: datetime.date
data: str data: str
class MatchTVScheduleParser(Parser[ScheduleData, Schedule]): class MatchTVScheduleParser(JSONContextParser[ScheduleData, Schedule]):
date_parser = DateParser() date_parser = DateParser()
def parse(self, data: ScheduleData) -> Schedule: def extract(self, data: ParsedContext[ScheduleData, Any]) -> Schedule:
content = json.loads(data.data) channel_data = data.data["result"]["channels"][0]
channel_data = content["result"]["channels"][0]
channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=PROVIDER) channel = Channel(id=channel_data["alias"], name=channel_data["name"], provider=PROVIDER)
values = [] values = []
start = datetime.datetime.combine(data.date, datetime.time.min) start = datetime.datetime.combine(data.context.date, datetime.time.min)
end = start + datetime.timedelta(days=1, hours=6) end = start + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None prev_value: ScheduleValue | None = None
for item in channel_data["schedule"]: for item in channel_data["schedule"]:
@@ -47,22 +48,22 @@ class MatchTVScheduleParser(Parser[ScheduleData, Schedule]):
prev_value = value prev_value = value
return Schedule( return Schedule(
channel=channel, channel=channel,
date=data.date, date=data.context.date,
values=values, values=values,
) )
class ChannelData(NamedTuple): @dataclass
class ChannelData:
query: str query: str
data: str data: str
class MatchTVChannelParser(Parser[ChannelData, list[Channel]]): class MatchTVChannelParser(JSONContextParser[ChannelData, list[Channel]]):
def parse(self, data: ChannelData) -> list[Channel]: def extract(self, data: ParsedContext[ChannelData, Any]) -> list[Channel]:
context = json.loads(data.data)
result = [] result = []
query = data.query.lower() query = data.context.query.lower()
for item in context["result"]: for item in data.data["result"]:
if query in item["name"].lower() or query in item["alias"]: if query in item["name"].lower() or query in item["alias"]:
channel_id = item["alias"] channel_id = item["alias"]
name = item["name"].split("|")[0].strip() name = item["name"].split("|")[0].strip()
+13 -14
View File
@@ -1,11 +1,11 @@
import datetime import datetime
import re import re
from bs4 import BeautifulSoup, Tag from bs4 import Tag
from gallery.sketch.parse.core import Parser
from gallery.sketch.parse.date import parse_date from gallery.sketch.parse.date import parse_date
from gallery.sketch.parse.html import ( from gallery.sketch.parse.html import (
HTMLParser,
TableParser, TableParser,
TableValueParser, TableValueParser,
TagIterParser, TagIterParser,
@@ -82,6 +82,7 @@ class PrecipitationParser(TableValueParser[tuple[Precipitation, float]]):
PRECIPITATION_MAP = { PRECIPITATION_MAP = {
"Явления погоды отсутствуют": Precipitation.NO, "Явления погоды отсутствуют": Precipitation.NO,
"Cлабый дождь": Precipitation.SMALL_RAIN, "Cлабый дождь": Precipitation.SMALL_RAIN,
"Умеренный дождь": Precipitation.RAIN,
} }
def extract(self, data: Tag) -> tuple[Precipitation, float]: def extract(self, data: Tag) -> tuple[Precipitation, float]:
@@ -92,7 +93,7 @@ class PrecipitationParser(TableValueParser[tuple[Precipitation, float]]):
if precipitation_value_matched: if precipitation_value_matched:
# one hour value # one hour value
precipitation_value = float(precipitation_value_matched.group(1)) * 6 precipitation_value = float(precipitation_value_matched.group(1)) * 6
if "(" in precipitation_str: if " (" in precipitation_str:
precipitation_str = precipitation_str.split(" (")[0] precipitation_str = precipitation_str.split(" (")[0]
return self.PRECIPITATION_MAP[precipitation_str], precipitation_value return self.PRECIPITATION_MAP[precipitation_str], precipitation_value
@@ -163,16 +164,15 @@ class RP5TableParser(TableParser[WeatherValue]):
return WeatherValue.model_validate(data) return WeatherValue.model_validate(data)
class RP5ForecastParser(Parser[str, list[WeatherResponse]]): class RP5ForecastParser(HTMLParser[list[WeatherResponse]]):
location_parser = LocationNameParser() location_parser = LocationNameParser()
dates_parser = DatesParser() dates_parser = DatesParser()
table_parser = RP5TableParser() table_parser = RP5TableParser()
def parse(self, data: str) -> list[WeatherResponse]: def extract(self, data: Tag) -> list[WeatherResponse]:
soup = BeautifulSoup(data, features="html.parser") location = self.location_parser.parse(data)
location = self.location_parser.parse(soup) dates = self.dates_parser.parse(data)
dates = self.dates_parser.parse(soup) values = self.table_parser.parse(data)
values = self.table_parser.parse(soup)
responses = [] responses = []
for date, count in dates: for date, count in dates:
item_values, values = values[:count], values[count:] item_values, values = values[:count], values[count:]
@@ -190,13 +190,12 @@ class RP5ForecastParser(Parser[str, list[WeatherResponse]]):
return responses return responses
class RP5LocationParser(Parser[str, list[Location]]): class RP5LocationParser(HTMLParser[list[Location]]):
selector = ".searchResults"
def parse(self, data: str) -> list[Location]: def extract(self, data: Tag) -> list[Location]:
soup = BeautifulSoup(data, features="html.parser")
table = soup.select_one(".searchResults")
result = [] result = []
for row in table.select("tr")[1:]: for row in data.select("tr")[1:]:
cells = row.select("td") cells = row.select("td")
name = cells[0].text.strip() name = cells[0].text.strip()
location_id = cells[0].select_one("a").attrs["href"].lstrip("/") location_id = cells[0].select_one("a").attrs["href"].lstrip("/")
+19 -17
View File
@@ -1,32 +1,34 @@
import datetime import datetime
import json from dataclasses import dataclass
from typing import NamedTuple from typing import Any
from bs4 import BeautifulSoup from bs4 import Tag
from gallery.sketch.parse.core import Parser from gallery.sketch.parse.core import ParsedContext
from gallery.sketch.parse.json import JSONParser
from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue from gallery.sketch.schedule.model import Channel, Schedule, ScheduleValue
from ...sketch.parse.html import HTMLContextParser
from . import PROVIDER from . import PROVIDER
class ScheduleData(NamedTuple): @dataclass
class ScheduleData:
date: datetime.date date: datetime.date
channel_id: str channel_id: str
data: str data: str
class YandexTVScheduleParser(Parser[ScheduleData, Schedule]): class YandexTVScheduleParser(HTMLContextParser[ScheduleData, Schedule]):
def parse(self, data: ScheduleData) -> Schedule: def extract(self, data: ParsedContext[ScheduleData, Tag]) -> Schedule:
soup = BeautifulSoup(data.data, features="html.parser") if data.data.select_one(".CheckboxCaptcha") is not None:
if soup.select_one(".CheckboxCaptcha") is not None:
raise RuntimeError("Captcha") raise RuntimeError("Captcha")
values = [] values = []
channel_name = soup.select_one(".channel-header__text").text.strip() channel_name = data.data.select_one(".channel-header__text").text.strip()
current_day = datetime.datetime.combine(data.date, datetime.datetime.min.time()) current_day = datetime.datetime.combine(data.context.date, datetime.datetime.min.time())
end = current_day + datetime.timedelta(days=1, hours=6) end = current_day + datetime.timedelta(days=1, hours=6)
prev_value: ScheduleValue | None = None prev_value: ScheduleValue | None = None
for item in soup.select(".channel-schedule .channel-schedule__event"): for item in data.data.select(".channel-schedule .channel-schedule__event"):
title = item.select_one(".channel-schedule__title").text.strip() title = item.select_one(".channel-schedule__title").text.strip()
time_str = item.select_one(".channel-schedule__time").text.strip() time_str = item.select_one(".channel-schedule__time").text.strip()
hours, minutes = map(int, time_str.split(":")) hours, minutes = map(int, time_str.split(":"))
@@ -41,15 +43,15 @@ class YandexTVScheduleParser(Parser[ScheduleData, Schedule]):
prev_value.end = item_date prev_value.end = item_date
prev_value = value prev_value = value
return Schedule( return Schedule(
channel=Channel(id=data.channel_id, name=channel_name, provider=PROVIDER), channel=Channel(id=data.context.channel_id, name=channel_name, provider=PROVIDER),
date=data.date, date=data.context.date,
values=values, values=values,
) )
class YandexTVChannelParser(Parser[str, list[Channel]]): class YandexTVChannelParser(JSONParser[list[Channel]]):
def parse(self, data: str) -> list[Channel]: def extract(self, data: Any) -> list[Channel]:
_, values = json.loads(data) _, values = data
result = [] result = []
for _, name, content in values: for _, name, content in values:
if content["label"] == "Каналы": if content["label"] == "Каналы":
+16 -13
View File
@@ -1,12 +1,17 @@
import datetime import datetime
import json
import logging import logging
from typing import Any
from bs4 import BeautifulSoup, Tag from bs4 import Tag
from gallery.sketch.parse.core import Parser
from gallery.sketch.parse.date import parse_date from gallery.sketch.parse.date import parse_date
from gallery.sketch.parse.html import TableParser, TableValueParser, TagParser from gallery.sketch.parse.html import (
HTMLParser,
TableParser,
TableValueParser,
TagParser,
)
from gallery.sketch.parse.json import JSONParser
from gallery.sketch.weather.model import ( from gallery.sketch.weather.model import (
Cloudness, Cloudness,
Location, Location,
@@ -143,15 +148,14 @@ class YandexTableParser(TableParser[WeatherValue]):
return WeatherValue.model_validate(data) return WeatherValue.model_validate(data)
class YandexForecastParser(Parser[str, list[WeatherResponse]]): class YandexForecastParser(HTMLParser[list[WeatherResponse]]):
table_parser = YandexTableParser() table_parser = YandexTableParser()
date_parser = DateParser() date_parser = DateParser()
location_parser = LocationNameParser() location_parser = LocationNameParser()
def parse(self, data: str) -> list[WeatherResponse]: def extract(self, data: Tag) -> list[WeatherResponse]:
soup = BeautifulSoup(data, features="html.parser") location = self.location_parser.parse(data)
location = self.location_parser.parse(soup) articles = data.select("article[class^=AppForecastDay]")
articles = soup.select("article[class^=AppForecastDay]")
return [ return [
WeatherResponse( WeatherResponse(
location=location, location=location,
@@ -163,10 +167,9 @@ class YandexForecastParser(Parser[str, list[WeatherResponse]]):
] ]
class YandexLocationParser(Parser[str, list[Location]]): class YandexLocationParser(JSONParser[list[Location]]):
def parse(self, data: str) -> list[Location]: def extract(self, data: Any) -> list[Location]:
content = json.loads(data)
return [ return [
Location( Location(
id=str(item["geoId"]), id=str(item["geoId"]),
@@ -179,5 +182,5 @@ class YandexLocationParser(Parser[str, list[Location]]):
district=item["description"], district=item["description"],
subdistrict="", subdistrict="",
) )
for item in content for item in data
] ]
+42 -6
View File
@@ -1,13 +1,49 @@
from typing import Generic, TypeVar from abc import ABC, abstractmethod
from typing import Generic, NamedTuple, Protocol, TypeVar
D = TypeVar("D")
R = TypeVar("R")
class ParseError(Exception): class ParseError(Exception):
pass pass
class Parser(Generic[D, R]): D = TypeVar("D")
R = TypeVar("R")
class Parser[D, R](Protocol):
def parse(self, data: D) -> R: ...
P = TypeVar("P")
class PrepareParser(ABC, Parser[D, R], Generic[D, P, R]):
@abstractmethod
def prepare(self, data: D) -> P: ...
@abstractmethod
def extract(self, data: P) -> R: ...
def parse(self, data: D) -> R: def parse(self, data: D) -> R:
raise NotImplementedError return self.extract(self.prepare(data))
class Context(Protocol):
data: str
C = TypeVar("C", bound=Context)
class ParsedContext(NamedTuple, Generic[C, D]):
context: C
data: D
class ContextParser(PrepareParser[C, ParsedContext[C, P], R], Generic[C, P, R]):
@abstractmethod
def prepare_context(self, data: str) -> P: ...
def prepare(self, data: C) -> ParsedContext[C, P]:
return ParsedContext(context=data, data=self.prepare_context(data.data))
+37 -12
View File
@@ -1,22 +1,47 @@
from typing import Generic, Iterable from typing import Generic, Iterable
from bs4 import Tag from bs4 import BeautifulSoup, Tag
from .core import ParseError, Parser, R from .core import C, ContextParser, ParseError, Parser, PrepareParser, R
from .util import set_dict_nested_value from .util import set_dict_nested_value
class TagParser(Parser[Tag, R], Generic[R]): def _resolve_tag(source: Tag, selector: str | None = None) -> Tag:
selector: str if selector:
tag = source.select_one(selector)
def extract(self, data: Tag) -> R:
raise NotImplementedError
def parse(self, data: Tag) -> R:
tag = data.select_one(self.selector) if self.selector else data
if tag is None: if tag is None:
raise ParseError("Tag not found", self.selector) raise ParseError("Tag not found", selector)
return self.extract(tag) return tag
return source
class _HTMLParser(Parser[str, BeautifulSoup]):
def parse(self, data: str) -> BeautifulSoup:
return BeautifulSoup(data, features="html.parser")
HTML_PARSER = _HTMLParser()
class HTMLParser(PrepareParser[str, Tag, R], Generic[R]):
selector: str | None = None
def prepare(self, data: str) -> Tag:
return _resolve_tag(HTML_PARSER.parse(data), self.selector)
class HTMLContextParser(ContextParser[C, Tag, R], Generic[C, R]):
selector: str | None = None
def prepare_context(self, data: str) -> Tag:
return _resolve_tag(HTML_PARSER.parse(data), self.selector)
class TagParser(PrepareParser[Tag, Tag, R], Generic[R]):
selector: str | None = None
def prepare(self, data: Tag) -> Tag:
return _resolve_tag(data, self.selector)
class TagIterParser(Parser[Tag, Iterable[R]], Generic[R]): class TagIterParser(Parser[Tag, Iterable[R]], Generic[R]):
+22
View File
@@ -0,0 +1,22 @@
import json
from typing import Any, Generic
from .core import C, ContextParser, Parser, PrepareParser, R
class _JSONParser(Parser[str, Any]):
def parse(self, data: str) -> Any:
return json.loads(data)
JSON_PARSER = _JSONParser()
class JSONParser(PrepareParser[str, Any, R], Generic[R]):
def prepare(self, data: str) -> Any:
return JSON_PARSER.parse(data)
class JSONContextParser(ContextParser[C, Any, R], Generic[C, R]):
def prepare_context(self, data: str) -> Any:
return JSON_PARSER.parse(data)