givememd 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- givememd/__init__.py +39 -0
- givememd/__main__.py +3 -0
- givememd/cli.py +23 -0
- givememd/components.py +189 -0
- givememd/compose.py +337 -0
- givememd/datatables.py +272 -0
- givememd/decide.py +144 -0
- givememd/extract.py +474 -0
- givememd/fetch.py +256 -0
- givememd/jev.py +146 -0
- givememd/mcp_server.py +30 -0
- givememd/server.py +67 -0
- givememd-0.1.0.dist-info/METADATA +181 -0
- givememd-0.1.0.dist-info/RECORD +17 -0
- givememd-0.1.0.dist-info/WHEEL +4 -0
- givememd-0.1.0.dist-info/entry_points.txt +4 -0
- givememd-0.1.0.dist-info/licenses/LICENSE +21 -0
givememd/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .extract import convert
|
|
4
|
+
from .fetch import TIERS, UnsafeURL, fetch
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def read(target, links=True, tiers=None):
|
|
8
|
+
"""URL -> (markdown, Result). Raises UnsafeURL for private/invalid targets."""
|
|
9
|
+
r = fetch(target, tiers=tiers)
|
|
10
|
+
if r.partial:
|
|
11
|
+
return convert(r.body, r.url, links=False, meta_only=True), r
|
|
12
|
+
if not r.ok:
|
|
13
|
+
return None, r
|
|
14
|
+
if 'markdown' in r.content_type:
|
|
15
|
+
return clean_site_markdown(r.body), r
|
|
16
|
+
md = convert(r.body, r.url, links=links)
|
|
17
|
+
later = [n for n, _ in TIERS[[n for n, _ in TIERS].index(r.tier) + 1:]] if r.tier in [n for n, _ in TIERS] else []
|
|
18
|
+
if later and len(body_text(md)) < 200 and r.body.count('<script') >= 3:
|
|
19
|
+
# the page "loaded" but its content is rendered by JavaScript (streamed payloads, app shells): render it
|
|
20
|
+
r2 = fetch(target, tiers=later, use_cache=False)
|
|
21
|
+
if r2.ok and 'markdown' not in r2.content_type:
|
|
22
|
+
md2 = convert(r2.body, r2.url, links=links)
|
|
23
|
+
if len(body_text(md2)) > len(body_text(md)):
|
|
24
|
+
r2.trace = r.trace + ['extraction was empty; rendered'] + r2.trace
|
|
25
|
+
return md2, r2
|
|
26
|
+
return md, r
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def clean_site_markdown(md):
|
|
30
|
+
"""Site-served markdown is often MDX: drop heading anchors and bare component tags, keep their content."""
|
|
31
|
+
md = re.sub(r'\s*\{/\*.*?\*/\}', '', md)
|
|
32
|
+
md = re.sub(r'(?m)^[ \t]*</?[A-Z][\w.]*(\s[^>]*)?/?>[ \t]*\n', '', md)
|
|
33
|
+
return re.sub(r'\n{3,}', '\n\n', md)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def body_text(md):
|
|
37
|
+
"""Text after the frontmatter, without markdown punctuation."""
|
|
38
|
+
parts = md.split('---', 2)
|
|
39
|
+
return re.sub(r'[#*_`>|\-\s]+', ' ', parts[2] if len(parts) == 3 else md).strip()
|
givememd/__main__.py
ADDED
givememd/cli.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""givememd <url> [--no-links] [--trace] -- print LLM-ready markdown for a web page (fetched from this machine)."""
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from . import UnsafeURL, read
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
args = [a for a in sys.argv[1:] if not a.startswith('--')]
|
|
9
|
+
if not args or '--help' in sys.argv or '-h' in sys.argv:
|
|
10
|
+
print(__doc__.strip()); sys.exit(0 if args or '--help' in sys.argv or '-h' in sys.argv else 2)
|
|
11
|
+
try:
|
|
12
|
+
md, r = read(args[0], links='--no-links' not in sys.argv)
|
|
13
|
+
except UnsafeURL as e:
|
|
14
|
+
sys.exit(f'refused: {e}')
|
|
15
|
+
if '--trace' in sys.argv:
|
|
16
|
+
print('\n'.join(r.trace), file=sys.stderr)
|
|
17
|
+
if md is None:
|
|
18
|
+
sys.exit(f'could not read {r.url}')
|
|
19
|
+
print(md)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
if __name__ == '__main__':
|
|
23
|
+
main()
|
givememd/components.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Component catalog: every section of a web page is matched to one of these, filled, and rendered.
|
|
2
|
+
|
|
3
|
+
Each component has a category (first-stage choice), a short description (the decision model's criterion),
|
|
4
|
+
a shape (how it is filled from the DOM and rendered), the fields it carries, and the JSON-LD types that can
|
|
5
|
+
fill it directly. Shapes:
|
|
6
|
+
|
|
7
|
+
header title + one line of facts kv key: value pairs
|
|
8
|
+
bullets list of short items prose paragraphs
|
|
9
|
+
steps numbered list qa question / answer pairs
|
|
10
|
+
table rows and columns cards repeated items -> one table
|
|
11
|
+
code code with surrounding text links titled links (rendered only if asked)
|
|
12
|
+
drop not content; never rendered
|
|
13
|
+
"""
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Component:
|
|
19
|
+
name: str
|
|
20
|
+
category: str
|
|
21
|
+
desc: str
|
|
22
|
+
shape: str
|
|
23
|
+
fields: tuple = ()
|
|
24
|
+
ld: tuple = () # JSON-LD @types that fill this component
|
|
25
|
+
heading: str = '' # section heading when rendered ('' = the section's own heading)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def C(name, category, desc, shape, fields='', ld='', heading=''):
|
|
29
|
+
return Component(name, category, desc, shape, tuple(fields.split()), tuple(ld.split()), heading)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
CATEGORIES = {
|
|
33
|
+
'commerce': 'a product for sale: its name, price, options, specs, shipping, reviews',
|
|
34
|
+
'listings': 'many items listed together: search results, category pages, homes, jobs, events, people',
|
|
35
|
+
'article': 'news, blog, essay or story content: headline, byline, body text, quotes, images',
|
|
36
|
+
'docs': 'technical documentation, API references, code, tutorials, changelogs',
|
|
37
|
+
'community': 'forum threads, questions and answers, comments, social posts',
|
|
38
|
+
'profile': 'a person, company, place or channel: name, bio, stats, contact',
|
|
39
|
+
'media': 'video, podcast, music, film, book or game details',
|
|
40
|
+
'events_travel': 'events, tickets, hotels, flights, rentals, itineraries',
|
|
41
|
+
'food_health': 'recipes, nutrition, menus, medical or fitness information',
|
|
42
|
+
'data_finance': 'tables, statistics, prices, charts, financial quotes, comparisons',
|
|
43
|
+
'business': 'company marketing: features, pricing plans, testimonials, FAQs, calls to action',
|
|
44
|
+
'reference': 'encyclopedia or knowledge content: infobox, definitions, timelines, citations',
|
|
45
|
+
'chrome': 'not content: navigation, footers, ads, cookie banners, login prompts, UI text',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
CATALOG = [
|
|
49
|
+
# ---- commerce
|
|
50
|
+
C('product_header', 'commerce', 'main product name, brand, price, rating', 'header', 'name brand price list_price rating rating_count availability', 'Product'),
|
|
51
|
+
C('price_offer', 'commerce', 'price, discount, sellers, deal or offer details', 'kv', 'price list_price discount seller condition', 'Offer AggregateOffer'),
|
|
52
|
+
C('variant_picker', 'commerce', 'color, size, style or configuration options', 'bullets', 'option values', heading='Options'),
|
|
53
|
+
C('spec_table', 'commerce', 'product specifications as key/value pairs', 'kv', 'specs', heading='Specs'),
|
|
54
|
+
C('feature_bullets', 'commerce', 'bullet list of product features or highlights', 'bullets', 'items', heading='Features'),
|
|
55
|
+
C('product_description', 'commerce', 'paragraphs describing the product', 'prose', 'text', heading='Description'),
|
|
56
|
+
C('whats_in_box', 'commerce', "list of what's included in the box", 'bullets', 'items', heading="What's in the box"),
|
|
57
|
+
C('shipping_returns', 'commerce', 'delivery, shipping, returns or warranty terms', 'kv', 'shipping returns warranty', heading='Shipping & returns'),
|
|
58
|
+
C('stock_status', 'commerce', 'in stock, sold out, quantity left, bought recently', 'kv', 'availability'),
|
|
59
|
+
C('review_summary', 'commerce', 'average rating, rating histogram, review topics', 'kv', 'rating count distribution topics', 'AggregateRating', 'Reviews'),
|
|
60
|
+
C('review_item', 'commerce', 'one customer review: author, stars, date, text', 'prose', 'author rating date title text', 'Review', 'Review'),
|
|
61
|
+
C('size_guide', 'commerce', 'size chart or fit guidance', 'table', 'rows', heading='Size guide'),
|
|
62
|
+
C('product_comparison', 'commerce', 'table comparing several products side by side', 'table', 'rows', heading='Comparison'),
|
|
63
|
+
C('bundle_offer', 'commerce', 'frequently bought together or bundle deal', 'links', 'items'),
|
|
64
|
+
C('seller_info', 'commerce', 'seller or store name, ratings, policies', 'kv', 'name rating', 'Store', 'Seller'),
|
|
65
|
+
# ---- listings
|
|
66
|
+
C('listing_grid', 'listings', 'grid or list of many products, homes, results', 'cards', 'items', 'ItemList', 'Results'),
|
|
67
|
+
C('listing_card', 'listings', 'one item in a listing: title, price, key facts', 'kv', 'title price facts'),
|
|
68
|
+
C('result_count', 'listings', 'number of results, sort order, page info', 'kv', 'count sort'),
|
|
69
|
+
C('filter_panel', 'listings', 'search filters and facets', 'drop'),
|
|
70
|
+
C('pagination', 'listings', 'page numbers, next/previous links', 'drop'),
|
|
71
|
+
C('real_estate_listing', 'listings', 'one home or rental: price, beds, baths, area, address', 'kv', 'address price beds baths area status', 'SingleFamilyResidence Apartment House Residence RealEstateListing'),
|
|
72
|
+
C('job_posting', 'listings', 'job title, company, location, salary, description', 'kv', 'title company location salary type posted', 'JobPosting'),
|
|
73
|
+
C('search_box', 'listings', 'search input and suggestions', 'drop'),
|
|
74
|
+
C('map_widget', 'listings', 'map with pins or location picker', 'drop'),
|
|
75
|
+
# ---- article
|
|
76
|
+
C('article_header', 'article', 'headline, byline, date, standfirst of an article', 'header', 'headline author date standfirst', 'NewsArticle Article BlogPosting Report'),
|
|
77
|
+
C('article_body', 'article', 'paragraphs of article or blog text', 'prose', 'text'),
|
|
78
|
+
C('subheading_section', 'article', 'subheading followed by its paragraphs', 'prose', 'text'),
|
|
79
|
+
C('pull_quote', 'article', 'highlighted quotation from the article', 'prose', 'text'),
|
|
80
|
+
C('image_caption', 'article', 'caption or credit for a photo or figure', 'prose', 'text'),
|
|
81
|
+
C('author_bio', 'article', 'short biography of the author', 'prose', 'text', heading='About the author'),
|
|
82
|
+
C('key_takeaways', 'article', 'summary bullets, TL;DR, key points', 'bullets', 'items', heading='Key points'),
|
|
83
|
+
C('table_of_contents', 'article', 'list of sections in this page', 'drop'),
|
|
84
|
+
C('related_articles', 'article', 'links to other stories: most viewed, read next, related', 'links', 'items'),
|
|
85
|
+
C('newsletter_signup', 'article', 'subscribe to a newsletter prompt', 'drop'),
|
|
86
|
+
C('correction_note', 'article', 'correction, update or editor note', 'prose', 'text'),
|
|
87
|
+
C('live_blog_entry', 'article', 'timestamped live blog update', 'prose', 'time text', 'LiveBlogPosting'),
|
|
88
|
+
C('embedded_post', 'article', 'embedded social media post inside an article', 'prose', 'author text'),
|
|
89
|
+
# ---- docs
|
|
90
|
+
C('doc_header', 'docs', 'title and summary of a documentation page', 'header', 'title summary', 'TechArticle'),
|
|
91
|
+
C('api_signature', 'docs', 'function, method or endpoint signature', 'code', 'signature'),
|
|
92
|
+
C('parameter_list', 'docs', 'parameters or arguments with descriptions', 'kv', 'params', heading='Parameters'),
|
|
93
|
+
C('return_value', 'docs', 'what a function returns', 'prose', 'text', heading='Returns'),
|
|
94
|
+
C('code_example', 'docs', 'code snippet, possibly with explanation', 'code', 'code text'),
|
|
95
|
+
C('doc_prose', 'docs', 'explanatory documentation text', 'prose', 'text'),
|
|
96
|
+
C('callout_note', 'docs', 'note, warning, tip or pitfall box', 'prose', 'kind text'),
|
|
97
|
+
C('install_instructions', 'docs', 'install or setup commands', 'code', 'code text', heading='Install'),
|
|
98
|
+
C('changelog_entry', 'docs', 'release notes for a version', 'bullets', 'version date items'),
|
|
99
|
+
C('compat_table', 'docs', 'browser or version compatibility table', 'table', 'rows', heading='Compatibility'),
|
|
100
|
+
C('readme_badges', 'docs', 'status badges and shields', 'drop'),
|
|
101
|
+
C('repo_header', 'docs', 'code repository name, description, stars, forks', 'header', 'name description stars forks language', 'SoftwareSourceCode'),
|
|
102
|
+
C('file_tree', 'docs', 'list of files and folders in a repository', 'drop'),
|
|
103
|
+
C('cli_reference', 'docs', 'command line usage and flags', 'code', 'code text'),
|
|
104
|
+
# ---- community
|
|
105
|
+
C('thread_header', 'community', 'forum or Q&A question title, author, stats', 'header', 'title author date votes answers views', 'QAPage DiscussionForumPosting'),
|
|
106
|
+
C('post_body', 'community', 'body of a forum post or question', 'prose', 'author date text'),
|
|
107
|
+
C('answer', 'community', 'one answer or reply with author and votes', 'prose', 'author votes date text', 'Answer Comment'),
|
|
108
|
+
C('comment_list', 'community', 'comments under a post', 'prose', 'items', heading='Comments'),
|
|
109
|
+
C('social_post', 'community', 'tweet, post or status with author and counts', 'prose', 'author date text likes reposts replies', 'SocialMediaPosting'),
|
|
110
|
+
C('poll', 'community', 'poll question and options with votes', 'kv', 'question options'),
|
|
111
|
+
C('community_rules', 'community', 'rules or guidelines of a forum or group', 'bullets', 'items', heading='Rules'),
|
|
112
|
+
C('subreddit_header', 'community', 'community or group name, members, description', 'header', 'name members description'),
|
|
113
|
+
# ---- profile
|
|
114
|
+
C('profile_header', 'profile', 'person or account name, handle, bio, follower counts', 'header', 'name handle bio followers following posts', 'Person ProfilePage'),
|
|
115
|
+
C('org_header', 'profile', 'company or organization name, industry, size, HQ', 'header', 'name industry size headquarters founded website', 'Organization Corporation'),
|
|
116
|
+
C('place_header', 'profile', 'business or place name, address, hours, rating', 'header', 'name address phone hours rating', 'LocalBusiness Restaurant Place'),
|
|
117
|
+
C('experience_list', 'profile', 'jobs or roles held with dates', 'bullets', 'items', heading='Experience'),
|
|
118
|
+
C('education_list', 'profile', 'schools and degrees', 'bullets', 'items', heading='Education'),
|
|
119
|
+
C('skills_list', 'profile', 'skills, tags or specialties', 'bullets', 'items', heading='Skills'),
|
|
120
|
+
C('contact_info', 'profile', 'email, phone, address, social links', 'kv', 'email phone address', heading='Contact'),
|
|
121
|
+
C('opening_hours', 'profile', 'opening hours by day', 'kv', 'hours', heading='Hours'),
|
|
122
|
+
C('post_grid', 'profile', "grid of an account's posts or videos", 'cards', 'items', heading='Posts'),
|
|
123
|
+
C('people_list', 'profile', 'list of people, employees, followers or members', 'cards', 'items', heading='People'),
|
|
124
|
+
# ---- media
|
|
125
|
+
C('video_header', 'media', 'video title, channel, views, date, description', 'header', 'title channel views date duration description', 'VideoObject'),
|
|
126
|
+
C('podcast_episode', 'media', 'podcast or episode title, show, date, summary', 'header', 'title show date duration summary', 'PodcastEpisode'),
|
|
127
|
+
C('film_header', 'media', 'film or show title, year, rating, director, cast', 'header', 'title year rating director cast genre', 'Movie TVSeries'),
|
|
128
|
+
C('book_header', 'media', 'book title, author, rating, pages, published', 'header', 'title author rating pages published', 'Book'),
|
|
129
|
+
C('music_header', 'media', 'song or album title, artist, release', 'header', 'title artist release', 'MusicRecording MusicAlbum'),
|
|
130
|
+
C('game_header', 'media', 'game or app name, developer, rating, platforms', 'header', 'name developer rating platforms', 'VideoGame SoftwareApplication MobileApplication'),
|
|
131
|
+
C('cast_list', 'media', 'cast and crew list', 'bullets', 'items', heading='Cast'),
|
|
132
|
+
C('episode_list', 'media', 'list of episodes or tracks', 'cards', 'items', heading='Episodes'),
|
|
133
|
+
C('transcript', 'media', 'transcript or lyrics text', 'prose', 'text', heading='Transcript'),
|
|
134
|
+
C('genre_tags', 'media', 'genres, tags or categories', 'bullets', 'items', heading='Genres'),
|
|
135
|
+
C('media_player', 'media', 'player controls, timestamps, playback UI', 'drop'),
|
|
136
|
+
# ---- events / travel
|
|
137
|
+
C('event_card', 'events_travel', 'event name, date, time, venue, price', 'kv', 'name date venue price', 'Event'),
|
|
138
|
+
C('ticket_options', 'events_travel', 'ticket types and prices', 'table', 'rows', heading='Tickets'),
|
|
139
|
+
C('hotel_header', 'events_travel', 'hotel or rental name, rating, price per night, location', 'header', 'name rating price location', 'Hotel LodgingBusiness VacationRental'),
|
|
140
|
+
C('amenities_list', 'events_travel', 'amenities or facilities', 'bullets', 'items', heading='Amenities'),
|
|
141
|
+
C('flight_option', 'events_travel', 'flight times, airline, stops, price', 'kv', 'airline depart arrive stops price', 'Flight'),
|
|
142
|
+
C('itinerary', 'events_travel', 'day-by-day plan or schedule', 'steps', 'items', heading='Itinerary'),
|
|
143
|
+
C('booking_policy', 'events_travel', 'cancellation, check-in or house rules', 'kv', 'rules', heading='Policies'),
|
|
144
|
+
# ---- food / health
|
|
145
|
+
C('recipe', 'food_health', 'recipe title, time, servings, rating', 'header', 'name time servings rating', 'Recipe'),
|
|
146
|
+
C('ingredients', 'food_health', 'list of recipe ingredients', 'bullets', 'items', heading='Ingredients'),
|
|
147
|
+
C('instructions', 'food_health', 'numbered cooking or how-to steps', 'steps', 'items', 'HowTo', 'Steps'),
|
|
148
|
+
C('nutrition_facts', 'food_health', 'calories and nutrients', 'kv', 'nutrients', 'NutritionInformation', 'Nutrition'),
|
|
149
|
+
C('menu', 'food_health', 'restaurant menu items and prices', 'cards', 'items', 'Menu', 'Menu'),
|
|
150
|
+
C('medical_info', 'food_health', 'symptoms, dosage, treatment or health facts', 'prose', 'text', 'MedicalCondition Drug'),
|
|
151
|
+
# ---- data / finance
|
|
152
|
+
C('data_table', 'data_finance', 'table of data with rows and columns', 'table', 'rows'),
|
|
153
|
+
C('stat_highlight', 'data_finance', 'big number or key statistic with label', 'kv', 'label value'),
|
|
154
|
+
C('stock_quote', 'data_finance', 'ticker, price, change, market data', 'kv', 'symbol price change volume market_cap'),
|
|
155
|
+
C('chart', 'data_finance', 'chart or graph with its labels', 'prose', 'title text'),
|
|
156
|
+
C('pricing_plans', 'business', 'subscription plans with prices and features', 'table', 'rows', heading='Pricing'),
|
|
157
|
+
# ---- business
|
|
158
|
+
C('hero', 'business', 'headline and tagline at the top of a landing page', 'header', 'headline tagline'),
|
|
159
|
+
C('feature_grid', 'business', 'grid of product features with short descriptions', 'kv', 'features', heading='Features'),
|
|
160
|
+
C('testimonial', 'business', 'customer quote or case study snippet', 'prose', 'author text'),
|
|
161
|
+
C('logo_wall', 'business', 'customer or partner logos', 'drop'),
|
|
162
|
+
C('faq', 'business', 'frequently asked questions and answers', 'qa', 'items', 'FAQPage Question', 'FAQ'),
|
|
163
|
+
C('call_to_action', 'business', 'sign up, get started, contact sales buttons', 'drop'),
|
|
164
|
+
C('team_section', 'business', 'team members with roles', 'cards', 'items', heading='Team'),
|
|
165
|
+
C('timeline', 'reference', 'dated events in order', 'bullets', 'items', heading='Timeline'),
|
|
166
|
+
# ---- reference
|
|
167
|
+
C('infobox', 'reference', 'summary box of key facts about a subject', 'kv', 'facts', heading='Facts'),
|
|
168
|
+
C('definition', 'reference', 'term and its definition', 'kv', 'term definition'),
|
|
169
|
+
C('encyclopedia_section', 'reference', 'section of encyclopedia text under a heading', 'prose', 'text'),
|
|
170
|
+
C('see_also', 'reference', 'see also list of related topics', 'links', 'items', heading='See also'),
|
|
171
|
+
C('citations', 'reference', 'references, footnotes, sources', 'drop'),
|
|
172
|
+
C('disambiguation', 'reference', 'list of meanings for an ambiguous term', 'bullets', 'items'),
|
|
173
|
+
# ---- chrome
|
|
174
|
+
C('navigation', 'chrome', 'site menus, tabs, category lists', 'drop'),
|
|
175
|
+
C('breadcrumb', 'chrome', 'category path like Home > Electronics > Headphones', 'kv', 'path', 'BreadcrumbList'),
|
|
176
|
+
C('footer', 'chrome', 'site footer, copyright, legal and policy links', 'drop'),
|
|
177
|
+
C('promo_ad', 'chrome', 'ads, sponsored items, credit card or upsell offers', 'drop'),
|
|
178
|
+
C('cookie_banner', 'chrome', 'cookie or consent notice', 'drop'),
|
|
179
|
+
C('login_prompt', 'chrome', 'sign in, create account or app download prompt', 'drop'),
|
|
180
|
+
C('share_buttons', 'chrome', 'share, save, like buttons', 'drop'),
|
|
181
|
+
C('ui_chrome', 'chrome', 'loading text, image placeholders, empty widgets, error text', 'drop'),
|
|
182
|
+
C('recommendations', 'chrome', 'customers also bought, you may also like, trending', 'links', 'items'),
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
BY_NAME = {c.name: c for c in CATALOG}
|
|
186
|
+
BY_CATEGORY = {cat: [c for c in CATALOG if c.category == cat] for cat in CATEGORIES}
|
|
187
|
+
BY_LD = {t: c for c in CATALOG for t in c.ld}
|
|
188
|
+
assert len(BY_NAME) == len(CATALOG), 'duplicate component name'
|
|
189
|
+
assert all(len(v) <= 255 for v in BY_CATEGORY.values())
|
givememd/compose.py
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
"""Component pipeline: segment the page finely, label each section with a catalog component, fill, render.
|
|
2
|
+
|
|
3
|
+
segments(root) -> Segment list in reading order (small, single-purpose sections)
|
|
4
|
+
page_type(...) -> one catalog category for the whole page (JSON-LD, else Jev, else rules)
|
|
5
|
+
label(...) -> a component per segment: confident rules first, Jev for the rest
|
|
6
|
+
render(...) -> markdown: components drawn with fixed layouts, junk dropped, same-kind runs merged
|
|
7
|
+
|
|
8
|
+
Turned on with convert(..., mode='components') or GIVEMEMD_COMPONENTS=1. Without a Jev key it still runs, with
|
|
9
|
+
rules-only labels (coarser: anything unclear is kept as generic prose).
|
|
10
|
+
"""
|
|
11
|
+
import hashlib, re
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from . import datatables, jev
|
|
16
|
+
from .components import BY_CATEGORY, BY_LD, BY_NAME, CATEGORIES, CATALOG
|
|
17
|
+
|
|
18
|
+
DROP_CONFIDENCE = float(__import__('os').environ.get('GIVEMEMD_DROP_CONFIDENCE', '0.7')) # a model must be this sure before content is thrown away; otherwise it is kept
|
|
19
|
+
MAXSEG = 700 # a section bigger than this is split into its children
|
|
20
|
+
LEAF = {'table', 'pre', 'ul', 'ol', 'dl'}
|
|
21
|
+
LEAF_MAX = 6000
|
|
22
|
+
HEADING = re.compile(r'^#{1,6} ')
|
|
23
|
+
FACT = re.compile(r'^[-\s]*([$€£¥₹]\s?[\d,.]+|[\d.]+ out of 5|\(?[\d,]+\)? ?(ratings|reviews)|[\d.]+ ?stars?)\b', re.I)
|
|
24
|
+
KV = re.compile(r'^- ?[^:]{1,40}: \S')
|
|
25
|
+
GENERIC = ['article_body', 'doc_prose', 'encyclopedia_section', 'data_table', 'image_caption', 'related_articles',
|
|
26
|
+
'see_also', 'faq', 'breadcrumb', 'key_takeaways', 'callout_note', 'code_example']
|
|
27
|
+
PROSE_FOR = {'article': 'article_body', 'docs': 'doc_prose', 'reference': 'encyclopedia_section', 'community': 'post_body',
|
|
28
|
+
'commerce': 'product_description', 'food_health': 'article_body', 'business': 'article_body'}
|
|
29
|
+
KV_FOR = {'commerce': 'spec_table', 'reference': 'infobox', 'docs': 'parameter_list', 'profile': 'contact_info'}
|
|
30
|
+
LD_CATEGORY = {'Product': 'commerce', 'NewsArticle': 'article', 'Article': 'article', 'BlogPosting': 'article',
|
|
31
|
+
'Recipe': 'food_health', 'Event': 'events_travel', 'JobPosting': 'listings', 'ItemList': 'listings',
|
|
32
|
+
'QAPage': 'community', 'DiscussionForumPosting': 'community', 'VideoObject': 'media', 'Movie': 'media',
|
|
33
|
+
'Book': 'media', 'Person': 'profile', 'ProfilePage': 'profile', 'Organization': 'profile',
|
|
34
|
+
'LocalBusiness': 'profile', 'SoftwareSourceCode': 'docs', 'TechArticle': 'docs', 'FAQPage': 'business',
|
|
35
|
+
'Hotel': 'events_travel', 'SingleFamilyResidence': 'listings'}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# when a page carries several JSON-LD entities, the main one is the most specific
|
|
39
|
+
LD_PRIORITY = ['Product', 'Recipe', 'Event', 'JobPosting', 'NewsArticle', 'BlogPosting', 'Article', 'TechArticle', 'QAPage',
|
|
40
|
+
'DiscussionForumPosting', 'Movie', 'Book', 'Hotel', 'SingleFamilyResidence', 'SoftwareSourceCode', 'LocalBusiness',
|
|
41
|
+
'Person', 'ProfilePage', 'VideoObject', 'FAQPage', 'ItemList', 'Organization']
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _types(o):
|
|
45
|
+
t = o.get('@type')
|
|
46
|
+
return t if isinstance(t, list) else [t]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main_entities(ld):
|
|
50
|
+
rank = lambda o: min((LD_PRIORITY.index(t) for t in _types(o) if t in LD_PRIORITY), default=len(LD_PRIORITY))
|
|
51
|
+
return sorted(ld, key=rank)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
# types that sites attach to almost any page; they don't say what the page is
|
|
55
|
+
WEAK_LD = {'Organization', 'ItemList', 'Person', 'VideoObject', 'Article', 'FAQPage', 'WebPage', 'WebSite', 'BreadcrumbList'}
|
|
56
|
+
PROSE_KINDS = ['article_body', 'subheading_section', 'doc_prose', 'encyclopedia_section', 'post_body', 'answer', 'review_item',
|
|
57
|
+
'product_description', 'callout_note', 'author_bio', 'testimonial', 'pull_quote', 'image_caption', 'medical_info',
|
|
58
|
+
'api_signature', 'parameter_list', 'return_value', 'transcript', 'related_articles', 'promo_ad', 'login_prompt', 'ui_chrome']
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Segment:
|
|
63
|
+
blocks: list # markdown blocks (from extract.MD)
|
|
64
|
+
tag: str = ''
|
|
65
|
+
links: list = field(default_factory=list)
|
|
66
|
+
label: str = ''
|
|
67
|
+
how: str = '' # 'rule:<name>' | 'jev' | 'default'
|
|
68
|
+
confidence: float = 1.0
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def text(self):
|
|
72
|
+
return ' '.join(re.sub(r'^#+ |^- ', '', b) for b in self.blocks)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ---------- segmentation ----------
|
|
76
|
+
def segments(root, url):
|
|
77
|
+
from .extract import MD
|
|
78
|
+
out = []
|
|
79
|
+
|
|
80
|
+
def emit(nodes, tag):
|
|
81
|
+
md = MD(url)
|
|
82
|
+
for n in nodes:
|
|
83
|
+
md.handle(n)
|
|
84
|
+
md.flush()
|
|
85
|
+
if md.blocks:
|
|
86
|
+
out.append(Segment(md.blocks, tag, md.links))
|
|
87
|
+
|
|
88
|
+
def rec(node):
|
|
89
|
+
run = [] # consecutive inline children (text, <span>, <a>...)
|
|
90
|
+
for c in node.iter(include_text=True):
|
|
91
|
+
if c.tag == '-text' or c.tag in ('span', 'a', 'b', 'strong', 'em', 'i', 'code', 'small', 'label', 'sup', 'sub', 'br'):
|
|
92
|
+
run.append(c); continue
|
|
93
|
+
if run:
|
|
94
|
+
emit(run, 'inline'); run = []
|
|
95
|
+
text = c.text(separator=' ')
|
|
96
|
+
n = len(re.sub(r'\s+', ' ', text).strip())
|
|
97
|
+
if n == 0:
|
|
98
|
+
continue
|
|
99
|
+
if c.tag in LEAF and n <= LEAF_MAX or c.tag in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
|
|
100
|
+
emit([c], c.tag); continue
|
|
101
|
+
if n <= MAXSEG and not _mixed(c, url):
|
|
102
|
+
emit([c], c.tag); continue
|
|
103
|
+
rec(c)
|
|
104
|
+
if run:
|
|
105
|
+
emit(run, 'inline')
|
|
106
|
+
|
|
107
|
+
rec(root)
|
|
108
|
+
return _attach_headings(out)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _mixed(node, url):
|
|
112
|
+
"""A small box that holds a price/rating fact next to other text (Amazon: price + Visa promo) is split."""
|
|
113
|
+
from .extract import MD
|
|
114
|
+
md = MD(url); md.walk(node); md.flush()
|
|
115
|
+
return len(md.blocks) > 1 and any(FACT.match(b) for b in md.blocks) and any(len(b) > 60 for b in md.blocks) \
|
|
116
|
+
and node.css_first('div,span,p,li') is not None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _attach_headings(segs):
|
|
120
|
+
"""A heading on its own belongs to the section that follows it."""
|
|
121
|
+
out, pending = [], []
|
|
122
|
+
for s in segs:
|
|
123
|
+
if all(HEADING.match(b) for b in s.blocks):
|
|
124
|
+
pending += s.blocks; continue
|
|
125
|
+
if pending:
|
|
126
|
+
if len(pending) >= 3: # a stack of empty headings (tab labels, "step 1..11") says nothing
|
|
127
|
+
pending = pending[-1:]
|
|
128
|
+
s.blocks = pending + s.blocks; pending = []
|
|
129
|
+
out.append(s)
|
|
130
|
+
if pending:
|
|
131
|
+
out.append(Segment(pending, 'h'))
|
|
132
|
+
for s in out: # the same rule inside one section
|
|
133
|
+
blocks, run = [], []
|
|
134
|
+
for b in s.blocks + ['']:
|
|
135
|
+
if b and HEADING.match(b):
|
|
136
|
+
run.append(b); continue
|
|
137
|
+
blocks += run[-1:] if len(run) >= 3 else run
|
|
138
|
+
run = []
|
|
139
|
+
if b: blocks.append(b)
|
|
140
|
+
s.blocks = blocks
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------- labeling ----------
|
|
145
|
+
def page_type(meta, ld, segs, tables, judge):
|
|
146
|
+
counts = Counter(t for o in ld for t in _types(o) if t)
|
|
147
|
+
repeated = [t for t, n in counts.items() if n >= 3 and t not in ('ListItem', 'BreadcrumbList', 'Question', 'Answer')]
|
|
148
|
+
if repeated: # many Events / Products / homes on one page = a listing
|
|
149
|
+
return 'listings', f'ld:{repeated[0]}x{counts[repeated[0]]}'
|
|
150
|
+
for o in main_entities(ld):
|
|
151
|
+
for t in _types(o):
|
|
152
|
+
if t in LD_CATEGORY and t not in WEAK_LD:
|
|
153
|
+
return LD_CATEGORY[t], f'ld:{t}'
|
|
154
|
+
if judge:
|
|
155
|
+
state = f"Web page: {meta.get('title', '')}\nURL: {meta.get('url', '')}\nOpening text: " + ' '.join(s.text for s in segs)[:1500]
|
|
156
|
+
a = judge.ask(state, {'page': {'type': 'choice', 'instructions': 'What kind of web page is this?',
|
|
157
|
+
'criteria': {k: v for k, v in CATEGORIES.items() if k != 'chrome'}}})
|
|
158
|
+
if a: return a['page']['choice'], 'jev'
|
|
159
|
+
if tables and len(tables[0].recs) >= 8: return 'listings', 'rule:table'
|
|
160
|
+
if sum(1 for s in segs if s.tag == 'pre') >= 3: return 'docs', 'rule:code'
|
|
161
|
+
return 'article', 'default'
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _rule(s, ptype):
|
|
165
|
+
"""Confident labels that need no model. Returns a component name or None."""
|
|
166
|
+
from .extract import UI_TEXT, DATA_MARK
|
|
167
|
+
body = [b for b in s.blocks if not HEADING.match(b)]
|
|
168
|
+
if any(DATA_MARK in b for b in s.blocks): return 'listing_grid'
|
|
169
|
+
if not body: return None
|
|
170
|
+
if all(UI_TEXT.match(b) for b in body): return 'ui_chrome'
|
|
171
|
+
if s.tag == 'pre' or all(b.startswith('```') for b in body): return 'code_example'
|
|
172
|
+
if sum(1 for b in body if KV.match(b)) >= max(2, 0.7 * len(body)): return KV_FOR.get(ptype, 'spec_table')
|
|
173
|
+
text = ' '.join(body)
|
|
174
|
+
link_chars = sum(len(t) for t, _ in s.links)
|
|
175
|
+
if len(text) >= 250 and link_chars < 0.25 * len(text) and not any(FACT.match(b) for b in body):
|
|
176
|
+
return 'prose?' # prose for sure; which kind is asked (cheaply) below
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def options(ptype):
|
|
181
|
+
names = [c.name for c in BY_CATEGORY.get(ptype, [])] + GENERIC + [c.name for c in BY_CATEGORY['chrome']]
|
|
182
|
+
return {n: BY_NAME[n].desc for n in dict.fromkeys(names)}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def label(segs, ptype, meta, judge):
|
|
186
|
+
ask, prose = [], []
|
|
187
|
+
for s in segs:
|
|
188
|
+
r = _rule(s, ptype)
|
|
189
|
+
if r == 'prose?':
|
|
190
|
+
s.label, s.how = PROSE_FOR.get(ptype, 'article_body'), 'rule'
|
|
191
|
+
prose.append(s)
|
|
192
|
+
elif r:
|
|
193
|
+
s.label, s.how = r, 'rule'
|
|
194
|
+
else:
|
|
195
|
+
ask.append(s)
|
|
196
|
+
if judge and (ask or prose):
|
|
197
|
+
crit, pcrit = options(ptype), {n: BY_NAME[n].desc for n in PROSE_KINDS}
|
|
198
|
+
state = f"Web page ({ptype}): {meta.get('title', '')}\nURL: {meta.get('url', '')}"
|
|
199
|
+
q = lambda s, c: {'type': 'choice', 'criteria': c, 'instructions': 'Which component is this section of the page?\nSection:\n' + s.text[:600]}
|
|
200
|
+
qs = {f's{i}': q(s, crit) for i, s in enumerate(ask)}
|
|
201
|
+
qs.update({f'p{i}': q(s, pcrit) for i, s in enumerate(prose)})
|
|
202
|
+
a = judge.ask(state, qs)
|
|
203
|
+
if a:
|
|
204
|
+
for i, s in enumerate(prose):
|
|
205
|
+
s.label, s.how, s.confidence = a[f'p{i}']['choice'], 'jev-prose', a[f'p{i}'].get('confidence', 0.0)
|
|
206
|
+
if BY_NAME[s.label].shape == 'drop' and s.confidence < DROP_CONFIDENCE:
|
|
207
|
+
s.label, s.how = PROSE_FOR.get(ptype, 'article_body'), 'kept'
|
|
208
|
+
for i, s in enumerate(ask):
|
|
209
|
+
s.label, s.how, s.confidence = a[f's{i}']['choice'], 'jev', a[f's{i}'].get('confidence', 0.0)
|
|
210
|
+
if BY_NAME[s.label].shape == 'drop' and s.confidence < DROP_CONFIDENCE:
|
|
211
|
+
s.label, s.how = PROSE_FOR.get(ptype, 'article_body'), f'kept (jev said {s.label} {s.confidence:.2f})'
|
|
212
|
+
for s in ask:
|
|
213
|
+
if not s.label: # no model: keep it, as generic content
|
|
214
|
+
s.label, s.how = PROSE_FOR.get(ptype, 'article_body'), 'default'
|
|
215
|
+
return segs
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------- filling + rendering ----------
|
|
219
|
+
ALIASES = {'name': ['name', 'headline', 'title'], 'headline': ['headline', 'name'], 'title': ['name', 'headline', 'title'],
|
|
220
|
+
'price': ['offers.price', 'offers.lowPrice', 'price'], 'brand': ['brand.name', 'brand'],
|
|
221
|
+
'rating': ['aggregateRating.ratingValue'], 'rating_count': ['aggregateRating.reviewCount', 'aggregateRating.ratingCount'],
|
|
222
|
+
'author': ['author.name', 'author'], 'date': ['datePublished', 'startDate', 'uploadDate'],
|
|
223
|
+
'availability': ['offers.availability'], 'standfirst': ['description'], 'description': ['description'],
|
|
224
|
+
'time': ['totalTime'], 'servings': ['recipeYield'], 'venue': ['location.name'], 'channel': ['author.name'],
|
|
225
|
+
'views': ['interactionStatistic.userInteractionCount'], 'followers': ['interactionStatistic.userInteractionCount']}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def ld_header(ld, ptype):
|
|
229
|
+
"""The page's main entity from JSON-LD, filled into its header component."""
|
|
230
|
+
for o in main_entities(ld):
|
|
231
|
+
types = _types(o)
|
|
232
|
+
comp = next((BY_LD[t] for t in types if t in BY_LD and BY_LD[t].shape == 'header'), None)
|
|
233
|
+
if not comp: continue
|
|
234
|
+
flat = datatables.flatten(o)
|
|
235
|
+
vals = {}
|
|
236
|
+
for f in comp.fields:
|
|
237
|
+
for k in ALIASES.get(f, [f]):
|
|
238
|
+
v = flat.get(k)
|
|
239
|
+
if v not in (None, '') and not str(v).startswith('http'):
|
|
240
|
+
vals[f] = re.sub(r'^https?://schema.org/', '', str(v)); break
|
|
241
|
+
if vals.get('name') or vals.get('headline') or vals.get('title'):
|
|
242
|
+
return comp, vals
|
|
243
|
+
return None, {}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def render_header(comp, vals):
|
|
247
|
+
title = vals.get('name') or vals.get('headline') or vals.get('title')
|
|
248
|
+
facts = [f'**{k.replace("_", " ").title()}:** {v}' for k, v in vals.items() if k not in ('name', 'headline', 'title', 'description', 'standfirst')]
|
|
249
|
+
out = [f'# {title}']
|
|
250
|
+
if facts: out.append(' · '.join(facts))
|
|
251
|
+
if vals.get('standfirst') or vals.get('description'): out.append(vals.get('standfirst') or vals.get('description'))
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
STEP_HEADING = re.compile(r'^#+ (step|stage|part)?\s*\d+\.?$', re.I)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def render_segment(s, comp, prev):
|
|
259
|
+
"""Blocks keep their page order; only the section's leading headings are treated as its title."""
|
|
260
|
+
lead = 0
|
|
261
|
+
while lead < len(s.blocks) and HEADING.match(s.blocks[lead]):
|
|
262
|
+
lead += 1
|
|
263
|
+
out, rest = list(s.blocks[:lead]), s.blocks[lead:]
|
|
264
|
+
if not out and comp.heading and prev != comp.name:
|
|
265
|
+
out.append(f'## {comp.heading}')
|
|
266
|
+
if comp.shape == 'header':
|
|
267
|
+
short = [b for b in rest if len(b) <= 80 and not HEADING.match(b)]
|
|
268
|
+
out += ([' · '.join(re.sub(r'^- ', '', b) for b in short)] if short else []) + [b for b in rest if b not in short]
|
|
269
|
+
elif comp.shape in ('bullets', 'links'):
|
|
270
|
+
out += [b if b.startswith(('- ', '|', '```', '#')) else f'- {b}' for b in rest[:15 if comp.shape == 'links' else None]]
|
|
271
|
+
elif comp.shape == 'steps': # "step 1".."step 11" headings carry nothing once the steps are numbered
|
|
272
|
+
out = [b for b in out if not STEP_HEADING.match(b)]
|
|
273
|
+
items = [re.sub(r'^- ', '', b) for b in rest if not STEP_HEADING.match(b)]
|
|
274
|
+
out += [b if HEADING.match(b) else f'{i}. {b}' for i, b in enumerate(items, 1)]
|
|
275
|
+
else: # kv, prose, qa, table, code: the blocks already have the right form
|
|
276
|
+
out += rest
|
|
277
|
+
return out
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# short facts that belong in the main header's facts line, keyed by the field they fill
|
|
281
|
+
FACT_FIELD = {'price_offer': 'Price', 'review_summary': 'Rating', 'stock_status': 'Availability', 'shipping_returns': 'Shipping',
|
|
282
|
+
'variant_picker': 'Options', 'seller_info': 'Seller', 'result_count': 'Results', 'stat_highlight': 'Stats',
|
|
283
|
+
'contact_info': 'Contact', 'opening_hours': 'Hours', 'genre_tags': 'Genres', 'breadcrumb': 'Category'}
|
|
284
|
+
NEAR = 8 # how far from the header a fact may sit
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def header_facts(segs):
|
|
288
|
+
"""Fill the main header component: index of the header section, and the nearby short fact sections that
|
|
289
|
+
fill its fields (price, rating, availability...)."""
|
|
290
|
+
h = next((i for i, s in enumerate(segs) if BY_NAME.get(s.label) and BY_NAME[s.label].shape == 'header'), None)
|
|
291
|
+
if h is None: return None, {}
|
|
292
|
+
facts = {}
|
|
293
|
+
for i in range(max(0, h - NEAR), min(len(segs), h + NEAR + 1)):
|
|
294
|
+
s = segs[i]
|
|
295
|
+
if s.label in FACT_FIELD and len(s.text) <= 120:
|
|
296
|
+
facts[i] = (FACT_FIELD[s.label], re.sub(r'^#+ ', '', s.text))
|
|
297
|
+
return h, facts
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def render(segs, ptype, meta, ld, cands):
|
|
301
|
+
from .extract import DATA_MARK
|
|
302
|
+
lines, prev, dropped = [], None, Counter()
|
|
303
|
+
comp, vals = ld_header(ld, ptype)
|
|
304
|
+
header_text = ''
|
|
305
|
+
if comp:
|
|
306
|
+
h = render_header(comp, vals)
|
|
307
|
+
lines += h; header_text = ' '.join(h).lower(); prev = comp.name
|
|
308
|
+
tables = [datatables.render([c]) for c in cands]
|
|
309
|
+
h, facts = header_facts(segs)
|
|
310
|
+
for i, s in enumerate(segs):
|
|
311
|
+
c = BY_NAME.get(s.label) or BY_NAME[PROSE_FOR.get(ptype, 'article_body')]
|
|
312
|
+
if c.shape == 'drop':
|
|
313
|
+
dropped[c.name] += 1; continue
|
|
314
|
+
if i in facts: continue # rendered inside the header's facts line
|
|
315
|
+
if i == h and facts:
|
|
316
|
+
block = render_segment(s, c, prev)
|
|
317
|
+
by_field = {}
|
|
318
|
+
for f, v in facts.values(): by_field.setdefault(f, []).append(v)
|
|
319
|
+
block.insert(1 if block and HEADING.match(block[0]) else 0,
|
|
320
|
+
' · '.join(f'**{f}:** ' + '; '.join(dict.fromkeys(v)) for f, v in by_field.items()))
|
|
321
|
+
if comp and c.name == comp.name:
|
|
322
|
+
block = [b for b in block if b.lower().lstrip('# ') not in header_text]
|
|
323
|
+
lines += block; prev = c.name; continue
|
|
324
|
+
if any(DATA_MARK in b for b in s.blocks) and tables:
|
|
325
|
+
lines += tables.pop(0); prev = 'listing_grid'; continue
|
|
326
|
+
block = render_segment(s, c, prev)
|
|
327
|
+
if comp and c.name == comp.name: # DOM copy of the JSON-LD header: keep only what it adds
|
|
328
|
+
block = [b for b in block if b.lower().lstrip('# ') not in header_text]
|
|
329
|
+
lines += block
|
|
330
|
+
prev = c.name
|
|
331
|
+
for t in tables:
|
|
332
|
+
lines += t
|
|
333
|
+
return lines, dropped
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def signature(s):
|
|
337
|
+
return hashlib.sha1(s.text[:200].encode()).hexdigest()[:12]
|