cs-bs4utils 20260912__tar.gz
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.
- cs_bs4utils-20260912/PKG-INFO +366 -0
- cs_bs4utils-20260912/pyproject.toml +390 -0
- cs_bs4utils-20260912/src/cs/bs4utils.py +652 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cs-bs4utils
|
|
3
|
+
Version: 20260912
|
|
4
|
+
Summary: Various utility functions and classes for working with the HTML soup from `beautifulsoup4`.
|
|
5
|
+
Keywords: python3
|
|
6
|
+
Author-email: Cameron Simpson <cs@cskk.id.au>
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Classifier: Programming Language :: Python
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: Text Processing
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
15
|
+
Requires-Dist: beautifulsoup4
|
|
16
|
+
Requires-Dist: cs.gimmicks>=20260311
|
|
17
|
+
Requires-Dist: cs.lex>=20260912
|
|
18
|
+
Requires-Dist: cs.pfx>=20260912
|
|
19
|
+
Requires-Dist: icontract
|
|
20
|
+
Requires-Dist: lxml
|
|
21
|
+
Requires-Dist: typguard
|
|
22
|
+
Project-URL: MonoRepo Commits, https://bitbucket.org/cameron_simpson/css/commits/branch/main
|
|
23
|
+
Project-URL: Monorepo Git Mirror, https://github.com/cameron-simpson/css
|
|
24
|
+
Project-URL: Monorepo Hg/Mercurial Mirror, https://hg.sr.ht/~cameron-simpson/css
|
|
25
|
+
Project-URL: Source, https://github.com/cameron-simpson/css/blob/main/lib/python/cs/bs4utils.py
|
|
26
|
+
|
|
27
|
+
Various utility functions and classes for working with the HTML soup from `beautifulsoup4`.
|
|
28
|
+
|
|
29
|
+
*Latest release 20260912*:
|
|
30
|
+
Initial PyPI release.
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
Short summary:
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
* `as_xml`: Transform `tag` into an `lxml` XML element.
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
* `child_tags`: A generator yielding the immediate child tags of `child` whose tag name is `child_name`. If `child_name` is `None`, yield all the immediate child tags, skipping things like strings and comments.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
* `find_heading`: Find the nearest heading satisfying the test `filter(tag)`. Return the tag or `None` is one is not found. The default `filter` tests that the heading is not empty. This uses `find_up` to locate the tag.
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
* `find_up`: A generator yielding `(found,ref)` 2-tuples obtained by search the tag tree left and up from `tag` using `.previous_sibling` and `.parent`, matching tags where `test(found)` is true.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
* `printt_soup`: Print the contents of the soup via `cs.lex.printt` using `tabulate_soup` to make the table.
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
* `Table`: A `Widget` subclass representing an HTML TABLE tag.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
* `tabulate_soup`: Return a table describing `soup` for use with `cs.lex.printt`. Connect tags with their child tags using Unicode box characters.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
* `Widget`: Base class for various "widget" HTML constructs, such as a TABLE, or in principle anything else regular on a page.
|
|
59
|
+
|
|
60
|
+
# Functions
|
|
61
|
+
|
|
62
|
+
## as_xml(tag: bs4.element.Tag, *, E=None)
|
|
63
|
+
|
|
64
|
+
Transform `tag` into an `lxml` XML element.
|
|
65
|
+
|
|
66
|
+
## child_tags(tag, child_name: str | None = None) -> Iterable[bs4.element.Tag]
|
|
67
|
+
|
|
68
|
+
A generator yielding the immediate child tags of `child`
|
|
69
|
+
whose tag name is `child_name`.
|
|
70
|
+
If `child_name` is `None`, yield all the immediate child tags,
|
|
71
|
+
skipping things like strings and comments.
|
|
72
|
+
|
|
73
|
+
## find_heading(tag, filter: Callable[[bs4.element.Tag], bool] = <function <lambda> at 0x110363ec0>) -> bs4.element.Tag | None
|
|
74
|
+
|
|
75
|
+
Find the nearest heading satisfying the test `filter(tag)`.
|
|
76
|
+
Return the tag or `None` is one is not found.
|
|
77
|
+
The default `filter` tests that the heading is not empty.
|
|
78
|
+
This uses `find_up` to locate the tag.
|
|
79
|
+
|
|
80
|
+
## find_up(tag, test: Union[str, Callable[[bs4.element.Tag], bool]], *, first=False) -> Generator[tuple[bs4.element.Tag, bs4.element.Tag], tuple[None, None], NoneType]
|
|
81
|
+
|
|
82
|
+
A generator yielding `(found,ref)` 2-tuples obtained by
|
|
83
|
+
search the tag tree left and up from `tag` using `.previous_sibling`
|
|
84
|
+
and `.parent`, matching tags where `test(found)` is true.
|
|
85
|
+
|
|
86
|
+
The `test` may be a tag `.name` value (a string) or a callable
|
|
87
|
+
to evaluate a ound tag.
|
|
88
|
+
|
|
89
|
+
If `first` is true (default `False`) then the search stops
|
|
90
|
+
after the first match. If there are no matches the tuple
|
|
91
|
+
`(None,None)` is returned (this does not happen if `first`
|
|
92
|
+
is false).
|
|
93
|
+
|
|
94
|
+
A primary use case for this is to find the heading tag for `tag`.
|
|
95
|
+
|
|
96
|
+
In the tuple, `found` is the matched tag. `ref` is the reference
|
|
97
|
+
tag, the later sibling of `found` where the search started
|
|
98
|
+
for that level; if `found` is at the same level as `tag` then
|
|
99
|
+
`ref` will be `tag`.
|
|
100
|
+
|
|
101
|
+
For example, to locate the level 2 heading governing a tag:
|
|
102
|
+
|
|
103
|
+
(h2,_), *_ = find_up(tag,lambda found: found.name == 'h2')
|
|
104
|
+
|
|
105
|
+
or more concisely:
|
|
106
|
+
|
|
107
|
+
(h2,_), *_ = find_up(tag, 'h2')
|
|
108
|
+
|
|
109
|
+
or even:
|
|
110
|
+
|
|
111
|
+
(h2,_), = find_up(tag, 'h2',first=True)
|
|
112
|
+
|
|
113
|
+
Note that the first two will cause Python to raise an exception
|
|
114
|
+
if there are no matches, while the third will provide `h2`
|
|
115
|
+
as `None`.
|
|
116
|
+
|
|
117
|
+
## printt_soup(tag: bs4.element.Tag, **printt_kw)
|
|
118
|
+
|
|
119
|
+
Print the contents of the soup via `cs.lex.printt`
|
|
120
|
+
using `tabulate_soup` to make the table.
|
|
121
|
+
|
|
122
|
+
## tabulate_soup(tag: bs4.element.Tag | bs4.element.NavigableString) -> list[list[str, str] | tuple]
|
|
123
|
+
|
|
124
|
+
Return a table describing `soup` for use with `cs.lex.printt`.
|
|
125
|
+
Connect tags with their child tags using Unicode box characters.
|
|
126
|
+
|
|
127
|
+
# Classes
|
|
128
|
+
|
|
129
|
+
## class Table(Widget)
|
|
130
|
+
|
|
131
|
+
A `Widget` subclass representing an HTML TABLE tag.
|
|
132
|
+
|
|
133
|
+
### Table.__init__(self, tag)
|
|
134
|
+
|
|
135
|
+
Scan the TABLE for the basic structures, used for the other properties etc later.
|
|
136
|
+
|
|
137
|
+
Note that if there was no TBODY, the immediate rows of the
|
|
138
|
+
TABLE are presented as though they were in a single TBODY.
|
|
139
|
+
|
|
140
|
+
### Table.IndexedCellValueType
|
|
141
|
+
|
|
142
|
+
Built-in immutable sequence.
|
|
143
|
+
|
|
144
|
+
If no argument is given, the constructor returns an empty tuple.
|
|
145
|
+
If iterable is specified the tuple is initialized from iterable's items.
|
|
146
|
+
|
|
147
|
+
If the argument is a tuple, the return value is the same object.
|
|
148
|
+
|
|
149
|
+
### Table.__firstlineno__
|
|
150
|
+
|
|
151
|
+
int([x]) -> integer
|
|
152
|
+
int(x, base=10) -> integer
|
|
153
|
+
|
|
154
|
+
Convert a number or string to an integer, or return 0 if no arguments
|
|
155
|
+
are given. If x is a number, return x.__int__(). For floating-point
|
|
156
|
+
numbers, this truncates towards zero.
|
|
157
|
+
|
|
158
|
+
If x is not a number or if base is given, then x must be a string,
|
|
159
|
+
bytes, or bytearray instance representing an integer literal in the
|
|
160
|
+
given base. The literal can be preceded by '+' or '-' and be surrounded
|
|
161
|
+
by whitespace. The base defaults to 10. Valid bases are 0 and 2-36.
|
|
162
|
+
Base 0 means to interpret the base from the string as an integer literal.
|
|
163
|
+
>>> int('0b100', base=0)
|
|
164
|
+
4
|
|
165
|
+
|
|
166
|
+
### Table.__static_attributes__
|
|
167
|
+
|
|
168
|
+
Built-in immutable sequence.
|
|
169
|
+
|
|
170
|
+
If no argument is given, the constructor returns an empty tuple.
|
|
171
|
+
If iterable is specified the tuple is initialized from iterable's items.
|
|
172
|
+
|
|
173
|
+
If the argument is a tuple, the return value is the same object.
|
|
174
|
+
|
|
175
|
+
### Table.all_rows
|
|
176
|
+
|
|
177
|
+
Return all the rows from the header, bodies, and footer.
|
|
178
|
+
|
|
179
|
+
### Table.as_indexed_values(self, *, convert: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_head_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_body_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_foot_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, omit_header=False, omit_footer=False) -> list[list[tuple[str, int, int, int, bs4.element.Tag, typing.Any]]]
|
|
180
|
+
|
|
181
|
+
Return the table contents as a list-of-lists of indexed cell values.
|
|
182
|
+
Each inner list contains the cell value records from a row.
|
|
183
|
+
|
|
184
|
+
this is an elaborate counterpart to the `as_lists` method.
|
|
185
|
+
|
|
186
|
+
Parameters:
|
|
187
|
+
* `convert`: the default cell conversion function
|
|
188
|
+
* `convert_head`: the header cell conversion function, default from `convert`
|
|
189
|
+
* `convert_body`: the body cell conversion function, default from `convert`
|
|
190
|
+
* `convert_foot`: the footer cell conversion function, default from `convert`
|
|
191
|
+
* `omit_header`: do not include rows from the `THEAD` section
|
|
192
|
+
* `omit_footer`: do not include rows from the `TFOOT` section
|
|
193
|
+
|
|
194
|
+
The conversion functions accept the following positional parameters:
|
|
195
|
+
* `section_type`: one of `"THEAD"`, `"TBODY"` or `"TFOOT"`
|
|
196
|
+
* `section_index`: the index of the section, 0 for the
|
|
197
|
+
header or footer but there may be multiple `TBODY` sections
|
|
198
|
+
* `row_index`: the index of the row within the section
|
|
199
|
+
* `col_index`: the index of the column within the row
|
|
200
|
+
* `cell`: the `TD` or `TH` tag for the cell
|
|
201
|
+
The function should return the converted value of `cell`.
|
|
202
|
+
The default conversion function returns `cell`.
|
|
203
|
+
|
|
204
|
+
The row and column indices supplied to the conversion unction
|
|
205
|
+
are of the _resolved_ cells, after expansion via the `colspan`
|
|
206
|
+
or `rowspan` values.
|
|
207
|
+
For example, a row with 3 cells whose second cell had a
|
|
208
|
+
`colspan=2` would be a list of 4 cells, with the second
|
|
209
|
+
original cell referenced in the second and third items of
|
|
210
|
+
the list; it _will_ be the same tag instance.
|
|
211
|
+
|
|
212
|
+
Each cell instance is converted only once; the same cell
|
|
213
|
+
spanning multiple columns or rows will have the same value
|
|
214
|
+
instance in the result record.
|
|
215
|
+
|
|
216
|
+
The resulting list-of-lists contains value records, a 6-tuple
|
|
217
|
+
of `(section_type,section_index,row_index,col_index,cell,value)`.
|
|
218
|
+
Note that the `row_index` and `col_index` are those of the
|
|
219
|
+
top left index where the `cell` was first encountered for
|
|
220
|
+
cells spanning multiple columns or rows.
|
|
221
|
+
|
|
222
|
+
Examples:
|
|
223
|
+
|
|
224
|
+
Convert every numeric cell to its `float` value, leave other cells as their text.
|
|
225
|
+
|
|
226
|
+
def as_float(section_type, section_index, row_index, column_index, cell):
|
|
227
|
+
text = cell.get_text.strip()
|
|
228
|
+
try:
|
|
229
|
+
value = float(text)
|
|
230
|
+
except ValueError:
|
|
231
|
+
value = text
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
values = T.as_indexed_values(convert=as_float)
|
|
235
|
+
|
|
236
|
+
Convert only the body cells, keep the headers as tags, omit the footer:
|
|
237
|
+
|
|
238
|
+
values = T.as_indexed_values(convert_body_cell=as_float, omit_footer=True)
|
|
239
|
+
|
|
240
|
+
### Table.as_lists(self, *, omit_header=False, omit_footer=False) -> list[list[bs4.element.Tag]]
|
|
241
|
+
|
|
242
|
+
Return the table contents as a list-of-lists-of-tags;
|
|
243
|
+
each inner list is a row of tags.
|
|
244
|
+
The innermost elements are the TH or TD tags.
|
|
245
|
+
Note that cells spanning multiple columns or rows via their
|
|
246
|
+
`colspan` or `rowspan` are the same reference.
|
|
247
|
+
|
|
248
|
+
Parameters:
|
|
249
|
+
* `omit_header`: do not include rows from the `THEAD` section
|
|
250
|
+
* `omit_footer`: do not include rows from the `TFOOT` section
|
|
251
|
+
|
|
252
|
+
### Table.body_rows
|
|
253
|
+
|
|
254
|
+
The rows from the table TBODY tags, if any.
|
|
255
|
+
Note that if there was no TBODY, the immediate rows of the
|
|
256
|
+
TABLE are presented as though they were in a single TBODY.
|
|
257
|
+
|
|
258
|
+
### Table.cell_colspan(cell: bs4.element.Tag) -> int
|
|
259
|
+
|
|
260
|
+
Compute the `colspan` value for a table cell.
|
|
261
|
+
|
|
262
|
+
### Table.cell_rowspan(cell: bs4.element.Tag) -> int
|
|
263
|
+
|
|
264
|
+
Compute the `rowspan` value for a table cell.
|
|
265
|
+
|
|
266
|
+
### Table.foot_rows
|
|
267
|
+
|
|
268
|
+
The rows from the table TFOOT, if any.
|
|
269
|
+
|
|
270
|
+
### Table.head_rows
|
|
271
|
+
|
|
272
|
+
The rows from the table THEAD, if any.
|
|
273
|
+
|
|
274
|
+
### Table.printt(self)
|
|
275
|
+
|
|
276
|
+
Print the table text.
|
|
277
|
+
|
|
278
|
+
### Table.row_cells(tr: bs4.element.Tag) -> list[bs4.element.Tag]
|
|
279
|
+
|
|
280
|
+
Return a list of the cells (`TD` or `TH`) from a `TR` tag.
|
|
281
|
+
`colspan` is supported by referencing the same cell multiple times.
|
|
282
|
+
Only `TD` and `TH` tags which are immediate children of the `TR` are recognised.
|
|
283
|
+
|
|
284
|
+
### Table.section_rows(section: bs4.element.Tag | None) -> list[list[bs4.element.Tag]]
|
|
285
|
+
|
|
286
|
+
Return the rows from a table section such as `THEAD`, `TBODY`, or `TFOOT`.
|
|
287
|
+
`rowspan` is supported by referencing the same cell in lower rows.
|
|
288
|
+
|
|
289
|
+
### Table.title
|
|
290
|
+
|
|
291
|
+
The title of the table, from the caption or the nearest heading.
|
|
292
|
+
|
|
293
|
+
## class Widget
|
|
294
|
+
|
|
295
|
+
Base class for various "widget" HTML constructs, such as a
|
|
296
|
+
TABLE, or in principle anything else regular on a page.
|
|
297
|
+
|
|
298
|
+
A `Widgwt` supplies:
|
|
299
|
+
- `__init__(tag)` to record the target BS4 tag, typically the
|
|
300
|
+
top level tag encompassing the wudget
|
|
301
|
+
- `find_all(soup)`: returning a list of the top level tags
|
|
302
|
+
within the BS4 tag `soup`; the default method calls
|
|
303
|
+
`soup.find_all()` with the lower case version of the class
|
|
304
|
+
name via `soup.find_all()`
|
|
305
|
+
- `scan(soup)`: a factory method calling `cls(tag)` for every
|
|
306
|
+
tag found by `find_all(soup)`
|
|
307
|
+
|
|
308
|
+
Everything else in a subclass supports whatever needs doing
|
|
309
|
+
with the widget; the `Table` class is an exemplar:
|
|
310
|
+
- its `__init__` method passes the tag to `super().__init__()`
|
|
311
|
+
as normal, then find s a few top level things about the table
|
|
312
|
+
- the caption, header, bodies, footer
|
|
313
|
+
- the default `find_all` is used because the lass name matches
|
|
314
|
+
the HTML tag name
|
|
315
|
+
- everything else more complex is provided as methods or
|
|
316
|
+
`@cached_property` properties, computed on demand
|
|
317
|
+
|
|
318
|
+
### Widget.__init__(self, tag: bs4.element.Tag)
|
|
319
|
+
|
|
320
|
+
Initialise this `Widget` by saving `tag` as `self.tag` and
|
|
321
|
+
then calling `self.scan()`.
|
|
322
|
+
|
|
323
|
+
### Widget.__dict__
|
|
324
|
+
|
|
325
|
+
Read-only proxy of a mapping.
|
|
326
|
+
|
|
327
|
+
### Widget.__firstlineno__
|
|
328
|
+
|
|
329
|
+
int([x]) -> integer
|
|
330
|
+
int(x, base=10) -> integer
|
|
331
|
+
|
|
332
|
+
Convert a number or string to an integer, or return 0 if no arguments
|
|
333
|
+
are given. If x is a number, return x.__int__(). For floating-point
|
|
334
|
+
numbers, this truncates towards zero.
|
|
335
|
+
|
|
336
|
+
If x is not a number or if base is given, then x must be a string,
|
|
337
|
+
bytes, or bytearray instance representing an integer literal in the
|
|
338
|
+
given base. The literal can be preceded by '+' or '-' and be surrounded
|
|
339
|
+
by whitespace. The base defaults to 10. Valid bases are 0 and 2-36.
|
|
340
|
+
Base 0 means to interpret the base from the string as an integer literal.
|
|
341
|
+
>>> int('0b100', base=0)
|
|
342
|
+
4
|
|
343
|
+
|
|
344
|
+
### Widget.__static_attributes__
|
|
345
|
+
|
|
346
|
+
Built-in immutable sequence.
|
|
347
|
+
|
|
348
|
+
If no argument is given, the constructor returns an empty tuple.
|
|
349
|
+
If iterable is specified the tuple is initialized from iterable's items.
|
|
350
|
+
|
|
351
|
+
If the argument is a tuple, the return value is the same object.
|
|
352
|
+
|
|
353
|
+
### Widget.find_all(soup) -> list[bs4.element.Tag]
|
|
354
|
+
|
|
355
|
+
The default `find_all` finds tags from `soup` whose name matches the class name.
|
|
356
|
+
|
|
357
|
+
### Widget.scan(soup) -> list[typing.Self]
|
|
358
|
+
|
|
359
|
+
Return a list of all `Widget`s of this type found in `soup`.
|
|
360
|
+
|
|
361
|
+
# Release Log
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
*Release 20260912*:
|
|
366
|
+
Initial PyPI release.
|
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "cs-bs4utils"
|
|
3
|
+
description = "Various utility functions and classes for working with the HTML soup from `beautifulsoup4`."
|
|
4
|
+
authors = [
|
|
5
|
+
{ name = "Cameron Simpson", email = "cs@cskk.id.au" },
|
|
6
|
+
]
|
|
7
|
+
keywords = [
|
|
8
|
+
"python3",
|
|
9
|
+
]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"beautifulsoup4",
|
|
12
|
+
"cs.gimmicks>=20260311",
|
|
13
|
+
"cs.lex>=20260912",
|
|
14
|
+
"cs.pfx>=20260912",
|
|
15
|
+
"icontract",
|
|
16
|
+
"lxml",
|
|
17
|
+
"typguard",
|
|
18
|
+
]
|
|
19
|
+
classifiers = [
|
|
20
|
+
"Programming Language :: Python",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Text Processing",
|
|
23
|
+
"Development Status :: 4 - Beta",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
27
|
+
]
|
|
28
|
+
version = "20260912"
|
|
29
|
+
|
|
30
|
+
[project.license]
|
|
31
|
+
text = "GNU General Public License v3 or later (GPLv3+)"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
"Monorepo Hg/Mercurial Mirror" = "https://hg.sr.ht/~cameron-simpson/css"
|
|
35
|
+
"Monorepo Git Mirror" = "https://github.com/cameron-simpson/css"
|
|
36
|
+
"MonoRepo Commits" = "https://bitbucket.org/cameron_simpson/css/commits/branch/main"
|
|
37
|
+
Source = "https://github.com/cameron-simpson/css/blob/main/lib/python/cs/bs4utils.py"
|
|
38
|
+
|
|
39
|
+
[project.readme]
|
|
40
|
+
text = """
|
|
41
|
+
Various utility functions and classes for working with the HTML soup from `beautifulsoup4`.
|
|
42
|
+
|
|
43
|
+
*Latest release 20260912*:
|
|
44
|
+
Initial PyPI release.
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
Short summary:
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
* `as_xml`: Transform `tag` into an `lxml` XML element.
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
* `child_tags`: A generator yielding the immediate child tags of `child` whose tag name is `child_name`. If `child_name` is `None`, yield all the immediate child tags, skipping things like strings and comments.
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
* `find_heading`: Find the nearest heading satisfying the test `filter(tag)`. Return the tag or `None` is one is not found. The default `filter` tests that the heading is not empty. This uses `find_up` to locate the tag.
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
* `find_up`: A generator yielding `(found,ref)` 2-tuples obtained by search the tag tree left and up from `tag` using `.previous_sibling` and `.parent`, matching tags where `test(found)` is true.
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
* `printt_soup`: Print the contents of the soup via `cs.lex.printt` using `tabulate_soup` to make the table.
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
* `Table`: A `Widget` subclass representing an HTML TABLE tag.
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
* `tabulate_soup`: Return a table describing `soup` for use with `cs.lex.printt`. Connect tags with their child tags using Unicode box characters.
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
* `Widget`: Base class for various \"widget\" HTML constructs, such as a TABLE, or in principle anything else regular on a page.
|
|
73
|
+
|
|
74
|
+
# Functions
|
|
75
|
+
|
|
76
|
+
## as_xml(tag: bs4.element.Tag, *, E=None)
|
|
77
|
+
|
|
78
|
+
Transform `tag` into an `lxml` XML element.
|
|
79
|
+
|
|
80
|
+
## child_tags(tag, child_name: str | None = None) -> Iterable[bs4.element.Tag]
|
|
81
|
+
|
|
82
|
+
A generator yielding the immediate child tags of `child`
|
|
83
|
+
whose tag name is `child_name`.
|
|
84
|
+
If `child_name` is `None`, yield all the immediate child tags,
|
|
85
|
+
skipping things like strings and comments.
|
|
86
|
+
|
|
87
|
+
## find_heading(tag, filter: Callable[[bs4.element.Tag], bool] = <function <lambda> at 0x110363ec0>) -> bs4.element.Tag | None
|
|
88
|
+
|
|
89
|
+
Find the nearest heading satisfying the test `filter(tag)`.
|
|
90
|
+
Return the tag or `None` is one is not found.
|
|
91
|
+
The default `filter` tests that the heading is not empty.
|
|
92
|
+
This uses `find_up` to locate the tag.
|
|
93
|
+
|
|
94
|
+
## find_up(tag, test: Union[str, Callable[[bs4.element.Tag], bool]], *, first=False) -> Generator[tuple[bs4.element.Tag, bs4.element.Tag], tuple[None, None], NoneType]
|
|
95
|
+
|
|
96
|
+
A generator yielding `(found,ref)` 2-tuples obtained by
|
|
97
|
+
search the tag tree left and up from `tag` using `.previous_sibling`
|
|
98
|
+
and `.parent`, matching tags where `test(found)` is true.
|
|
99
|
+
|
|
100
|
+
The `test` may be a tag `.name` value (a string) or a callable
|
|
101
|
+
to evaluate a ound tag.
|
|
102
|
+
|
|
103
|
+
If `first` is true (default `False`) then the search stops
|
|
104
|
+
after the first match. If there are no matches the tuple
|
|
105
|
+
`(None,None)` is returned (this does not happen if `first`
|
|
106
|
+
is false).
|
|
107
|
+
|
|
108
|
+
A primary use case for this is to find the heading tag for `tag`.
|
|
109
|
+
|
|
110
|
+
In the tuple, `found` is the matched tag. `ref` is the reference
|
|
111
|
+
tag, the later sibling of `found` where the search started
|
|
112
|
+
for that level; if `found` is at the same level as `tag` then
|
|
113
|
+
`ref` will be `tag`.
|
|
114
|
+
|
|
115
|
+
For example, to locate the level 2 heading governing a tag:
|
|
116
|
+
|
|
117
|
+
(h2,_), *_ = find_up(tag,lambda found: found.name == 'h2')
|
|
118
|
+
|
|
119
|
+
or more concisely:
|
|
120
|
+
|
|
121
|
+
(h2,_), *_ = find_up(tag, 'h2')
|
|
122
|
+
|
|
123
|
+
or even:
|
|
124
|
+
|
|
125
|
+
(h2,_), = find_up(tag, 'h2',first=True)
|
|
126
|
+
|
|
127
|
+
Note that the first two will cause Python to raise an exception
|
|
128
|
+
if there are no matches, while the third will provide `h2`
|
|
129
|
+
as `None`.
|
|
130
|
+
|
|
131
|
+
## printt_soup(tag: bs4.element.Tag, **printt_kw)
|
|
132
|
+
|
|
133
|
+
Print the contents of the soup via `cs.lex.printt`
|
|
134
|
+
using `tabulate_soup` to make the table.
|
|
135
|
+
|
|
136
|
+
## tabulate_soup(tag: bs4.element.Tag | bs4.element.NavigableString) -> list[list[str, str] | tuple]
|
|
137
|
+
|
|
138
|
+
Return a table describing `soup` for use with `cs.lex.printt`.
|
|
139
|
+
Connect tags with their child tags using Unicode box characters.
|
|
140
|
+
|
|
141
|
+
# Classes
|
|
142
|
+
|
|
143
|
+
## class Table(Widget)
|
|
144
|
+
|
|
145
|
+
A `Widget` subclass representing an HTML TABLE tag.
|
|
146
|
+
|
|
147
|
+
### Table.__init__(self, tag)
|
|
148
|
+
|
|
149
|
+
Scan the TABLE for the basic structures, used for the other properties etc later.
|
|
150
|
+
|
|
151
|
+
Note that if there was no TBODY, the immediate rows of the
|
|
152
|
+
TABLE are presented as though they were in a single TBODY.
|
|
153
|
+
|
|
154
|
+
### Table.IndexedCellValueType
|
|
155
|
+
|
|
156
|
+
Built-in immutable sequence.
|
|
157
|
+
|
|
158
|
+
If no argument is given, the constructor returns an empty tuple.
|
|
159
|
+
If iterable is specified the tuple is initialized from iterable's items.
|
|
160
|
+
|
|
161
|
+
If the argument is a tuple, the return value is the same object.
|
|
162
|
+
|
|
163
|
+
### Table.__firstlineno__
|
|
164
|
+
|
|
165
|
+
int([x]) -> integer
|
|
166
|
+
int(x, base=10) -> integer
|
|
167
|
+
|
|
168
|
+
Convert a number or string to an integer, or return 0 if no arguments
|
|
169
|
+
are given. If x is a number, return x.__int__(). For floating-point
|
|
170
|
+
numbers, this truncates towards zero.
|
|
171
|
+
|
|
172
|
+
If x is not a number or if base is given, then x must be a string,
|
|
173
|
+
bytes, or bytearray instance representing an integer literal in the
|
|
174
|
+
given base. The literal can be preceded by '+' or '-' and be surrounded
|
|
175
|
+
by whitespace. The base defaults to 10. Valid bases are 0 and 2-36.
|
|
176
|
+
Base 0 means to interpret the base from the string as an integer literal.
|
|
177
|
+
>>> int('0b100', base=0)
|
|
178
|
+
4
|
|
179
|
+
|
|
180
|
+
### Table.__static_attributes__
|
|
181
|
+
|
|
182
|
+
Built-in immutable sequence.
|
|
183
|
+
|
|
184
|
+
If no argument is given, the constructor returns an empty tuple.
|
|
185
|
+
If iterable is specified the tuple is initialized from iterable's items.
|
|
186
|
+
|
|
187
|
+
If the argument is a tuple, the return value is the same object.
|
|
188
|
+
|
|
189
|
+
### Table.all_rows
|
|
190
|
+
|
|
191
|
+
Return all the rows from the header, bodies, and footer.
|
|
192
|
+
|
|
193
|
+
### Table.as_indexed_values(self, *, convert: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_head_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_body_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, convert_foot_cell: Optional[Callable[[str, int, int, int, bs4.element.Tag], Any]] = None, omit_header=False, omit_footer=False) -> list[list[tuple[str, int, int, int, bs4.element.Tag, typing.Any]]]
|
|
194
|
+
|
|
195
|
+
Return the table contents as a list-of-lists of indexed cell values.
|
|
196
|
+
Each inner list contains the cell value records from a row.
|
|
197
|
+
|
|
198
|
+
this is an elaborate counterpart to the `as_lists` method.
|
|
199
|
+
|
|
200
|
+
Parameters:
|
|
201
|
+
* `convert`: the default cell conversion function
|
|
202
|
+
* `convert_head`: the header cell conversion function, default from `convert`
|
|
203
|
+
* `convert_body`: the body cell conversion function, default from `convert`
|
|
204
|
+
* `convert_foot`: the footer cell conversion function, default from `convert`
|
|
205
|
+
* `omit_header`: do not include rows from the `THEAD` section
|
|
206
|
+
* `omit_footer`: do not include rows from the `TFOOT` section
|
|
207
|
+
|
|
208
|
+
The conversion functions accept the following positional parameters:
|
|
209
|
+
* `section_type`: one of `\"THEAD\"`, `\"TBODY\"` or `\"TFOOT\"`
|
|
210
|
+
* `section_index`: the index of the section, 0 for the
|
|
211
|
+
header or footer but there may be multiple `TBODY` sections
|
|
212
|
+
* `row_index`: the index of the row within the section
|
|
213
|
+
* `col_index`: the index of the column within the row
|
|
214
|
+
* `cell`: the `TD` or `TH` tag for the cell
|
|
215
|
+
The function should return the converted value of `cell`.
|
|
216
|
+
The default conversion function returns `cell`.
|
|
217
|
+
|
|
218
|
+
The row and column indices supplied to the conversion unction
|
|
219
|
+
are of the _resolved_ cells, after expansion via the `colspan`
|
|
220
|
+
or `rowspan` values.
|
|
221
|
+
For example, a row with 3 cells whose second cell had a
|
|
222
|
+
`colspan=2` would be a list of 4 cells, with the second
|
|
223
|
+
original cell referenced in the second and third items of
|
|
224
|
+
the list; it _will_ be the same tag instance.
|
|
225
|
+
|
|
226
|
+
Each cell instance is converted only once; the same cell
|
|
227
|
+
spanning multiple columns or rows will have the same value
|
|
228
|
+
instance in the result record.
|
|
229
|
+
|
|
230
|
+
The resulting list-of-lists contains value records, a 6-tuple
|
|
231
|
+
of `(section_type,section_index,row_index,col_index,cell,value)`.
|
|
232
|
+
Note that the `row_index` and `col_index` are those of the
|
|
233
|
+
top left index where the `cell` was first encountered for
|
|
234
|
+
cells spanning multiple columns or rows.
|
|
235
|
+
|
|
236
|
+
Examples:
|
|
237
|
+
|
|
238
|
+
Convert every numeric cell to its `float` value, leave other cells as their text.
|
|
239
|
+
|
|
240
|
+
def as_float(section_type, section_index, row_index, column_index, cell):
|
|
241
|
+
text = cell.get_text.strip()
|
|
242
|
+
try:
|
|
243
|
+
value = float(text)
|
|
244
|
+
except ValueError:
|
|
245
|
+
value = text
|
|
246
|
+
return value
|
|
247
|
+
|
|
248
|
+
values = T.as_indexed_values(convert=as_float)
|
|
249
|
+
|
|
250
|
+
Convert only the body cells, keep the headers as tags, omit the footer:
|
|
251
|
+
|
|
252
|
+
values = T.as_indexed_values(convert_body_cell=as_float, omit_footer=True)
|
|
253
|
+
|
|
254
|
+
### Table.as_lists(self, *, omit_header=False, omit_footer=False) -> list[list[bs4.element.Tag]]
|
|
255
|
+
|
|
256
|
+
Return the table contents as a list-of-lists-of-tags;
|
|
257
|
+
each inner list is a row of tags.
|
|
258
|
+
The innermost elements are the TH or TD tags.
|
|
259
|
+
Note that cells spanning multiple columns or rows via their
|
|
260
|
+
`colspan` or `rowspan` are the same reference.
|
|
261
|
+
|
|
262
|
+
Parameters:
|
|
263
|
+
* `omit_header`: do not include rows from the `THEAD` section
|
|
264
|
+
* `omit_footer`: do not include rows from the `TFOOT` section
|
|
265
|
+
|
|
266
|
+
### Table.body_rows
|
|
267
|
+
|
|
268
|
+
The rows from the table TBODY tags, if any.
|
|
269
|
+
Note that if there was no TBODY, the immediate rows of the
|
|
270
|
+
TABLE are presented as though they were in a single TBODY.
|
|
271
|
+
|
|
272
|
+
### Table.cell_colspan(cell: bs4.element.Tag) -> int
|
|
273
|
+
|
|
274
|
+
Compute the `colspan` value for a table cell.
|
|
275
|
+
|
|
276
|
+
### Table.cell_rowspan(cell: bs4.element.Tag) -> int
|
|
277
|
+
|
|
278
|
+
Compute the `rowspan` value for a table cell.
|
|
279
|
+
|
|
280
|
+
### Table.foot_rows
|
|
281
|
+
|
|
282
|
+
The rows from the table TFOOT, if any.
|
|
283
|
+
|
|
284
|
+
### Table.head_rows
|
|
285
|
+
|
|
286
|
+
The rows from the table THEAD, if any.
|
|
287
|
+
|
|
288
|
+
### Table.printt(self)
|
|
289
|
+
|
|
290
|
+
Print the table text.
|
|
291
|
+
|
|
292
|
+
### Table.row_cells(tr: bs4.element.Tag) -> list[bs4.element.Tag]
|
|
293
|
+
|
|
294
|
+
Return a list of the cells (`TD` or `TH`) from a `TR` tag.
|
|
295
|
+
`colspan` is supported by referencing the same cell multiple times.
|
|
296
|
+
Only `TD` and `TH` tags which are immediate children of the `TR` are recognised.
|
|
297
|
+
|
|
298
|
+
### Table.section_rows(section: bs4.element.Tag | None) -> list[list[bs4.element.Tag]]
|
|
299
|
+
|
|
300
|
+
Return the rows from a table section such as `THEAD`, `TBODY`, or `TFOOT`.
|
|
301
|
+
`rowspan` is supported by referencing the same cell in lower rows.
|
|
302
|
+
|
|
303
|
+
### Table.title
|
|
304
|
+
|
|
305
|
+
The title of the table, from the caption or the nearest heading.
|
|
306
|
+
|
|
307
|
+
## class Widget
|
|
308
|
+
|
|
309
|
+
Base class for various \"widget\" HTML constructs, such as a
|
|
310
|
+
TABLE, or in principle anything else regular on a page.
|
|
311
|
+
|
|
312
|
+
A `Widgwt` supplies:
|
|
313
|
+
- `__init__(tag)` to record the target BS4 tag, typically the
|
|
314
|
+
top level tag encompassing the wudget
|
|
315
|
+
- `find_all(soup)`: returning a list of the top level tags
|
|
316
|
+
within the BS4 tag `soup`; the default method calls
|
|
317
|
+
`soup.find_all()` with the lower case version of the class
|
|
318
|
+
name via `soup.find_all()`
|
|
319
|
+
- `scan(soup)`: a factory method calling `cls(tag)` for every
|
|
320
|
+
tag found by `find_all(soup)`
|
|
321
|
+
|
|
322
|
+
Everything else in a subclass supports whatever needs doing
|
|
323
|
+
with the widget; the `Table` class is an exemplar:
|
|
324
|
+
- its `__init__` method passes the tag to `super().__init__()`
|
|
325
|
+
as normal, then find s a few top level things about the table
|
|
326
|
+
- the caption, header, bodies, footer
|
|
327
|
+
- the default `find_all` is used because the lass name matches
|
|
328
|
+
the HTML tag name
|
|
329
|
+
- everything else more complex is provided as methods or
|
|
330
|
+
`@cached_property` properties, computed on demand
|
|
331
|
+
|
|
332
|
+
### Widget.__init__(self, tag: bs4.element.Tag)
|
|
333
|
+
|
|
334
|
+
Initialise this `Widget` by saving `tag` as `self.tag` and
|
|
335
|
+
then calling `self.scan()`.
|
|
336
|
+
|
|
337
|
+
### Widget.__dict__
|
|
338
|
+
|
|
339
|
+
Read-only proxy of a mapping.
|
|
340
|
+
|
|
341
|
+
### Widget.__firstlineno__
|
|
342
|
+
|
|
343
|
+
int([x]) -> integer
|
|
344
|
+
int(x, base=10) -> integer
|
|
345
|
+
|
|
346
|
+
Convert a number or string to an integer, or return 0 if no arguments
|
|
347
|
+
are given. If x is a number, return x.__int__(). For floating-point
|
|
348
|
+
numbers, this truncates towards zero.
|
|
349
|
+
|
|
350
|
+
If x is not a number or if base is given, then x must be a string,
|
|
351
|
+
bytes, or bytearray instance representing an integer literal in the
|
|
352
|
+
given base. The literal can be preceded by '+' or '-' and be surrounded
|
|
353
|
+
by whitespace. The base defaults to 10. Valid bases are 0 and 2-36.
|
|
354
|
+
Base 0 means to interpret the base from the string as an integer literal.
|
|
355
|
+
>>> int('0b100', base=0)
|
|
356
|
+
4
|
|
357
|
+
|
|
358
|
+
### Widget.__static_attributes__
|
|
359
|
+
|
|
360
|
+
Built-in immutable sequence.
|
|
361
|
+
|
|
362
|
+
If no argument is given, the constructor returns an empty tuple.
|
|
363
|
+
If iterable is specified the tuple is initialized from iterable's items.
|
|
364
|
+
|
|
365
|
+
If the argument is a tuple, the return value is the same object.
|
|
366
|
+
|
|
367
|
+
### Widget.find_all(soup) -> list[bs4.element.Tag]
|
|
368
|
+
|
|
369
|
+
The default `find_all` finds tags from `soup` whose name matches the class name.
|
|
370
|
+
|
|
371
|
+
### Widget.scan(soup) -> list[typing.Self]
|
|
372
|
+
|
|
373
|
+
Return a list of all `Widget`s of this type found in `soup`.
|
|
374
|
+
|
|
375
|
+
# Release Log
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
*Release 20260912*:
|
|
380
|
+
Initial PyPI release."""
|
|
381
|
+
content-type = "text/markdown"
|
|
382
|
+
|
|
383
|
+
[build-system]
|
|
384
|
+
build-backend = "flit_core.buildapi"
|
|
385
|
+
requires = [
|
|
386
|
+
"flit_core >=3.2,<4",
|
|
387
|
+
]
|
|
388
|
+
|
|
389
|
+
[tool.flit.module]
|
|
390
|
+
name = "cs.bs4utils"
|
|
@@ -0,0 +1,652 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
|
|
3
|
+
''' Various utility functions and classes for working with the HTML soup from `beautifulsoup4`.
|
|
4
|
+
'''
|
|
5
|
+
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from typing import Any, Callable, Generator, Iterable, Self
|
|
8
|
+
|
|
9
|
+
from bs4 import BeautifulSoup, Tag as BS4Tag, NavigableString
|
|
10
|
+
from icontract import require
|
|
11
|
+
from lxml.builder import ElementMaker
|
|
12
|
+
from typeguard import typechecked
|
|
13
|
+
|
|
14
|
+
from cs.lex import cropped_repr, printt
|
|
15
|
+
from cs.pfx import pfx
|
|
16
|
+
from cs.gimmicks import warning
|
|
17
|
+
|
|
18
|
+
__version__ = '20260912'
|
|
19
|
+
|
|
20
|
+
DISTINFO = {
|
|
21
|
+
'keywords': ["python3"],
|
|
22
|
+
'classifiers': [
|
|
23
|
+
"Programming Language :: Python",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Topic :: Text Processing",
|
|
26
|
+
],
|
|
27
|
+
'install_requires': [
|
|
28
|
+
'cs.lex',
|
|
29
|
+
'cs.pfx',
|
|
30
|
+
'cs.gimmicks',
|
|
31
|
+
'beautifulsoup4',
|
|
32
|
+
'icontract',
|
|
33
|
+
'lxml',
|
|
34
|
+
'typguard',
|
|
35
|
+
],
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
# TODO: find_all(...,recursive=False) does this? apparently not?
|
|
39
|
+
def child_tags(tag, child_name: str | None = None) -> Iterable[BS4Tag]:
|
|
40
|
+
''' A generator yielding the immediate child tags of `child`
|
|
41
|
+
whose tag name is `child_name`.
|
|
42
|
+
If `child_name` is `None`, yield all the immediate child tags,
|
|
43
|
+
skipping things like strings and comments.
|
|
44
|
+
'''
|
|
45
|
+
for child in tag.children:
|
|
46
|
+
if isinstance(child, BS4Tag) and (child_name is None
|
|
47
|
+
or child.name == child_name):
|
|
48
|
+
yield child
|
|
49
|
+
|
|
50
|
+
def find_up(
|
|
51
|
+
tag,
|
|
52
|
+
test: str | Callable[[BS4Tag], bool],
|
|
53
|
+
*,
|
|
54
|
+
first=False
|
|
55
|
+
) -> Generator[tuple[BS4Tag, BS4Tag], tuple[None, None]]:
|
|
56
|
+
''' A generator yielding `(found,ref)` 2-tuples obtained by
|
|
57
|
+
search the tag tree left and up from `tag` using `.previous_sibling`
|
|
58
|
+
and `.parent`, matching tags where `test(found)` is true.
|
|
59
|
+
|
|
60
|
+
The `test` may be a tag `.name` value (a string) or a callable
|
|
61
|
+
to evaluate a ound tag.
|
|
62
|
+
|
|
63
|
+
If `first` is true (default `False`) then the search stops
|
|
64
|
+
after the first match. If there are no matches the tuple
|
|
65
|
+
`(None,None)` is returned (this does not happen if `first`
|
|
66
|
+
is false).
|
|
67
|
+
|
|
68
|
+
A primary use case for this is to find the heading tag for `tag`.
|
|
69
|
+
|
|
70
|
+
In the tuple, `found` is the matched tag. `ref` is the reference
|
|
71
|
+
tag, the later sibling of `found` where the search started
|
|
72
|
+
for that level; if `found` is at the same level as `tag` then
|
|
73
|
+
`ref` will be `tag`.
|
|
74
|
+
|
|
75
|
+
For example, to locate the level 2 heading governing a tag:
|
|
76
|
+
|
|
77
|
+
(h2,_), *_ = find_up(tag,lambda found: found.name == 'h2')
|
|
78
|
+
|
|
79
|
+
or more concisely:
|
|
80
|
+
|
|
81
|
+
(h2,_), *_ = find_up(tag, 'h2')
|
|
82
|
+
|
|
83
|
+
or even:
|
|
84
|
+
|
|
85
|
+
(h2,_), = find_up(tag, 'h2',first=True)
|
|
86
|
+
|
|
87
|
+
Note that the first two will cause Python to raise an exception
|
|
88
|
+
if there are no matches, while the third will provide `h2`
|
|
89
|
+
as `None`.
|
|
90
|
+
'''
|
|
91
|
+
if isinstance(test, str):
|
|
92
|
+
tag_name = test
|
|
93
|
+
test = lambda tag: tag.name is not None and tag.name == tag_name
|
|
94
|
+
reftag = tag
|
|
95
|
+
while reftag is not None:
|
|
96
|
+
prev = reftag.previous_sibling
|
|
97
|
+
while prev is not None:
|
|
98
|
+
if tag.name is not None and test(prev):
|
|
99
|
+
yield prev, reftag
|
|
100
|
+
if first:
|
|
101
|
+
return
|
|
102
|
+
prev = prev.previous_sibling
|
|
103
|
+
reftag = reftag.parent
|
|
104
|
+
if first:
|
|
105
|
+
yield None, None
|
|
106
|
+
|
|
107
|
+
def find_heading(
|
|
108
|
+
tag,
|
|
109
|
+
filter: Callable[[BS4Tag],
|
|
110
|
+
bool] = (lambda tag: len(tag.get_text().strip()) > 0),
|
|
111
|
+
) -> BS4Tag | None:
|
|
112
|
+
''' Find the nearest heading satisfying the test `filter(tag)`.
|
|
113
|
+
Return the tag or `None` is one is not found.
|
|
114
|
+
The default `filter` tests that the heading is not empty.
|
|
115
|
+
This uses `find_up` to locate the tag.
|
|
116
|
+
'''
|
|
117
|
+
(h, _), = find_up(
|
|
118
|
+
tag,
|
|
119
|
+
lambda tag: (
|
|
120
|
+
tag.name and tag.name.startswith('h') and tag.name[1:].isdigit() and
|
|
121
|
+
filter(tag)
|
|
122
|
+
),
|
|
123
|
+
first=True,
|
|
124
|
+
)
|
|
125
|
+
return h
|
|
126
|
+
|
|
127
|
+
@typechecked
|
|
128
|
+
def tabulate_soup(
|
|
129
|
+
tag: BS4Tag | NavigableString
|
|
130
|
+
) -> list[list[str, str] | tuple]:
|
|
131
|
+
''' Return a table describing `soup` for use with `cs.lex.printt`.
|
|
132
|
+
Connect tags with their child tags using Unicode box characters.
|
|
133
|
+
'''
|
|
134
|
+
table = []
|
|
135
|
+
if isinstance(tag, NavigableString):
|
|
136
|
+
text = str(tag).strip()
|
|
137
|
+
if text:
|
|
138
|
+
table.append(['', text])
|
|
139
|
+
else:
|
|
140
|
+
# A tag with interior content.
|
|
141
|
+
attrs = dict(tag.attrs)
|
|
142
|
+
label = tag.name
|
|
143
|
+
# pop off the id attribute if present, include in the label
|
|
144
|
+
try:
|
|
145
|
+
id_attr = attrs.pop('id')
|
|
146
|
+
except KeyError:
|
|
147
|
+
pass
|
|
148
|
+
else:
|
|
149
|
+
label += f' #{id_attr}'
|
|
150
|
+
# pop off the name attribute if present, include in the label
|
|
151
|
+
try:
|
|
152
|
+
name_attr = attrs.pop('name')
|
|
153
|
+
except KeyError:
|
|
154
|
+
pass
|
|
155
|
+
else:
|
|
156
|
+
# I saw an amazon page embed an obscene amount of JSON in a
|
|
157
|
+
# name attribute :-(
|
|
158
|
+
label += f' name={cropped_repr(name_attr)}'
|
|
159
|
+
children = list(
|
|
160
|
+
child for child in tag.children if isinstance(child, NavigableString)
|
|
161
|
+
or child.name not in ('script', 'style')
|
|
162
|
+
)
|
|
163
|
+
# count the subtags which aren't strings
|
|
164
|
+
nsubtags = sum(
|
|
165
|
+
not isinstance(child, NavigableString) for child in children
|
|
166
|
+
)
|
|
167
|
+
if not attrs and len(children) == 1 and isinstance(children[0],
|
|
168
|
+
NavigableString):
|
|
169
|
+
# The super compact form:
|
|
170
|
+
# a tag with no attrs and some text puts the text beside the tag name.
|
|
171
|
+
assert nsubtags == 0
|
|
172
|
+
text = f'{str(children[0]).strip()}'
|
|
173
|
+
table.append([label, text])
|
|
174
|
+
else:
|
|
175
|
+
attr_text = "\n".join(
|
|
176
|
+
f'{attr}={value!r}' for attr, value in sorted(attrs.items())
|
|
177
|
+
)
|
|
178
|
+
table.append([label, attr_text])
|
|
179
|
+
if children:
|
|
180
|
+
subtable = []
|
|
181
|
+
for child in children:
|
|
182
|
+
subtable.extend(tabulate_soup(child))
|
|
183
|
+
table.append(tuple(subtable))
|
|
184
|
+
return table
|
|
185
|
+
|
|
186
|
+
def printt_soup(tag: BS4Tag, **printt_kw):
|
|
187
|
+
''' Print the contents of the soup via `cs.lex.printt`
|
|
188
|
+
using `tabulate_soup` to make the table.
|
|
189
|
+
'''
|
|
190
|
+
if isinstance(tag, BS4Tag) and tag.name == 'html':
|
|
191
|
+
table = []
|
|
192
|
+
if tag.head:
|
|
193
|
+
table.extend(tabulate_soup(tag.head))
|
|
194
|
+
table.extend(tabulate_soup(tag.body))
|
|
195
|
+
else:
|
|
196
|
+
table = tabulate_soup(tag)
|
|
197
|
+
printt(*table, **printt_kw)
|
|
198
|
+
|
|
199
|
+
def as_xml(tag: BS4Tag, *, E=None):
|
|
200
|
+
''' Transform `tag` into an `lxml` XML element.
|
|
201
|
+
'''
|
|
202
|
+
if E is None:
|
|
203
|
+
E = ElementMaker()
|
|
204
|
+
return E(tag.name, *map(as_xml, tag.children), **tag.attrs)
|
|
205
|
+
|
|
206
|
+
class Widget:
|
|
207
|
+
''' Base class for various "widget" HTML constructs, such as a
|
|
208
|
+
TABLE, or in principle anything else regular on a page.
|
|
209
|
+
|
|
210
|
+
A `Widgwt` supplies:
|
|
211
|
+
- `__init__(tag)` to record the target BS4 tag, typically the
|
|
212
|
+
top level tag encompassing the wudget
|
|
213
|
+
- `find_all(soup)`: returning a list of the top level tags
|
|
214
|
+
within the BS4 tag `soup`; the default method calls
|
|
215
|
+
`soup.find_all()` with the lower case version of the class
|
|
216
|
+
name via `soup.find_all()`
|
|
217
|
+
- `scan(soup)`: a factory method calling `cls(tag)` for every
|
|
218
|
+
tag found by `find_all(soup)`
|
|
219
|
+
|
|
220
|
+
Everything else in a subclass supports whatever needs doing
|
|
221
|
+
with the widget; the `Table` class is an exemplar:
|
|
222
|
+
- its `__init__` method passes the tag to `super().__init__()`
|
|
223
|
+
as normal, then find s a few top level things about the table
|
|
224
|
+
- the caption, header, bodies, footer
|
|
225
|
+
- the default `find_all` is used because the lass name matches
|
|
226
|
+
the HTML tag name
|
|
227
|
+
- everything else more complex is provided as methods or
|
|
228
|
+
`@cached_property` properties, computed on demand
|
|
229
|
+
'''
|
|
230
|
+
|
|
231
|
+
def __init__(self, tag: BS4Tag):
|
|
232
|
+
''' Initialise this `Widget` by saving `tag` as `self.tag` and
|
|
233
|
+
then calling `self.scan()`.
|
|
234
|
+
'''
|
|
235
|
+
self.tag = tag
|
|
236
|
+
|
|
237
|
+
@classmethod
|
|
238
|
+
def find_all(cls, soup) -> list[BS4Tag]:
|
|
239
|
+
''' The default `find_all` finds tags from `soup` whose name matches the class name.
|
|
240
|
+
'''
|
|
241
|
+
return soup.find_all(cls.__name__.lower())
|
|
242
|
+
|
|
243
|
+
@classmethod
|
|
244
|
+
def scan(cls, soup) -> list[Self]:
|
|
245
|
+
''' Return a list of all `Widget`s of this type found in `soup`.
|
|
246
|
+
'''
|
|
247
|
+
return [cls(tag) for tag in cls.find_all(soup)]
|
|
248
|
+
|
|
249
|
+
class Table(Widget):
|
|
250
|
+
''' A `Widget` subclass representing an HTML TABLE tag.
|
|
251
|
+
'''
|
|
252
|
+
|
|
253
|
+
def __init__(self, tag):
|
|
254
|
+
''' Scan the TABLE for the basic structures, used for the other properties etc later.
|
|
255
|
+
|
|
256
|
+
Note that if there was no TBODY, the immediate rows of the
|
|
257
|
+
TABLE are presented as though they were in a single TBODY.
|
|
258
|
+
'''
|
|
259
|
+
super().__init__(tag)
|
|
260
|
+
self.caption = tag.find('caption')
|
|
261
|
+
self.colgroups = tag.find_all('colgroup', recursive=False)
|
|
262
|
+
self.thead = tag.find('thead')
|
|
263
|
+
self.tbodies = tag.find_all('tbody')
|
|
264
|
+
if not self.tbodies:
|
|
265
|
+
# fake up a single TBODY if there are none
|
|
266
|
+
tbody = BS4Tag(name='tbody')
|
|
267
|
+
for tr in tag.find_all('tr', recursive=False):
|
|
268
|
+
tbody.append(tr)
|
|
269
|
+
self.tbodies = [tbody]
|
|
270
|
+
self.tfoot = tag.find('tfoot')
|
|
271
|
+
|
|
272
|
+
@staticmethod
|
|
273
|
+
def cell_colspan(cell: BS4Tag) -> int:
|
|
274
|
+
''' Compute the `colspan` value for a table cell.
|
|
275
|
+
'''
|
|
276
|
+
colspan = cell.attrs.get("colspan", 1)
|
|
277
|
+
try:
|
|
278
|
+
colspan = int(colspan)
|
|
279
|
+
except ValueError as e:
|
|
280
|
+
warning(f'invalid {colspan=} ({e}), using 1: {cell}')
|
|
281
|
+
colspan = 1
|
|
282
|
+
return colspan
|
|
283
|
+
|
|
284
|
+
@staticmethod
|
|
285
|
+
def cell_rowspan(cell: BS4Tag) -> int:
|
|
286
|
+
''' Compute the `rowspan` value for a table cell.
|
|
287
|
+
'''
|
|
288
|
+
rowspan = cell.attrs.get("rowspan", 1)
|
|
289
|
+
try:
|
|
290
|
+
rowspan = int(rowspan)
|
|
291
|
+
except ValueError as e:
|
|
292
|
+
warning(f'invalid {rowspan=} ({e}), using 1: {cell}')
|
|
293
|
+
rowspan = 1
|
|
294
|
+
return rowspan
|
|
295
|
+
|
|
296
|
+
@classmethod
|
|
297
|
+
def row_cells(cls, tr: BS4Tag) -> list[BS4Tag]:
|
|
298
|
+
''' Return a list of the cells (`TD` or `TH`) from a `TR` tag.
|
|
299
|
+
`colspan` is supported by referencing the same cell multiple times.
|
|
300
|
+
Only `TD` and `TH` tags which are immediate children of the `TR` are recognised.
|
|
301
|
+
'''
|
|
302
|
+
cells = []
|
|
303
|
+
for cell in tr.find_all(lambda tag: tag.name in ('th', 'td'),
|
|
304
|
+
recursive=False):
|
|
305
|
+
colspan = cls.cell_colspan(cell)
|
|
306
|
+
for _ in range(colspan):
|
|
307
|
+
cells.append(cell)
|
|
308
|
+
return cells
|
|
309
|
+
|
|
310
|
+
@classmethod
|
|
311
|
+
@require(lambda section: section.name in ('thead', 'tbody', 'tfoot'))
|
|
312
|
+
def section_rows(cls, section: BS4Tag | None) -> list[list[BS4Tag]]:
|
|
313
|
+
''' Return the rows from a table section such as `THEAD`, `TBODY`, or `TFOOT`.
|
|
314
|
+
`rowspan` is supported by referencing the same cell in lower rows.
|
|
315
|
+
'''
|
|
316
|
+
if section is None:
|
|
317
|
+
return []
|
|
318
|
+
trs = section.find_all('tr', recursive=False)
|
|
319
|
+
rows = [[] for _ in trs]
|
|
320
|
+
for row_index, row_cells in enumerate(cls.row_cells(tr) for tr in trs):
|
|
321
|
+
row = rows[row_index]
|
|
322
|
+
assert rows[row_index] is row
|
|
323
|
+
cell_pos = 0
|
|
324
|
+
for cell in row_cells:
|
|
325
|
+
# advance past any cells presupplied by a rowspan
|
|
326
|
+
while cell_pos < len(row) and row[cell_pos] is not None:
|
|
327
|
+
cell_pos += 1
|
|
328
|
+
if cell_pos < len(row):
|
|
329
|
+
assert row[cell_pos] is None
|
|
330
|
+
row[cell_pos] = cell
|
|
331
|
+
else:
|
|
332
|
+
assert cell_pos == len(row)
|
|
333
|
+
row.append(cell)
|
|
334
|
+
# propagate this cell to further rows for its rowspan
|
|
335
|
+
for offset in range(1, cls.cell_rowspan(cell)):
|
|
336
|
+
subindex = row_index + offset
|
|
337
|
+
if subindex == len(rows):
|
|
338
|
+
subrow = []
|
|
339
|
+
rows.append(subrow)
|
|
340
|
+
else:
|
|
341
|
+
assert subindex < len(rows)
|
|
342
|
+
subrow = rows[subindex]
|
|
343
|
+
while len(subrow) < cell_pos:
|
|
344
|
+
subrow.append(None)
|
|
345
|
+
if cell_pos < len(subrow):
|
|
346
|
+
subrow[cell_pos] = cell
|
|
347
|
+
else:
|
|
348
|
+
assert len(subrow) == cell_pos
|
|
349
|
+
subrow.append(cell)
|
|
350
|
+
cell_pos += 1
|
|
351
|
+
# infill None cells with empty TD tags
|
|
352
|
+
for row in rows[1:]:
|
|
353
|
+
for i, cell in enumerate(row):
|
|
354
|
+
if cell is None:
|
|
355
|
+
row[i] = BS4Tag(name='td')
|
|
356
|
+
return rows
|
|
357
|
+
|
|
358
|
+
@cached_property
|
|
359
|
+
def head_rows(self) -> list[list[BS4Tag]]:
|
|
360
|
+
''' The rows from the table THEAD, if any.
|
|
361
|
+
'''
|
|
362
|
+
return self.section_rows(self.thead)
|
|
363
|
+
|
|
364
|
+
@cached_property
|
|
365
|
+
def body_rows(self) -> list[list[BS4Tag]]:
|
|
366
|
+
''' The rows from the table TBODY tags, if any.
|
|
367
|
+
Note that if there was no TBODY, the immediate rows of the
|
|
368
|
+
TABLE are presented as though they were in a single TBODY.
|
|
369
|
+
'''
|
|
370
|
+
rows = []
|
|
371
|
+
for body in self.tbodies:
|
|
372
|
+
rows.extend(self.section_rows(body))
|
|
373
|
+
return rows
|
|
374
|
+
|
|
375
|
+
@cached_property
|
|
376
|
+
def foot_rows(self) -> list[list[BS4Tag]]:
|
|
377
|
+
''' The rows from the table TFOOT, if any.
|
|
378
|
+
'''
|
|
379
|
+
return self.section_rows(self.tfoot)
|
|
380
|
+
|
|
381
|
+
def as_lists(self,
|
|
382
|
+
*,
|
|
383
|
+
omit_header=False,
|
|
384
|
+
omit_footer=False) -> list[list[BS4Tag]]:
|
|
385
|
+
''' Return the table contents as a list-of-lists-of-tags;
|
|
386
|
+
each inner list is a row of tags.
|
|
387
|
+
The innermost elements are the TH or TD tags.
|
|
388
|
+
Note that cells spanning multiple columns or rows via their
|
|
389
|
+
`colspan` or `rowspan` are the same reference.
|
|
390
|
+
|
|
391
|
+
Parameters:
|
|
392
|
+
* `omit_header`: do not include rows from the `THEAD` section
|
|
393
|
+
* `omit_footer`: do not include rows from the `TFOOT` section
|
|
394
|
+
'''
|
|
395
|
+
rows = self.all_rows = []
|
|
396
|
+
if not omit_header:
|
|
397
|
+
rows.extend(self.head_rows)
|
|
398
|
+
rows.extend(self.body_rows)
|
|
399
|
+
if not omit_footer:
|
|
400
|
+
rows.extend(self.foot_rows)
|
|
401
|
+
return rows
|
|
402
|
+
|
|
403
|
+
# the type of a cell value entry
|
|
404
|
+
IndexedCellValueType = tuple[str, int, int, int, BS4Tag, Any]
|
|
405
|
+
|
|
406
|
+
# the type of a cell conversion function
|
|
407
|
+
IndexedCellConversionFunction = Callable[[str, int, int, int, BS4Tag], Any]
|
|
408
|
+
|
|
409
|
+
def as_indexed_values(
|
|
410
|
+
self,
|
|
411
|
+
*,
|
|
412
|
+
convert: IndexedCellConversionFunction | None = None,
|
|
413
|
+
convert_head_cell: IndexedCellConversionFunction | None = None,
|
|
414
|
+
convert_body_cell: IndexedCellConversionFunction | None = None,
|
|
415
|
+
convert_foot_cell: IndexedCellConversionFunction | None = None,
|
|
416
|
+
omit_header=False,
|
|
417
|
+
omit_footer=False,
|
|
418
|
+
) -> list[list[IndexedCellValueType]]:
|
|
419
|
+
''' Return the table contents as a list-of-lists of indexed cell values.
|
|
420
|
+
Each inner list contains the cell value records from a row.
|
|
421
|
+
|
|
422
|
+
this is an elaborate counterpart to the `as_lists` method.
|
|
423
|
+
|
|
424
|
+
Parameters:
|
|
425
|
+
* `convert`: the default cell conversion function
|
|
426
|
+
* `convert_head`: the header cell conversion function, default from `convert`
|
|
427
|
+
* `convert_body`: the body cell conversion function, default from `convert`
|
|
428
|
+
* `convert_foot`: the footer cell conversion function, default from `convert`
|
|
429
|
+
* `omit_header`: do not include rows from the `THEAD` section
|
|
430
|
+
* `omit_footer`: do not include rows from the `TFOOT` section
|
|
431
|
+
|
|
432
|
+
The conversion functions accept the following positional parameters:
|
|
433
|
+
* `section_type`: one of `"THEAD"`, `"TBODY"` or `"TFOOT"`
|
|
434
|
+
* `section_index`: the index of the section, 0 for the
|
|
435
|
+
header or footer but there may be multiple `TBODY` sections
|
|
436
|
+
* `row_index`: the index of the row within the section
|
|
437
|
+
* `col_index`: the index of the column within the row
|
|
438
|
+
* `cell`: the `TD` or `TH` tag for the cell
|
|
439
|
+
The function should return the converted value of `cell`.
|
|
440
|
+
The default conversion function returns `cell`.
|
|
441
|
+
|
|
442
|
+
The row and column indices supplied to the conversion unction
|
|
443
|
+
are of the _resolved_ cells, after expansion via the `colspan`
|
|
444
|
+
or `rowspan` values.
|
|
445
|
+
For example, a row with 3 cells whose second cell had a
|
|
446
|
+
`colspan=2` would be a list of 4 cells, with the second
|
|
447
|
+
original cell referenced in the second and third items of
|
|
448
|
+
the list; it _will_ be the same tag instance.
|
|
449
|
+
|
|
450
|
+
Each cell instance is converted only once; the same cell
|
|
451
|
+
spanning multiple columns or rows will have the same value
|
|
452
|
+
instance in the result record.
|
|
453
|
+
|
|
454
|
+
The resulting list-of-lists contains value records, a 6-tuple
|
|
455
|
+
of `(section_type,section_index,row_index,col_index,cell,value)`.
|
|
456
|
+
Note that the `row_index` and `col_index` are those of the
|
|
457
|
+
top left index where the `cell` was first encountered for
|
|
458
|
+
cells spanning multiple columns or rows.
|
|
459
|
+
|
|
460
|
+
Examples:
|
|
461
|
+
|
|
462
|
+
Convert every numeric cell to its `float` value, leave other cells as their text.
|
|
463
|
+
|
|
464
|
+
def as_float(section_type, section_index, row_index, column_index, cell):
|
|
465
|
+
text = cell.get_text.strip()
|
|
466
|
+
try:
|
|
467
|
+
value = float(text)
|
|
468
|
+
except ValueError:
|
|
469
|
+
value = text
|
|
470
|
+
return value
|
|
471
|
+
|
|
472
|
+
values = T.as_indexed_values(convert=as_float)
|
|
473
|
+
|
|
474
|
+
Convert only the body cells, keep the headers as tags, omit the footer:
|
|
475
|
+
|
|
476
|
+
values = T.as_indexed_values(convert_body_cell=as_float, omit_footer=True)
|
|
477
|
+
'''
|
|
478
|
+
if convert is None:
|
|
479
|
+
convert = (
|
|
480
|
+
lambda section_type, section_index, row_index, column_index, cell:
|
|
481
|
+
cell
|
|
482
|
+
)
|
|
483
|
+
if convert_head_cell is None:
|
|
484
|
+
convert_head_cell = convert
|
|
485
|
+
if convert_body_cell is None:
|
|
486
|
+
convert_body_cell = convert
|
|
487
|
+
if convert_foot_cell is None:
|
|
488
|
+
convert_foot_cell = convert
|
|
489
|
+
# cell_indicies={}
|
|
490
|
+
# mapping of id(tag) to (row_index,colum_index,converteed_value)
|
|
491
|
+
converted = {}
|
|
492
|
+
|
|
493
|
+
@pfx
|
|
494
|
+
def conv(
|
|
495
|
+
section_type, section_index, row_index, col_index, cell
|
|
496
|
+
) -> self.IndexedCellValueType:
|
|
497
|
+
cell_id = id(cell)
|
|
498
|
+
try:
|
|
499
|
+
value_record = converted[cell_id]
|
|
500
|
+
except KeyError:
|
|
501
|
+
if section_type == 'THEAD':
|
|
502
|
+
value = convert_head_cell(
|
|
503
|
+
section_type, section_index, row_index, col_index, cell
|
|
504
|
+
)
|
|
505
|
+
elif section_type == 'TBODY':
|
|
506
|
+
value = convert_body_cell(
|
|
507
|
+
section_type, section_index, row_index, col_index, cell
|
|
508
|
+
)
|
|
509
|
+
elif section_type == 'TFOOT':
|
|
510
|
+
value = convert_foot_cell(
|
|
511
|
+
section_type, section_index, row_index, col_index, cell
|
|
512
|
+
)
|
|
513
|
+
else:
|
|
514
|
+
raise RuntimeError(f'unhandled {section_type=}')
|
|
515
|
+
value_record = converted[cell_id] = (
|
|
516
|
+
section_type, section_index, row_index, col_index, cell, value
|
|
517
|
+
)
|
|
518
|
+
return value_record
|
|
519
|
+
|
|
520
|
+
rows = self.all_rows = []
|
|
521
|
+
if not omit_header:
|
|
522
|
+
for row_index, row in enumerate(self.head_rows):
|
|
523
|
+
rows.append(
|
|
524
|
+
[
|
|
525
|
+
conv('THEAD', 0, row_index, col_index, cell)
|
|
526
|
+
for col_index, cell in enumerate(row)
|
|
527
|
+
]
|
|
528
|
+
)
|
|
529
|
+
for body_index, body in enumerate(self.tbodies):
|
|
530
|
+
for row_index, row in enumerate(self.section_rows(body)):
|
|
531
|
+
rows.append(
|
|
532
|
+
[
|
|
533
|
+
conv('TBODY', body_index, row_index, col_index, cell)
|
|
534
|
+
for col_index, cell in enumerate(row)
|
|
535
|
+
]
|
|
536
|
+
)
|
|
537
|
+
if not omit_footer:
|
|
538
|
+
for row_index, row in enumerate(self.foot_rows):
|
|
539
|
+
rows.append(
|
|
540
|
+
[
|
|
541
|
+
conv('TFOOT', 0, row_index, col_index, cell)
|
|
542
|
+
for col_index, cell in enumerate(row)
|
|
543
|
+
]
|
|
544
|
+
)
|
|
545
|
+
return rows
|
|
546
|
+
|
|
547
|
+
@cached_property
|
|
548
|
+
def all_rows(self) -> list[list[BS4Tag]]:
|
|
549
|
+
''' Return all the rows from the header, bodies, and footer.
|
|
550
|
+
'''
|
|
551
|
+
return self.as_lists()
|
|
552
|
+
|
|
553
|
+
@cached_property
|
|
554
|
+
def title(self):
|
|
555
|
+
''' The title of the table, from the caption or the nearest heading.
|
|
556
|
+
'''
|
|
557
|
+
if self.caption:
|
|
558
|
+
title = self.caption.get_text()
|
|
559
|
+
else:
|
|
560
|
+
h = find_heading(self.tag)
|
|
561
|
+
if h:
|
|
562
|
+
title = h.get_text().strip()
|
|
563
|
+
else:
|
|
564
|
+
title = None
|
|
565
|
+
return title
|
|
566
|
+
|
|
567
|
+
def printt(self):
|
|
568
|
+
''' Print the table text.
|
|
569
|
+
'''
|
|
570
|
+
seen_ids = set()
|
|
571
|
+
|
|
572
|
+
def row_trow(row):
|
|
573
|
+
''' Render a row of cells for the table.
|
|
574
|
+
The row should have come from `section_rows` i.e. the
|
|
575
|
+
`colspan` is already applied.
|
|
576
|
+
'''
|
|
577
|
+
trow = []
|
|
578
|
+
for i, cell in enumerate(row):
|
|
579
|
+
if id(cell) in seen_ids:
|
|
580
|
+
trow.append("")
|
|
581
|
+
else:
|
|
582
|
+
seen_ids.add(id(cell))
|
|
583
|
+
trow.append(cell.get_text())
|
|
584
|
+
return trow
|
|
585
|
+
|
|
586
|
+
def section_trows(rows):
|
|
587
|
+
''' Render the rows of a section, each of whose rows should
|
|
588
|
+
have come from `section_rows` i.e. the `colspan` is already applied.
|
|
589
|
+
'''
|
|
590
|
+
|
|
591
|
+
table = []
|
|
592
|
+
heading = self.title or self.tag.name.upper()
|
|
593
|
+
table.append([heading])
|
|
594
|
+
if self.thead:
|
|
595
|
+
table.extend(((*map(row_trow, self.head_rows),),))
|
|
596
|
+
for tbody in self.tbodies:
|
|
597
|
+
table.extend(((*map(row_trow, self.section_rows(tbody)),),))
|
|
598
|
+
if self.tfoot:
|
|
599
|
+
table.extend(((*map(row_trow, self.foot_rows),),))
|
|
600
|
+
##print(self.tag.prettify())
|
|
601
|
+
##pprint(table)
|
|
602
|
+
printt(*table)
|
|
603
|
+
|
|
604
|
+
if __name__ == '__main__':
|
|
605
|
+
for html in ('foo', '<h1>foo</h1>', '''
|
|
606
|
+
<html>
|
|
607
|
+
<head>
|
|
608
|
+
<title>title here</title>
|
|
609
|
+
</head>
|
|
610
|
+
<body>
|
|
611
|
+
<h1 id="3" attr="zot" attr2="2">heading 1</h1>
|
|
612
|
+
body here
|
|
613
|
+
<h1>second heading</h1>
|
|
614
|
+
second
|
|
615
|
+
third
|
|
616
|
+
</body>
|
|
617
|
+
</html>
|
|
618
|
+
''', '''
|
|
619
|
+
<H1>H1 HEADING</H1>
|
|
620
|
+
<TABLE>
|
|
621
|
+
<THEAD><TR><TD>heaing 1<TD>heading 2
|
|
622
|
+
<TBODY><TR><TD>Label<TD ROWSPAN="2">9.5
|
|
623
|
+
<TR>
|
|
624
|
+
<TR><TD>3<TD>4
|
|
625
|
+
<TFOOT><TR><TD>foot1<TD>5
|
|
626
|
+
</TABLE>
|
|
627
|
+
'''):
|
|
628
|
+
print("======================================")
|
|
629
|
+
print(html)
|
|
630
|
+
print("--------------------------------------")
|
|
631
|
+
soup = BeautifulSoup(html, features="lxml")
|
|
632
|
+
printt_soup(soup)
|
|
633
|
+
for table in Table.scan(soup):
|
|
634
|
+
print()
|
|
635
|
+
table.printt()
|
|
636
|
+
|
|
637
|
+
for row in table.as_lists():
|
|
638
|
+
print(*map(type, row))
|
|
639
|
+
|
|
640
|
+
def as_float(section_type, section_index, row_index, col_index, cell):
|
|
641
|
+
text = cell.get_text().strip()
|
|
642
|
+
try:
|
|
643
|
+
return float(text)
|
|
644
|
+
except (TypeError, ValueError):
|
|
645
|
+
return text
|
|
646
|
+
|
|
647
|
+
for row_index, row in enumerate(table.as_indexed_values(
|
|
648
|
+
convert_body_cell=as_float, omit_footer=True)):
|
|
649
|
+
for col_index, record in enumerate(row):
|
|
650
|
+
section_type = record[0]
|
|
651
|
+
value = record[-1]
|
|
652
|
+
print(row_index, col_index, section_type, type(value), value)
|